| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539 |
- 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: `
- <nav class="sidebar" [class.collapsed]="collapsed()" aria-label="主导航">
- <!-- 品牌 + 折叠钮 -->
- <div class="brand-row">
- <span class="brand-mark" aria-hidden="true"><i class="fa-solid fa-cube"></i></span>
- @if (!collapsed()) {
- <span class="brand-word">Fmode</span>
- }
- <button
- type="button"
- class="collapse-btn"
- [attr.aria-label]="collapsed() ? '展开侧栏' : '收起侧栏'"
- [attr.aria-expanded]="!collapsed()"
- (click)="toggleCollapse()"
- >
- <i class="fa-solid" [class]="collapsed() ? 'fa-angles-right' : 'fa-angles-left'"></i>
- </button>
- </div>
- <!-- 二级分组导航 -->
- <div class="nav-scroll">
- @for (g of groups; track g.label) {
- @if (g.label && !collapsed()) {
- <p class="group-label">{{ g.label }}</p>
- }
- @if (g.label && collapsed()) {
- <hr class="group-divider" />
- }
- <ul class="nav-items">
- @for (item of g.items; track item.path) {
- <li>
- @if (collapsed()) {
- <ui-tooltip [content]="item.label" position="right">
- <a
- class="nav-link"
- [routerLink]="link(item.path)"
- [class.active]="isActive(item.path)"
- [attr.aria-label]="item.label"
- >
- <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
- @if (badgeFor(item.path) > 0) {
- <span class="dot-badge" aria-hidden="true"></span>
- }
- </a>
- </ui-tooltip>
- } @else {
- <a
- class="nav-link"
- [routerLink]="link(item.path)"
- [class.active]="isActive(item.path)"
- [attr.aria-label]="item.label"
- >
- <i class="fa-solid" [class]="item.icon" aria-hidden="true"></i>
- <span>{{ item.label }}</span>
- @if (badgeFor(item.path) > 0) {
- <span class="nav-count">{{ badgeFor(item.path) }}</span>
- }
- </a>
- }
- </li>
- }
- </ul>
- }
- </div>
- <!--
- 底部「设置」入口。
- 用户裁决(2026-10-29):把原来的用户卡片(头像+昵称+手机号)
- 收进设置里,底部只留一个设置入口 —— 左侧导航整体上移、重心更稳。
- 昵称与手机号在「我的」页顶部已有展示,这里不再重复。
- -->
- <a
- class="settings-entry"
- [class.active]="isActive('/mine')"
- [routerLink]="link('/mine')"
- aria-label="设置"
- >
- <i class="fa-solid fa-gear" aria-hidden="true"></i>
- @if (!collapsed()) {
- <span class="settings-label">设置</span>
- }
- </a>
- </nav>
- `,
- 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<boolean>();
- }
- /**
- * 导航项是否高亮 —— **纯函数**,便于单测。
- *
- * ## 🔴 为什么不用 `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}/`);
- }
|