Bladeren bron

feat(workspace): 工作区查重 + 删除二次确认 + 操作图标常驻

1. 重名校验
- WorkspaceService.hasName(name, exceptId?) 忽略大小写与首尾空白
- create/rename 均挡重名,重命名排除自己
- PromptModalService 新增 validate 钩子:校验失败不关闭弹窗并显示错误
  (原先点了没反应,用户不知道哪里不对)

2. 删除工作区二次确认
- ui-modal 说明「其中 N 条对话会被移回日常对话,不会被删除」

3. 图标常驻
- 工作区行的新建/重命名/删除从 display:none 悬停显示改为常驻
- 分组头「新建工作区」+ 从 opacity:0 改为常驻

验证:tsc 0 错误;单测 1089/1089;构建通过;verify:ui 12/12

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 23 uur geleden
bovenliggende
commit
a85af69d2a

+ 28 - 0
docs/00-tracking/操作追踪.md

@@ -3767,3 +3767,31 @@ agent-lj 的 8 个历史会话索引在墓碑中,Matrix 消息完好未删。
   (新增:再次新建清掉旧的 / 跨归属也只留一条 / 已发消息的会话不被清 /
     未落库会话清理不发云端请求)
 - 构建通过;verify:ui 12/12
+
+## 2026-11-01 第十九轮:工作区查重 / 删除确认 / 图标常驻
+
+### 1. 工作区重名校验
+- `WorkspaceService.hasName(name, exceptId?)`:忽略大小写与首尾空白
+- `create()` / `rename()` 均挡重名;重命名时排除自己
+- `PromptModalService` 新增 `validate` 钩子:校验失败**不关闭弹窗**并显示错误文案
+  (原先点了没反应,用户不知道哪里不对)
+
+### 2. 删除工作区二次确认
+- 点删除图标 → `ui-modal` 确认框,说明「其中 N 条对话会被移回日常对话,不会被删除」
+- 无对话时说明「这个工作区里没有对话」
+
+### 3. 图标常驻显示
+| 位置 | 原来 | 现在 |
+|---|---|---|
+| 工作区行(新建对话/重命名/删除) | `display:none` 悬停才出现 | 常驻 |
+| 分组头「新建工作区」+ | `opacity:0` 悬停才出现 | 常驻 |
+
+用户根本不知道这些入口存在,只能靠碰运气把鼠标移上去。
+
+### 踩坑(第三次)
+CSS 模板字符串的注释里用反引号包标识符(如 `` `opacity: 0` ``)会**提前闭合字符串**,
+报错表现为 `TS1005: ',' expected`,与真实原因完全无关。注释中引用代码一律不加反引号。
+
+### 验证
+- tsc 0 错误;单测 **1089/1089**(新增 4 条工作区重名用例)
+- 构建通过;verify:ui 12/12

+ 28 - 2
src/app/components/domain/prompt-modal/prompt-modal.service.ts

@@ -17,6 +17,14 @@ export interface PromptRequest {
   confirmText: string;
   /** 空值是否允许提交(搜索允许空 = 清空筛选;新建工作区不允许) */
   allowEmpty: boolean;
+  /**
+   * 提交前校验(2026-11-01)。
+   *
+   * 返回**错误文案**表示拒绝提交:弹窗保持打开并显示该文案,
+   * 让用户当场改,而不是「点了没反应、不知道哪里不对」。
+   * 返回 `null` 表示通过。
+   */
+  validate?: (value: string) => string | null;
   /** 提交回调 */
   onConfirm: (value: string) => void;
 }
@@ -37,18 +45,36 @@ export class PromptModalService {
   /** 当前请求(null = 关闭) */
   readonly request = this._request.asReadonly();
 
+  /** 校验失败的错误文案(null = 无错误);弹窗据此在输入框下方提示 */
+  private readonly _error = signal<string | null>(null);
+  readonly error = this._error.asReadonly();
+
   open(req: PromptRequest): void {
+    this._error.set(null);
     this._request.set(req);
   }
 
-  /** 确认:先关弹窗再执行回调,避免回调里再开一个弹窗时被自己覆盖 */
+  /**
+   * 确认:先校验,再关弹窗执行回调。
+   *
+   * 校验不通过时**不关闭**,把错误文案显示出来 —— 否则用户点「创建」没反应,
+   * 只能猜是哪里出了问题(重名、空名这类规则必须当场可见)。
+   */
   confirm(value: string): void {
     const req = this._request();
+    if (!req) return;
+    const err = req.validate?.(value) ?? null;
+    if (err) {
+      this._error.set(err);
+      return;
+    }
+    this._error.set(null);
     this._request.set(null);
-    req?.onConfirm(value);
+    req.onConfirm(value);
   }
 
   close(): void {
+    this._error.set(null);
     this._request.set(null);
   }
 }

+ 2 - 0
src/app/components/domain/prompt-modal/prompt-modal.ts

@@ -40,6 +40,8 @@ import { PromptModalService } from './prompt-modal.service';
         />
         @if (!req.allowEmpty && !value().trim()) {
           <p class="hint">名称不能为空</p>
+        } @else if (service.error(); as err) {
+          <p class="hint" role="alert">{{ err }}</p>
         }
         <div footer class="acts">
           <ui-button variant="ghost" (click)="close()">取消</ui-button>

+ 79 - 13
src/app/components/domain/session-tree/session-tree.ts

@@ -503,6 +503,29 @@ export interface NewSessionRequest {
       }
     </nav>
 
+    <!-- 删除工作区确认(2026-11-01) -->
+    <ui-modal
+      [open]="removingWorkspace() !== null"
+      title="删除这个工作区?"
+      size="sm"
+      (openChange)="$event || removingWorkspace.set(null)"
+    >
+      <p class="rm-note">
+        将删除工作区「{{ removingWorkspace()?.name }}」。
+        @if (removingWorkspace(); as rw) {
+          @if (sessionCountIn(rw.id) > 0) {
+            其中的 <strong>{{ sessionCountIn(rw.id) }} 条对话会被移回「日常对话」,不会被删除</strong>。
+          } @else {
+            这个工作区里没有对话。
+          }
+        }
+      </p>
+      <div class="rm-acts">
+        <ui-button variant="ghost" (click)="removingWorkspace.set(null)">取消</ui-button>
+        <ui-button variant="danger" (click)="doDeleteWorkspace()">删除工作区</ui-button>
+      </div>
+    </ui-modal>
+
     <!-- 归档确认(2026-11-01 用户要求:归档也要二次提示) -->
     <ui-modal
       [open]="archiving() !== null"
@@ -1014,16 +1037,23 @@ export interface NewSessionRequest {
         font-weight: 500;
         color: var(--muted);
         letter-spacing: 0.2px;
+        /* 名字占满剩余空间,「+」被推到最右 */
+        flex: 1;
       }
-      /* 分组标题的「+」默认不显示,悬停分组头才出现 —— 常驻一排加号会抢注意力 */
+      /*
+       * 分组头的「+」**常驻显示**(2026-11-01 用户要求)。
+       *
+       * 原先是 opacity: 0 悬停才出现 —— 用户根本不知道有「新建工作区」这个入口。
+       * 图标体积小,常驻不会抢注意力,反而让入口可发现。
+       */
       .sect-head ui-button {
-        opacity: 0;
-        transition: opacity var(--motion-fast) var(--ease-standard);
-      }
-      .sect-head:hover ui-button,
-      .sect-head:focus-within ui-button {
         opacity: 1;
       }
+      /* 计数紧跟名字,不与「+」抢位置 */
+      .sect-count {
+        font-size: var(--fs-11);
+        color: var(--faint);
+      }
       .sect-count {
         font-size: var(--fs-11);
         color: var(--faint);
@@ -1151,15 +1181,18 @@ export interface NewSessionRequest {
         flex: 1;
         min-width: 0;
       }
+      /*
+       * 工作区操作图标**常驻显示**(2026-11-01 用户要求)。
+       *
+       * 原先是 display:none + 悬停/聚焦才出现 —— 用户不知道文件夹上还有这些操作,
+       * 只能靠"碰运气"把鼠标移上去。侧栏本来就窄,三颗小图标常驻不会造成拥挤,
+       * 换来的是「看得见就能点」。
+       */
       .ws-acts {
-        display: none;
+        display: flex;
         gap: 0;
         flex-shrink: 0;
       }
-      .ws-head:hover .ws-acts,
-      .ws-head:focus-within .ws-acts {
-        display: flex;
-      }
       .ws-body {
         padding-left: var(--sp-4);
       }
@@ -1841,6 +1874,13 @@ export class SessionTreeComponent {
       value: '',
       confirmText: '创建',
       allowEmpty: false,
+      /*
+       * 🔴 重名校验放在提交前(2026-11-01 用户要求)。
+       *    两个同名文件夹在侧栏里完全无法区分,用户点了也不知道进了哪个。
+       *    校验失败时弹窗不关闭、当场提示,而不是静默失效。
+       */
+      validate: (name) =>
+        this.workspaceSvc.hasName(name) ? `已经有叫「${name.trim()}」的工作区了` : null,
       onConfirm: (name) => this.workspaceSvc.create(name),
     });
   }
@@ -1890,6 +1930,9 @@ export class SessionTreeComponent {
       value: w.name,
       confirmText: '保存',
       allowEmpty: false,
+      // 重命名同样查重(排除自己,否则「不改名直接保存」会被误判)
+      validate: (name) =>
+        this.workspaceSvc.hasName(name, w.id) ? `已经有叫「${name.trim()}」的工作区了` : null,
       onConfirm: (name) => this.workspaceSvc.rename(w.id, name),
     });
   }
@@ -1900,12 +1943,35 @@ export class SessionTreeComponent {
    * 🔴 **不删其中的会话** —— 把会话退回「日常对话」。
    *    连带删除会让用户看不到的东西被静默销毁,这是数据丢失,不是"整理"。
    */
+  /**
+   * 删除工作区 —— **二次确认**(2026-11-01 用户要求)。
+   *
+   * 删除工作区不是纯 UI 操作:它下面若有对话,会被移回「日常对话」。
+   * 不说清楚就直接删,用户会以为对话一起没了。
+   */
   protected deleteWorkspace(id: string, e: Event): void {
     e.stopPropagation();
-    for (const s of this.sessionsSvc.all().filter((x) => x.workspaceId === id)) {
+    const w = this.workspaceSvc.byId(id);
+    if (w) this.removingWorkspace.set(w);
+  }
+
+  /** 待删除的工作区(非 null = 确认框打开) */
+  protected readonly removingWorkspace = signal<Workspace | null>(null);
+
+  /** 确认删除工作区:其下会话退回日常对话 */
+  protected doDeleteWorkspace(): void {
+    const w = this.removingWorkspace();
+    this.removingWorkspace.set(null);
+    if (!w) return;
+    for (const s of this.sessionsSvc.all().filter((x) => x.workspaceId === w.id)) {
       this.sessionsSvc.moveTo(s.id, null);
     }
-    this.workspaceSvc.remove(id);
+    this.workspaceSvc.remove(w.id);
+  }
+
+  /** 该工作区下的会话数(确认框里说明用) */
+  protected sessionCountIn(wsId: string): number {
+    return this.sessionsSvc.all().filter((s) => s.workspaceId === wsId).length;
   }
 
   // ---- 会话操作 ----

+ 34 - 0
src/app/core/workspace.service.spec.ts

@@ -36,6 +36,40 @@ describe('WorkspaceService', () => {
     expect(svc.all().length).toBe(before);
   });
 
+  /*
+   * 🔴 重名约束(2026-11-01 用户要求)。
+   *
+   * 侧栏是「文件夹」导航,两个同名文件夹用户完全分不清点的是哪个,
+   * 必须挡在数据层,不能只靠输入框提示。
+   */
+  describe('工作区重名校验', () => {
+    it('🔴 同名工作区不能重复创建', () => {
+      svc.create('项目 A');
+      expect(svc.create('项目 A')).withContext('同名应被拒绝').toBeNull();
+      expect(svc.all().length).toBe(1);
+    });
+
+    it('🔴 忽略大小写与首尾空白', () => {
+      svc.create('Test');
+      expect(svc.create('  test  ')).toBeNull();
+      expect(svc.all().length).toBe(1);
+    });
+
+    it('重命名成已存在的名字会被忽略(保留原名)', () => {
+      const a = svc.create('甲')!;
+      svc.create('乙');
+      svc.rename(a.id, '乙');
+      expect(svc.byId(a.id)?.name).withContext('重名不应生效').toBe('甲');
+    });
+
+    it('重命名时排除自己(不改名直接保存不算重名)', () => {
+      const a = svc.create('甲')!;
+      expect(svc.hasName('甲', a.id)).toBeFalse();
+      svc.rename(a.id, '甲');
+      expect(svc.byId(a.id)?.name).toBe('甲');
+    });
+  });
+
   it('写入 localStorage 后重新载入可恢复(持久化生效)', () => {
     const w = svc.create('持久化')!;
     // 模拟刷新:清内存态后重新载入

+ 22 - 1
src/app/core/workspace.service.ts

@@ -126,7 +126,14 @@ export class WorkspaceService {
     this.persist();
   }
 
-  /** 新建工作区,返回新条目;名字为空时**不创建**并返回 null */
+  /**
+   * 新建工作区,返回新条目;名字为空或**重名**时返回 null。
+   *
+   * 🔴 重名检查(2026-11-01 用户要求):
+   *    侧栏是「文件夹」形态的导航,两个同名文件夹用户根本分不清该点哪个,
+   *    也看不出区别 —— 属于必须挡在数据层的约束,不能只靠输入框提示。
+   *    比较时忽略大小写与首尾空白(「Test」与「test 」视为同名)。
+   */
   create(name: string): Workspace | null {
     const clean = name.trim();
     /*
@@ -137,6 +144,7 @@ export class WorkspaceService {
      * 界面上就是一个点不开、改不回来的空文件夹(单测实测暴露)。
      */
     if (!clean) return null;
+    if (this.hasName(clean)) return null;
     const w: Workspace = {
       id: `ws_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 7)}`,
       name: clean,
@@ -148,9 +156,22 @@ export class WorkspaceService {
     return w;
   }
 
+  /**
+   * 是否已存在同名工作区(忽略大小写/首尾空白)。
+   *
+   * @param exceptId 重命名时排除自己,否则「不改名直接保存」会被判成重名
+   */
+  hasName(name: string, exceptId?: string): boolean {
+    const key = name.trim().toLowerCase();
+    if (!key) return false;
+    return this._all().some((w) => w.id !== exceptId && w.name.trim().toLowerCase() === key);
+  }
+
   rename(id: string, name: string): void {
     const next = name.trim();
     if (!next) return;
+    // 重名不生效(保留原名)
+    if (this.hasName(next, id)) return;
     this._all.update((list) => list.map((w) => (w.id === id ? { ...w, name: next } : w)));
     this.persist();
     const w = this.byId(id);