import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core'; import { NavigationEnd, Router, RouterLink } from '@angular/router'; import { UiAvatar, UiTooltip } from '../../../ui'; import { LiveService } from '../../../core/live.service'; import { SessionService } from '../../../core/session.service'; import { DeviceService } from '../../../core/device.service'; import { agentStatusTone } from '../../../core/api/models'; interface NavItem { path: string; label: string; icon: string; } interface NavGroup { /** 分组标题;空字符串表示不显示标题(主导航组) */ label: string; items: NavItem[]; } /** * 桌面主导航 —— 深墨色面板(视觉锚点,颜色随配色整套变化) * * ## ⚠️ 本组件已不在任何界面中挂载(2026-11-11 核实) * * 桌面外壳 `desktop-shell.ts` 引用的是 `SessionTreeComponent`(会话树 + 一级菜单), * **不引用本组件**。真正的左栏菜单数据源是 `core/nav-menu.service.ts` 的 `NAV_CATALOG`。 * * 🔴 改这里**不会**影响界面 —— 2026-11-11 曾把「工作台」在这里注释掉, * 界面毫无变化,白改一轮。调整菜单请改 `NAV_CATALOG`。 * * 现存价值:`navItemActive` 纯函数被 `session-tree.ts` 复用(判断路由是否激活), * 所以文件保留。改菜单时请同步本文件,避免日后有人从这里「恢复」出 * 一套与 NAV_CATALOG 不一致的入口。 * * 结构调整(2026-10-15 用户反馈): * · **二级分组导航**:业务 / 资产 / 运维 / 我的 —— 解决"导航过长 + 找不到任务看板" * · **补入任务看板入口**(用户反馈:不知道从哪进) * · **底部用户卡可点击** → 进「我的」(原为纯展示,是逻辑缺陷) * · 折叠功能保留(图标态 ↔ 展开态),状态持久化(兼容旧键 `fmode_dk_sidebar_collapsed`) * * 视觉:选中态用 `--nav-*` 族,**不用 --primary**(避免与内容区主色语义混淆) */ @Component({ selector: 'app-dk-nav', changeDetection: ChangeDetectionStrategy.OnPush, imports: [RouterLink, UiAvatar, UiTooltip], template: ` `, styles: [ ` :host { display: block; height: 100%; /* 网格项默认 min-height:auto —— 导航项多时会把侧栏撑高、带出整页滚动 */ min-height: 0; overflow: hidden; } .sidebar { display: flex; flex-direction: column; height: 100%; width: var(--desk-nav-w); background: var(--nav-surface); color: var(--nav-text); overflow: hidden; } .sidebar.collapsed { width: var(--desk-nav-w-icon); } /* 折叠态:品牌行与用户卡都居中,避免元素溢出/错位 */ .sidebar.collapsed .brand-row { position: relative; justify-content: center; padding: var(--sp-3) 0 var(--sp-2); } .sidebar.collapsed .nav-scroll { padding: 0 var(--sp-2) var(--sp-3); } .sidebar.collapsed .nav-items { justify-items: center; } .sidebar.collapsed .nav-link { width: var(--touch-min); margin: 0 auto; border-radius: var(--r-sm); } .brand-row { display: flex; align-items: center; gap: var(--sp-2); /* 收紧顶部留白 —— 导航整体上移(用户要求重心更靠上) */ padding: var(--sp-3) var(--sp-3) var(--sp-2); flex-shrink: 0; } .brand-mark { display: inline-flex; align-items: center; justify-content: center; width: 32px; height: 32px; flex-shrink: 0; border-radius: var(--r-md); background: var(--nav-brand); color: var(--nav-on-brand); } .brand-word { font-size: var(--fs-20); font-weight: 650; letter-spacing: -0.8px; color: var(--nav-ink); } /* 折叠钮:始终位于品牌行**最右侧**(展开时在顶部右侧,不挤占品牌位) */ .collapse-btn { margin-left: auto; width: 32px; height: 32px; display: grid; place-items: center; border: 0; border-radius: var(--r-sm); background: transparent; color: var(--nav-muted); cursor: pointer; flex-shrink: 0; } .collapse-btn:hover { background: var(--nav-hover); color: var(--nav-ink); } .sidebar.collapsed .collapse-btn { position: absolute; top: var(--sp-2); right: var(--sp-2); width: 24px; height: 24px; margin-left: 0; border-radius: var(--r-tag); background: var(--nav-hover); } .nav-scroll { flex: 1; min-height: 0; overflow-y: auto; overflow-x: hidden; padding: 0 var(--sp-2) var(--sp-3); } /* 二级分组标题 */ .group-label { margin: 0; /* 分组间距收紧:nav 整体重心上移(用户要求) */ padding: var(--sp-3) var(--sp-2) var(--sp-1); font-size: var(--fs-11); color: var(--nav-muted); letter-spacing: 0.5px; } .group-label:first-child { padding-top: var(--sp-2); } .group-divider { margin: var(--sp-2) var(--sp-2); border: 0; border-top: 1px solid var(--nav-line); } .nav-items { list-style: none; margin: 0; padding: 0; display: grid; gap: 2px; } .nav-link { position: relative; display: flex; align-items: center; gap: var(--sp-3); min-height: 40px; padding: 0 var(--sp-3); border-radius: var(--r-sm); color: var(--nav-text); font-size: var(--fs-13); text-decoration: none; transition: background-color var(--motion-fast) var(--ease-standard); } .sidebar.collapsed .nav-link { justify-content: center; padding: 0; min-height: var(--touch-min); } .nav-link:hover { background: var(--nav-hover); color: var(--nav-ink); } /* 选中态:仅用选中底色 + 加粗(用户反馈:不要左侧/右侧的竖条色块) */ .nav-link.active { background: var(--nav-active); color: var(--nav-ink); font-weight: 600; } .nav-count { margin-left: auto; min-width: 20px; text-align: center; font-size: var(--fs-11); border-radius: var(--r-sm); background: var(--nav-accent); color: var(--nav-surface); } .dot-badge { position: absolute; top: 8px; right: 8px; width: 8px; height: 8px; border-radius: var(--r-tag); background: var(--danger); } .alert-dot { width: 8px; height: 8px; border-radius: var(--r-tag); flex-shrink: 0; } .alert-dot.tone-danger { background: var(--danger); } .alert-dot.tone-warning { background: var(--warning); } .alert-dot.tone-success { background: var(--success); } .alert-dot.tone-neutral { background: var(--nav-muted); } /* 设置入口:底部单一入口(原用户卡已收进「我的」页) */ .settings-entry { display: flex; align-items: center; gap: var(--sp-2); min-height: var(--touch-min); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--nav-line); color: var(--nav-muted); text-decoration: none; flex-shrink: 0; font-size: var(--fs-13); } .settings-entry:hover { background: var(--nav-hover); color: var(--nav-ink); } .settings-entry.active { background: var(--nav-active); color: var(--nav-ink); font-weight: 600; } .settings-entry:focus-visible { outline: 2px solid var(--primary); outline-offset: -2px; } .sidebar.collapsed .settings-entry { justify-content: center; padding-left: 0; padding-right: 0; } .settings-entry > i { width: 16px; text-align: center; flex-shrink: 0; } .settings-label { min-width: 0; } .ellipsis { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } `, ], }) export class DkNavComponent { private readonly live = inject(LiveService); private readonly session = inject(SessionService); private readonly device = inject(DeviceService); private readonly router = inject(Router); readonly collapsed = input(false); constructor() { // 路由变化 → 更新高亮。用 events 而不是 router.url,保证切页后立刻刷新 this.router.events.subscribe((e) => { if (e instanceof NavigationEnd) this._url.set(e.urlAfterRedirects); }); this._url.set(this.router.url); } /** * 二级分组导航(用户裁决 2026-10-15) * · 业务:日常沟通与协作 * · 资产:产出物 * · 运维:运行状态与任务(P2/P3 画像的核心场景) */ readonly groups: NavGroup[] = [ { label: '业务', items: [ { path: '/chat', label: '会话', icon: 'fa-comment' }, /* * 🔒 工作组导航项 —— 2026-10-29 起暂时下线。 * 多容器协作依赖容器侧配置(REQUIRE_MENTION / 名称前缀 / 会话上报), * 当前环境下「组长派活」不成立,属于假象。恢复时取消注释即可 * (路由见 `desktop.routes.ts` 的对应说明)。 */ // { path: '/groups', label: '工作组', icon: 'fa-users' }, { path: '/contacts', label: '通讯录', icon: 'fa-address-book' }, /* * 🔒 2026-11-11 用户裁决:项目 / 任务 / 事件 / 成果 入口关闭。 * 与 `nav-menu.service.ts` 的 NAV_CATALOG 保持一致(本文件是死代码,见类注释)。 */ // { path: '/projects', label: '项目', icon: 'fa-folder' }, // { path: '/events', label: '事件', icon: 'fa-bolt' }, ], }, { label: '资产', items: [ // { path: '/deliverables', label: '成果', icon: 'fa-box' }, ], }, { label: '运维', items: [ { path: '/workbench', label: '工作台', icon: 'fa-table-cells-large' }, // { path: '/tasks', label: '任务看板', icon: 'fa-list-check' }, ], }, { label: '', items: [{ path: '/mine', label: '我的', icon: 'fa-user' }], }, ]; readonly meName = computed(() => this.session.me()?.name ?? '未登录'); readonly meMobile = computed(() => this.session.me()?.mobile ?? ''); readonly blockedAgents = computed(() => this.live.agents().filter((a) => a.status === 'error' || a.status === 'offline'), ); /** * 导航项是否高亮。 * * 🔴 不用 `routerLinkActive`:它按前缀匹配,`/chat/xxx` 命中 `/chat` 没问题, * 但群聊页会用 `navigateByUrl` 跳到 `/groups/!roomId`, * 实测此时「工作组」不高亮、「会话」反而亮着。 * 改为**基于当前 URL 显式判断**,行为可预期、可单测。 */ /** 当前路由(用于导航高亮,随 NavigationEnd 更新) */ private readonly _url = signal(''); private readonly url = this._url.asReadonly(); /** * 导航项是否高亮。 * * 🔴 **只用 `_url` 这个 signal**,不要回落到 `this.router.url` —— * 后者是普通属性,OnPush 下值变了视图也不会重算, * 导致部分入口(如底部的「设置」)点了不高亮(实测)。 */ isActive(path: string): boolean { return navItemActive(this.url(), this.device.mode(), path); } link(path: string): string { return `/${this.device.mode()}${path}`; } agentLink(id: string): string { return `/${this.device.mode()}/agent/${id}`; } toneOf(status: string): string { return agentStatusTone(status as never); } /** 会话项显示未读总数;其余模块暂无计数 */ badgeFor(path: string): number { if (path !== '/chat') return 0; return this.live.agents().reduce((n, a) => n + (a.unread ?? 0), 0); } toggleCollapse(): void { const next = !this.collapsed(); try { // 兼容存量键名(FR-16 AC-16.1) localStorage.setItem('fmode_dk_sidebar_collapsed', next ? '1' : '0'); } catch { /* 忽略 */ } this.collapseChange.emit(next); } /** 折叠状态变更(Angular output,外壳用 (collapseChange) 绑定) */ readonly collapseChange = output(); } /** * 导航项是否高亮 —— **纯函数**,便于单测。 * * ## 🔴 为什么不用 `routerLinkActive`(2026-10-27 实测) * * ``` * 点「工作组」→ URL 变成 /desktop/groups/!roomId * → 但左侧仍高亮「会话」 * ``` * * 两个原因叠加: * ① `routerLinkActive` 是前缀匹配,判断依据是「当前 URL 是否以 link 开头」 * ② 群聊页在挂载后用 `navigateByUrl` 二次跳转,导航组件的 * `NavigationEnd` 订阅没收到那次事件(组件初始化早于跳转) * * 改为**基于实时 URL 显式判断**:行为可预期,且能直接用单测锁住。 * * @param url 当前路由(如 `/desktop/groups/!abc`) * @param mode 端(`desktop` / `mobile`) * @param path 导航项路径(如 `/groups`) */ export function navItemActive(url: string, mode: string, path: string): boolean { const base = `/${mode}`; // 去掉 query / fragment,避免 `/desktop/chat?x=1` 判不中 const clean = url.split('?')[0].split('#')[0]; if (path === '/chat') { /* * 会话:/chat、/chat/:id、/chat/:id/:sessionId 都算。 * 另外**容器详情 /agent/:id 也归到「会话」**——它是从会话页点齿轮进来的, * 返回后用户期望回到会话;若不高亮,导航会「全都没有选中」, * 用户不知道自己在哪一级(实测:进详情页后左栏全部熄灭)。 */ return ( clean === `${base}/chat` || clean.startsWith(`${base}/chat/`) || clean === `${base}/agent` || clean.startsWith(`${base}/agent/`) ); } // 其余:精确或子路径都算 return clean === `${base}${path}` || clean.startsWith(`${base}${path}/`); }