dk-nav.ts 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539
  1. import { ChangeDetectionStrategy, Component, computed, inject, input, output, signal } from '@angular/core';
  2. import { NavigationEnd, Router, RouterLink } from '@angular/router';
  3. import { UiAvatar, UiTooltip } from '../../../ui';
  4. import { LiveService } from '../../../core/live.service';
  5. import { SessionService } from '../../../core/session.service';
  6. import { DeviceService } from '../../../core/device.service';
  7. import { agentStatusTone } from '../../../core/api/models';
  8. interface NavItem {
  9. path: string;
  10. label: string;
  11. icon: string;
  12. }
  13. interface NavGroup {
  14. /** 分组标题;空字符串表示不显示标题(主导航组) */
  15. label: string;
  16. items: NavItem[];
  17. }
  18. /**
  19. * 桌面主导航 —— 深墨色面板(视觉锚点,颜色随配色整套变化)
  20. *
  21. * ## ⚠️ 本组件已不在任何界面中挂载(2026-11-11 核实)
  22. *
  23. * 桌面外壳 `desktop-shell.ts` 引用的是 `SessionTreeComponent`(会话树 + 一级菜单),
  24. * **不引用本组件**。真正的左栏菜单数据源是 `core/nav-menu.service.ts` 的 `NAV_CATALOG`。
  25. *
  26. * 🔴 改这里**不会**影响界面 —— 2026-11-11 曾把「工作台」在这里注释掉,
  27. * 界面毫无变化,白改一轮。调整菜单请改 `NAV_CATALOG`。
  28. *
  29. * 现存价值:`navItemActive` 纯函数被 `session-tree.ts` 复用(判断路由是否激活),
  30. * 所以文件保留。改菜单时请同步本文件,避免日后有人从这里「恢复」出
  31. * 一套与 NAV_CATALOG 不一致的入口。
  32. *
  33. * 结构调整(2026-10-15 用户反馈):
  34. * · **二级分组导航**:业务 / 资产 / 运维 / 我的 —— 解决"导航过长 + 找不到任务看板"
  35. * · **补入任务看板入口**(用户反馈:不知道从哪进)
  36. * · **底部用户卡可点击** → 进「我的」(原为纯展示,是逻辑缺陷)
  37. * · 折叠功能保留(图标态 ↔ 展开态),状态持久化(兼容旧键 `fmode_dk_sidebar_collapsed`)
  38. *
  39. * 视觉:选中态用 `--nav-*` 族,**不用 --primary**(避免与内容区主色语义混淆)
  40. */
  41. @Component({
  42. selector: 'app-dk-nav',
  43. changeDetection: ChangeDetectionStrategy.OnPush,
  44. imports: [RouterLink, UiAvatar, UiTooltip],
  45. template: `
  46. <nav class="sidebar" [class.collapsed]="collapsed()" aria-label="主导航">
  47. <!-- 品牌 + 折叠钮 -->
  48. <div class="brand-row">
  49. <span class="brand-mark" aria-hidden="true"><i class="fa-solid fa-cube"></i></span>
  50. @if (!collapsed()) {
  51. <span class="brand-word">Fmode</span>
  52. }
  53. <button
  54. type="button"
  55. class="collapse-btn"
  56. [attr.aria-label]="collapsed() ? '展开侧栏' : '收起侧栏'"
  57. [attr.aria-expanded]="!collapsed()"
  58. (click)="toggleCollapse()"
  59. >
  60. <i class="fa-solid" [class]="collapsed() ? 'fa-angles-right' : 'fa-angles-left'"></i>
  61. </button>
  62. </div>
  63. <!-- 二级分组导航 -->
  64. <div class="nav-scroll">
  65. @for (g of groups; track g.label) {
  66. @if (g.label && !collapsed()) {
  67. <p class="group-label">{{ g.label }}</p>
  68. }
  69. @if (g.label && collapsed()) {
  70. <hr class="group-divider" />
  71. }
  72. <ul class="nav-items">
  73. @for (item of g.items; track item.path) {
  74. <li>
  75. @if (collapsed()) {
  76. <ui-tooltip [content]="item.label" position="right">
  77. <a
  78. class="nav-link"
  79. [routerLink]="link(item.path)"
  80. [class.active]="isActive(item.path)"
  81. [attr.aria-label]="item.label"
  82. >
  83. <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
  84. @if (badgeFor(item.path) > 0) {
  85. <span class="dot-badge" aria-hidden="true"></span>
  86. }
  87. </a>
  88. </ui-tooltip>
  89. } @else {
  90. <a
  91. class="nav-link"
  92. [routerLink]="link(item.path)"
  93. [class.active]="isActive(item.path)"
  94. [attr.aria-label]="item.label"
  95. >
  96. <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
  97. <span>{{ item.label }}</span>
  98. @if (badgeFor(item.path) > 0) {
  99. <span class="nav-count">{{ badgeFor(item.path) }}</span>
  100. }
  101. </a>
  102. }
  103. </li>
  104. }
  105. </ul>
  106. }
  107. </div>
  108. <!--
  109. 底部「设置」入口。
  110. 用户裁决(2026-10-29):把原来的用户卡片(头像+昵称+手机号)
  111. 收进设置里,底部只留一个设置入口 —— 左侧导航整体上移、重心更稳。
  112. 昵称与手机号在「我的」页顶部已有展示,这里不再重复。
  113. -->
  114. <a
  115. class="settings-entry"
  116. [class.active]="isActive('/mine')"
  117. [routerLink]="link('/mine')"
  118. aria-label="设置"
  119. >
  120. <i class="fa-solid fa-gear" aria-hidden="true"></i>
  121. @if (!collapsed()) {
  122. <span class="settings-label">设置</span>
  123. }
  124. </a>
  125. </nav>
  126. `,
  127. styles: [
  128. `
  129. :host {
  130. display: block;
  131. height: 100%;
  132. /* 网格项默认 min-height:auto —— 导航项多时会把侧栏撑高、带出整页滚动 */
  133. min-height: 0;
  134. overflow: hidden;
  135. }
  136. .sidebar {
  137. display: flex;
  138. flex-direction: column;
  139. height: 100%;
  140. width: var(--desk-nav-w);
  141. background: var(--nav-surface);
  142. color: var(--nav-text);
  143. overflow: hidden;
  144. }
  145. .sidebar.collapsed {
  146. width: var(--desk-nav-w-icon);
  147. }
  148. /* 折叠态:品牌行与用户卡都居中,避免元素溢出/错位 */
  149. .sidebar.collapsed .brand-row {
  150. position: relative;
  151. justify-content: center;
  152. padding: var(--sp-3) 0 var(--sp-2);
  153. }
  154. .sidebar.collapsed .nav-scroll {
  155. padding: 0 var(--sp-2) var(--sp-3);
  156. }
  157. .sidebar.collapsed .nav-items {
  158. justify-items: center;
  159. }
  160. .sidebar.collapsed .nav-link {
  161. width: var(--touch-min);
  162. margin: 0 auto;
  163. border-radius: var(--r-sm);
  164. }
  165. .brand-row {
  166. display: flex;
  167. align-items: center;
  168. gap: var(--sp-2);
  169. /* 收紧顶部留白 —— 导航整体上移(用户要求重心更靠上) */
  170. padding: var(--sp-3) var(--sp-3) var(--sp-2);
  171. flex-shrink: 0;
  172. }
  173. .brand-mark {
  174. display: inline-flex;
  175. align-items: center;
  176. justify-content: center;
  177. width: 32px;
  178. height: 32px;
  179. flex-shrink: 0;
  180. border-radius: var(--r-md);
  181. background: var(--nav-brand);
  182. color: var(--nav-on-brand);
  183. }
  184. .brand-word {
  185. font-size: var(--fs-20);
  186. font-weight: 650;
  187. letter-spacing: -0.8px;
  188. color: var(--nav-ink);
  189. }
  190. /* 折叠钮:始终位于品牌行**最右侧**(展开时在顶部右侧,不挤占品牌位) */
  191. .collapse-btn {
  192. margin-left: auto;
  193. width: 32px;
  194. height: 32px;
  195. display: grid;
  196. place-items: center;
  197. border: 0;
  198. border-radius: var(--r-sm);
  199. background: transparent;
  200. color: var(--nav-muted);
  201. cursor: pointer;
  202. flex-shrink: 0;
  203. }
  204. .collapse-btn:hover {
  205. background: var(--nav-hover);
  206. color: var(--nav-ink);
  207. }
  208. .sidebar.collapsed .collapse-btn {
  209. position: absolute;
  210. top: var(--sp-2);
  211. right: var(--sp-2);
  212. width: 24px;
  213. height: 24px;
  214. margin-left: 0;
  215. border-radius: var(--r-tag);
  216. background: var(--nav-hover);
  217. }
  218. .nav-scroll {
  219. flex: 1;
  220. min-height: 0;
  221. overflow-y: auto;
  222. overflow-x: hidden;
  223. padding: 0 var(--sp-2) var(--sp-3);
  224. }
  225. /* 二级分组标题 */
  226. .group-label {
  227. margin: 0;
  228. /* 分组间距收紧:nav 整体重心上移(用户要求) */
  229. padding: var(--sp-3) var(--sp-2) var(--sp-1);
  230. font-size: var(--fs-11);
  231. color: var(--nav-muted);
  232. letter-spacing: 0.5px;
  233. }
  234. .group-label:first-child {
  235. padding-top: var(--sp-2);
  236. }
  237. .group-divider {
  238. margin: var(--sp-2) var(--sp-2);
  239. border: 0;
  240. border-top: 1px solid var(--nav-line);
  241. }
  242. .nav-items {
  243. list-style: none;
  244. margin: 0;
  245. padding: 0;
  246. display: grid;
  247. gap: 2px;
  248. }
  249. .nav-link {
  250. position: relative;
  251. display: flex;
  252. align-items: center;
  253. gap: var(--sp-3);
  254. min-height: 40px;
  255. padding: 0 var(--sp-3);
  256. border-radius: var(--r-sm);
  257. color: var(--nav-text);
  258. font-size: var(--fs-13);
  259. text-decoration: none;
  260. transition: background-color var(--motion-fast) var(--ease-standard);
  261. }
  262. .sidebar.collapsed .nav-link {
  263. justify-content: center;
  264. padding: 0;
  265. min-height: var(--touch-min);
  266. }
  267. .nav-link:hover {
  268. background: var(--nav-hover);
  269. color: var(--nav-ink);
  270. }
  271. /* 选中态:仅用选中底色 + 加粗(用户反馈:不要左侧/右侧的竖条色块) */
  272. .nav-link.active {
  273. background: var(--nav-active);
  274. color: var(--nav-ink);
  275. font-weight: 600;
  276. }
  277. .nav-count {
  278. margin-left: auto;
  279. min-width: 20px;
  280. text-align: center;
  281. font-size: var(--fs-11);
  282. border-radius: var(--r-sm);
  283. background: var(--nav-accent);
  284. color: var(--nav-surface);
  285. }
  286. .dot-badge {
  287. position: absolute;
  288. top: 8px;
  289. right: 8px;
  290. width: 8px;
  291. height: 8px;
  292. border-radius: var(--r-tag);
  293. background: var(--danger);
  294. }
  295. .alert-dot {
  296. width: 8px;
  297. height: 8px;
  298. border-radius: var(--r-tag);
  299. flex-shrink: 0;
  300. }
  301. .alert-dot.tone-danger {
  302. background: var(--danger);
  303. }
  304. .alert-dot.tone-warning {
  305. background: var(--warning);
  306. }
  307. .alert-dot.tone-success {
  308. background: var(--success);
  309. }
  310. .alert-dot.tone-neutral {
  311. background: var(--nav-muted);
  312. }
  313. /* 设置入口:底部单一入口(原用户卡已收进「我的」页) */
  314. .settings-entry {
  315. display: flex;
  316. align-items: center;
  317. gap: var(--sp-2);
  318. min-height: var(--touch-min);
  319. padding: var(--sp-2) var(--sp-3);
  320. border-top: 1px solid var(--nav-line);
  321. color: var(--nav-muted);
  322. text-decoration: none;
  323. flex-shrink: 0;
  324. font-size: var(--fs-13);
  325. }
  326. .settings-entry:hover {
  327. background: var(--nav-hover);
  328. color: var(--nav-ink);
  329. }
  330. .settings-entry.active {
  331. background: var(--nav-active);
  332. color: var(--nav-ink);
  333. font-weight: 600;
  334. }
  335. .settings-entry:focus-visible {
  336. outline: 2px solid var(--primary);
  337. outline-offset: -2px;
  338. }
  339. .sidebar.collapsed .settings-entry {
  340. justify-content: center;
  341. padding-left: 0;
  342. padding-right: 0;
  343. }
  344. .settings-entry > i {
  345. width: 16px;
  346. text-align: center;
  347. flex-shrink: 0;
  348. }
  349. .settings-label {
  350. min-width: 0;
  351. }
  352. .ellipsis {
  353. overflow: hidden;
  354. text-overflow: ellipsis;
  355. white-space: nowrap;
  356. }
  357. `,
  358. ],
  359. })
  360. export class DkNavComponent {
  361. private readonly live = inject(LiveService);
  362. private readonly session = inject(SessionService);
  363. private readonly device = inject(DeviceService);
  364. private readonly router = inject(Router);
  365. readonly collapsed = input(false);
  366. constructor() {
  367. // 路由变化 → 更新高亮。用 events 而不是 router.url,保证切页后立刻刷新
  368. this.router.events.subscribe((e) => {
  369. if (e instanceof NavigationEnd) this._url.set(e.urlAfterRedirects);
  370. });
  371. this._url.set(this.router.url);
  372. }
  373. /**
  374. * 二级分组导航(用户裁决 2026-10-15)
  375. * · 业务:日常沟通与协作
  376. * · 资产:产出物
  377. * · 运维:运行状态与任务(P2/P3 画像的核心场景)
  378. */
  379. readonly groups: NavGroup[] = [
  380. {
  381. label: '业务',
  382. items: [
  383. { path: '/chat', label: '会话', icon: 'fa-comment' },
  384. /*
  385. * 🔒 工作组导航项 —— 2026-10-29 起暂时下线。
  386. * 多容器协作依赖容器侧配置(REQUIRE_MENTION / 名称前缀 / 会话上报),
  387. * 当前环境下「组长派活」不成立,属于假象。恢复时取消注释即可
  388. * (路由见 `desktop.routes.ts` 的对应说明)。
  389. */
  390. // { path: '/groups', label: '工作组', icon: 'fa-users' },
  391. { path: '/contacts', label: '通讯录', icon: 'fa-address-book' },
  392. /*
  393. * 🔒 2026-11-11 用户裁决:项目 / 任务 / 事件 / 成果 入口关闭。
  394. * 与 `nav-menu.service.ts` 的 NAV_CATALOG 保持一致(本文件是死代码,见类注释)。
  395. */
  396. // { path: '/projects', label: '项目', icon: 'fa-folder' },
  397. // { path: '/events', label: '事件', icon: 'fa-bolt' },
  398. ],
  399. },
  400. {
  401. label: '资产',
  402. items: [
  403. // { path: '/deliverables', label: '成果', icon: 'fa-box' },
  404. ],
  405. },
  406. {
  407. label: '运维',
  408. items: [
  409. { path: '/workbench', label: '工作台', icon: 'fa-table-cells-large' },
  410. // { path: '/tasks', label: '任务看板', icon: 'fa-list-check' },
  411. ],
  412. },
  413. {
  414. label: '',
  415. items: [{ path: '/mine', label: '我的', icon: 'fa-user' }],
  416. },
  417. ];
  418. readonly meName = computed(() => this.session.me()?.name ?? '未登录');
  419. readonly meMobile = computed(() => this.session.me()?.mobile ?? '');
  420. readonly blockedAgents = computed(() =>
  421. this.live.agents().filter((a) => a.status === 'error' || a.status === 'offline'),
  422. );
  423. /**
  424. * 导航项是否高亮。
  425. *
  426. * 🔴 不用 `routerLinkActive`:它按前缀匹配,`/chat/xxx` 命中 `/chat` 没问题,
  427. * 但群聊页会用 `navigateByUrl` 跳到 `/groups/!roomId`,
  428. * 实测此时「工作组」不高亮、「会话」反而亮着。
  429. * 改为**基于当前 URL 显式判断**,行为可预期、可单测。
  430. */
  431. /** 当前路由(用于导航高亮,随 NavigationEnd 更新) */
  432. private readonly _url = signal('');
  433. private readonly url = this._url.asReadonly();
  434. /**
  435. * 导航项是否高亮。
  436. *
  437. * 🔴 **只用 `_url` 这个 signal**,不要回落到 `this.router.url` ——
  438. * 后者是普通属性,OnPush 下值变了视图也不会重算,
  439. * 导致部分入口(如底部的「设置」)点了不高亮(实测)。
  440. */
  441. isActive(path: string): boolean {
  442. return navItemActive(this.url(), this.device.mode(), path);
  443. }
  444. link(path: string): string {
  445. return `/${this.device.mode()}${path}`;
  446. }
  447. agentLink(id: string): string {
  448. return `/${this.device.mode()}/agent/${id}`;
  449. }
  450. toneOf(status: string): string {
  451. return agentStatusTone(status as never);
  452. }
  453. /** 会话项显示未读总数;其余模块暂无计数 */
  454. badgeFor(path: string): number {
  455. if (path !== '/chat') return 0;
  456. return this.live.agents().reduce((n, a) => n + (a.unread ?? 0), 0);
  457. }
  458. toggleCollapse(): void {
  459. const next = !this.collapsed();
  460. try {
  461. // 兼容存量键名(FR-16 AC-16.1)
  462. localStorage.setItem('fmode_dk_sidebar_collapsed', next ? '1' : '0');
  463. } catch {
  464. /* 忽略 */
  465. }
  466. this.collapseChange.emit(next);
  467. }
  468. /** 折叠状态变更(Angular output,外壳用 (collapseChange) 绑定) */
  469. readonly collapseChange = output<boolean>();
  470. }
  471. /**
  472. * 导航项是否高亮 —— **纯函数**,便于单测。
  473. *
  474. * ## 🔴 为什么不用 `routerLinkActive`(2026-10-27 实测)
  475. *
  476. * ```
  477. * 点「工作组」→ URL 变成 /desktop/groups/!roomId
  478. * → 但左侧仍高亮「会话」
  479. * ```
  480. *
  481. * 两个原因叠加:
  482. * ① `routerLinkActive` 是前缀匹配,判断依据是「当前 URL 是否以 link 开头」
  483. * ② 群聊页在挂载后用 `navigateByUrl` 二次跳转,导航组件的
  484. * `NavigationEnd` 订阅没收到那次事件(组件初始化早于跳转)
  485. *
  486. * 改为**基于实时 URL 显式判断**:行为可预期,且能直接用单测锁住。
  487. *
  488. * @param url 当前路由(如 `/desktop/groups/!abc`)
  489. * @param mode 端(`desktop` / `mobile`)
  490. * @param path 导航项路径(如 `/groups`)
  491. */
  492. export function navItemActive(url: string, mode: string, path: string): boolean {
  493. const base = `/${mode}`;
  494. // 去掉 query / fragment,避免 `/desktop/chat?x=1` 判不中
  495. const clean = url.split('?')[0].split('#')[0];
  496. if (path === '/chat') {
  497. /*
  498. * 会话:/chat、/chat/:id、/chat/:id/:sessionId 都算。
  499. * 另外**容器详情 /agent/:id 也归到「会话」**——它是从会话页点齿轮进来的,
  500. * 返回后用户期望回到会话;若不高亮,导航会「全都没有选中」,
  501. * 用户不知道自己在哪一级(实测:进详情页后左栏全部熄灭)。
  502. */
  503. return (
  504. clean === `${base}/chat` ||
  505. clean.startsWith(`${base}/chat/`) ||
  506. clean === `${base}/agent` ||
  507. clean.startsWith(`${base}/agent/`)
  508. );
  509. }
  510. // 其余:精确或子路径都算
  511. return clean === `${base}${path}` || clean.startsWith(`${base}${path}/`);
  512. }