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}/`);
}