| 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244 |
- import { NgTemplateOutlet } from '@angular/common';
- import { ChangeDetectionStrategy, Component, computed, effect, inject, input, model, output, signal, untracked } from '@angular/core';
- import { NavigationEnd, Router } from '@angular/router';
- import { filter } from 'rxjs';
- import { Agent } from '../../../core/api/models';
- import { groupOf } from '../../../core/chat-session';
- import { AgentStateView, resolveAgentState } from '../../../core/agent-state';
- import { ChatSessionService } from '../../../core/chat-session.service';
- import { LiveService } from '../../../core/live.service';
- import { MobileChatViewService, SessionRow } from '../../../core/mobile-chat-view.service';
- import { WorkspaceService } from '../../../core/workspace.service';
- import { NavMenuService } from '../../../core/nav-menu.service';
- import { MNavCustomComponent } from '../m-nav-custom/m-nav-custom';
- import { UiMDialog } from '../../../ui/mobile/components/dialog/dialog';
- import { PromptModalService } from '../../../components/domain/prompt-modal/prompt-modal.service';
- import { LockScrollDirective } from '../../directives/lock-scroll.directive';
- /** 抽屉布局常量(与 PC 左栏 `SessionTreeComponent.PRIMARY_COUNT` 对齐) */
- class MNavConstants {
- static readonly PRIMARY_COUNT = 4;
- }
- /** 底部操作菜单的一项 */
- interface MenuItem {
- label: string;
- run: () => void;
- danger?: boolean;
- }
- import { isMatrixReady } from '../../../core/format';
- import { MAvatarComponent } from '../m-avatar/m-avatar';
- import { MBadgeComponent } from '../m-badge/m-badge';
- /** 抽屉一级菜单的一行 */
- interface NavRow {
- key: string;
- path: string;
- label: string;
- icon: string;
- active: boolean;
- }
- /**
- * 左侧滑出抽屉(Demo 04)
- *
- * 三段结构(与 Demo 一致):
- * ① 顶部「新建对话」主入口(选择数字生命)
- * ② 一级菜单(会话 / 项目 / 任务 / 事件 / 成果 / 工作台 / 通讯录 / 我的)
- * ③ 会话树:工作区 / 日常对话 / 已归档 —— **原地折叠,不重建 DOM**
- *
- * 路由与菜单项仍走项目既有页面(`/mobile/*`),不自建一套导航。
- */
- @Component({
- selector: 'app-m-drawer',
- changeDetection: ChangeDetectionStrategy.OnPush,
- imports: [NgTemplateOutlet, MAvatarComponent, MBadgeComponent, MNavCustomComponent, LockScrollDirective, UiMDialog],
- template: `
- <div class="m-mask" [class.show]="open()" (click)="close.emit()"></div>
- <aside class="m-drawer" [class.show]="open()" role="dialog" aria-label="导航" aria-modal="true" [appLockScroll]="open()">
- <div class="m-drawer-head">
- <button type="button" class="m-entry m-entry--primary" (click)="newChat.emit()">
- <span class="m-entry-lead"><i class="fa-solid fa-pen-to-square" aria-hidden="true"></i></span>
- <b>新建对话</b>
- <span class="m-entry-kbd">选择数字生命</span>
- </button>
- @if (quickNewAgentId()) {
- <button
- type="button"
- class="m-entry m-entry--quick"
- (click)="quickNew.emit(quickNewAgentId()!)"
- >
- <span class="m-entry-lead"><i class="fa-solid fa-plus" aria-hidden="true"></i></span>
- <b>在本容器新建</b>
- <span class="m-entry-kbd">不用再挑一次</span>
- </button>
- }
- </div>
- <nav class="m-drawer-nav" aria-label="功能">
- <!--
- 菜单项来自 NavMenuService(2026-11-03):顺序与显隐由用户配置,
- 与 PC 左栏共用同一份偏好。
- 折叠规则与 PC 一致:前 4 项直显,其余收进「更多」——
- 抽屉纵向空间有限,全平铺会把下面的会话区挤没。
- -->
- @for (item of primaryNav(); track item.key) {
- <button
- type="button"
- class="m-entry"
- [class.on]="item.active"
- (click)="go(item.path)"
- >
- <span class="m-entry-lead"><i [class]="item.icon" aria-hidden="true"></i></span>
- <b>{{ item.label }}</b>
- <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
- </button>
- }
- @if (moreNav().length > 0) {
- <button
- type="button"
- class="m-entry"
- [class.on]="moreOpen()"
- [attr.aria-expanded]="moreOpen()"
- (click)="moreOpen.set(!moreOpen())"
- >
- <span class="m-entry-lead"><i class="fa-solid fa-ellipsis" aria-hidden="true"></i></span>
- <b>更多</b>
- <span class="m-entry-kbd">{{ moreNav().length }}</span>
- <i
- class="fa-solid m-chev"
- [class.fa-chevron-down]="moreOpen()"
- [class.fa-chevron-right]="!moreOpen()"
- aria-hidden="true"
- ></i>
- </button>
- <!-- 展开态原地展开,不弹浮层(移动端没有悬浮定位的余量) -->
- @if (moreOpen()) {
- <div class="m-more">
- @for (item of moreNav(); track item.key) {
- <button
- type="button"
- class="m-entry m-entry--sub"
- [class.on]="item.active"
- (click)="go(item.path)"
- >
- <span class="m-entry-lead"><i [class]="item.icon" aria-hidden="true"></i></span>
- <b>{{ item.label }}</b>
- <i class="fa-solid fa-chevron-right m-chev" aria-hidden="true"></i>
- </button>
- }
- <!--
- 企业管理入口已移除(2026-11-11 用户裁决)。
- 落地页 /mobile/admin 下线,成员管理改由菜单目录
- (NAV_CATALOG 的 admin-users)直接指向 /admin/users,
- 不再经过中间页。管理员仍可在「更多」里看到「成员管理」。
- -->
- <!-- 编辑菜单(2026-11-03 用户要求:收进「更多」,不占一级位置) -->
- <button type="button" class="m-entry m-entry--sub" (click)="customOpen.set(true)">
- <span class="m-entry-lead"><i class="fa-solid fa-sliders" aria-hidden="true"></i></span>
- <b>编辑菜单</b>
- <span class="m-entry-kbd">排序 / 显隐</span>
- </button>
- </div>
- }
- }
- </nav>
- <!-- 滚动区:从「对话」标题起,到抽屉底部(唯一可滚区域) -->
- <nav class="m-drawer-body" aria-label="对话">
- <div class="m-drawer-caption">对话</div>
- <!-- ① 工作区:完整功能(加载 / 新建 / 重命名 / 删除 / 空工作区也显示) -->
- <div class="m-ws-bar">
- <span>工作区</span>
- <!-- 新建「工作区」:与行内的「新建对话」区分,用图标 + 无障碍标签 -->
- <button
- type="button"
- class="m-ws-create"
- aria-label="新建工作区"
- title="新建工作区"
- (click)="newWorkspace()"
- >
- <i class="fa-solid fa-plus" aria-hidden="true"></i>
- </button>
- </div>
- @for (ws of workspaces(); track ws.id) {
- <section>
- <div class="m-ws-row">
- <button
- type="button"
- class="m-tree-toggle"
- [attr.aria-expanded]="isOpen(ws.id)"
- [attr.aria-controls]="'tree-' + ws.id"
- (click)="toggle(ws.id)"
- >
- <i class="fa-solid fa-chevron-down m-arrow" aria-hidden="true"></i>
- {{ ws.name }}<span class="m-tree-count">{{ rowsInWorkspace(ws.id).length }}</span>
- </button>
- <!-- 在工作区中新建对话(2026-11-04 用户要求) -->
- <button
- type="button"
- class="m-ws-add"
- [attr.aria-label]="'在 ' + ws.name + ' 中新建对话'"
- (click)="newChatIn(ws.id)"
- >
- <i class="fa-solid fa-plus" aria-hidden="true"></i>
- </button>
- <button
- type="button"
- class="m-ws-more"
- [attr.aria-label]="ws.name + ' 的操作'"
- (click)="openWsMenu(ws.id)"
- >
- <i class="fa-solid fa-ellipsis" aria-hidden="true"></i>
- </button>
- </div>
- <div [id]="'tree-' + ws.id" [hidden]="!isOpen(ws.id)">
- <ng-container *ngTemplateOutlet="tree; context: { $implicit: rowsInWorkspace(ws.id) }" />
- <!-- 归档回到原分组(2026-11-08):该工作区的已归档会话留在这里 -->
- <ng-container
- *ngTemplateOutlet="archBlock; context: { $implicit: archivedIn(ws.id), key: 'arch-' + ws.id }"
- />
- </div>
- </section>
- } @empty {
- <p class="m-tree-empty">还没有工作区,点「新建」按项目分开存放对话</p>
- }
- <!--
- 🔒 2026-11-11 用户裁决:群聊入口整体隐藏(PC + 移动端一致)。
- 恢复:把下面的 false && 去掉,并同步恢复 session-tree.ts 的对应分组。
- -->
- @if (false && groupSessions().length > 0) {
- <section>
- <button
- type="button"
- class="m-tree-toggle"
- [attr.aria-expanded]="isOpen('__group')"
- aria-controls="tree-group"
- (click)="toggle('__group')"
- >
- <i class="fa-solid fa-chevron-down m-arrow" aria-hidden="true"></i>
- 群聊<span class="m-tree-count">{{ groupSessions().length }}</span>
- </button>
- <div id="tree-group" [hidden]="!isOpen('__group')">
- <ng-container *ngTemplateOutlet="tree; context: { $implicit: groupSessions() }" />
- <!-- 归档回到原分组(2026-11-08):群聊的已归档会话留在群聊下面 -->
- <ng-container
- *ngTemplateOutlet="archBlock; context: { $implicit: archivedGroup(), key: 'arch-group' }"
- />
- </div>
- </section>
- }
- <!-- ② 日常对话(无工作区归属) -->
- <section>
- <button
- type="button"
- class="m-tree-toggle"
- [attr.aria-expanded]="isOpen('__daily')"
- aria-controls="tree-daily"
- (click)="toggle('__daily')"
- >
- <i class="fa-solid fa-chevron-down m-arrow" aria-hidden="true"></i>
- 日常对话<span class="m-tree-count">{{ daily().length }}</span>
- </button>
- <div id="tree-daily" [hidden]="!isOpen('__daily')">
- <ng-container *ngTemplateOutlet="tree; context: { $implicit: daily() }" />
- <!-- 归档回到原分组(2026-11-08):日常的已归档会话留在日常下面 -->
- <ng-container
- *ngTemplateOutlet="archBlock; context: { $implicit: archivedDaily(), key: 'arch-daily' }"
- />
- </div>
- </section>
- </nav>
- </aside>
- <!-- 编辑菜单(排序 + 显隐),与 PC 共用同一份偏好 -->
- <app-m-nav-custom [(open)]="customOpen" />
- <!--
- 二次确认(2026-11-04,对齐 PC):归档 / 删除 / 删工作区 一律走居中 dialog。
- 规范 18 §三-13:删除类操作必须二次确认,不用 popconfirm。
- -->
- <ui-m-dialog
- [(open)]="confirmOpen"
- [title]="confirmTitle()"
- [message]="confirmMessage()"
- [confirmText]="confirmText()"
- (confirm)="runConfirm()"
- />
- <!-- 底部操作菜单(工作区 / 会话共用) -->
- @if (menu(); as m) {
- <div class="m-act-root" role="dialog" aria-modal="true" [attr.aria-label]="m.title" [appLockScroll]="true">
- <div class="m-act-mask" (click)="menu.set(null)"></div>
- <section class="m-act">
- <div class="m-grabber" aria-hidden="true"></div>
- <p class="m-act-title">{{ m.title }}</p>
- @for (item of m.items; track item.label) {
- <button
- type="button"
- class="m-act-item"
- [class.danger]="item.danger"
- (click)="runMenu(item)"
- >
- {{ item.label }}
- </button>
- }
- </section>
- <button type="button" class="m-act-cancel" (click)="menu.set(null)">取消</button>
- </div>
- }
- <ng-template #tree let-rows>
- @for (row of rows; track row.session.id) {
- <div class="m-tree-line">
- <button type="button" class="m-tree-item" [class.on]="row.session.id === activeSessionId()" (click)="goChat(row)">
- <app-m-avatar [name]="row.agent?.name ?? '?'" size="sm" />
- <span class="m-tree-text">
- <span class="m-tree-title">
- @if (row.session.kind === 'group') {
- <i class="fa-solid fa-users m-tree-group-icon" aria-label="群聊"></i>
- }
- {{ row.session.title }}
- </span>
- <!-- 容器名:明确「在跟哪个数字生命对话」(2026-11-04) -->
- <span class="m-tree-agent">
- @if (row.session.kind === 'group') {
- {{ row.session.memberIds?.length ?? 0 }} 个容器
- } @else {
- <!-- 容器状态点(2026-11-08):与 PC 左栏、会话列表同一口径 -->
- <i
- class="m-state-dot"
- [attr.data-tone]="stateOf(row).tone"
- [attr.aria-label]="stateOf(row).text"
- ></i>
- {{ row.agent?.name ?? '未知容器' }}
- }
- </span>
- </span>
- @if (row.unread > 0) {
- <app-m-badge tone="danger">{{ row.unread }}</app-m-badge>
- }
- </button>
- <!-- 移动到工作区 / 归档 / 删除(长按与「⋯」等价) -->
- <button
- type="button"
- class="m-tree-more"
- aria-label="会话操作"
- (click)="openSessionMenu(row)"
- >
- <i class="fa-solid fa-ellipsis" aria-hidden="true"></i>
- </button>
- </div>
- } @empty {
- <p class="m-tree-empty">暂无会话</p>
- }
- </ng-template>
- <!--
- 「已归档」子块(2026-11-08):挂在**原分组**下面,默认收起。
- 归档是「收起来看」,不是「搬到别的分组」——群聊归档仍在群聊下,
- 工作区归档仍在那个工作区下,日常归档仍在日常下。
- -->
- <ng-template #archBlock let-rows let-key="key">
- @if (rows.length > 0) {
- <button
- type="button"
- class="m-arch-toggle"
- [attr.aria-expanded]="isOpen(key)"
- (click)="toggle(key)"
- >
- <i
- class="fa-solid m-arrow"
- [class.fa-chevron-right]="!isOpen(key)"
- [class.fa-chevron-down]="isOpen(key)"
- aria-hidden="true"
- ></i>
- 已归档<span class="m-tree-count">{{ rows.length }}</span>
- </button>
- @if (isOpen(key)) {
- <ng-container *ngTemplateOutlet="tree; context: { $implicit: rows }" />
- }
- }
- </ng-template>
- `,
- styles: [
- `
- /*
- * 【2026-11-08】不能用 display: contents。
- *
- * 它会让抽屉子树的层叠提升到父级层叠上下文,与底栏(fixed,出现在 shell
- * 模板更后面)直接比较 —— 同为 fixed 时后者胜出,抽屉内的操作菜单被底栏压住。
- *
- * 改成 display:block:Host 有真实盒子,相对定位/高度约束才生效;
- * 抽屉与遮罩都是 position:fixed 且自带 z-index,层叠顺序不受影响。
- */
- :host {
- display: block;
- }
- .m-mask {
- position: fixed;
- inset: 0;
- z-index: calc(var(--z-modal) - 1);
- background: var(--overlay);
- opacity: 0;
- pointer-events: none;
- transition: opacity var(--motion-panel) var(--ease-standard);
- }
- .m-mask.show {
- opacity: 1;
- pointer-events: auto;
- }
- .m-drawer {
- position: fixed;
- top: 0;
- bottom: 0;
- left: 0;
- z-index: var(--z-modal);
- width: min(84%, 330px);
- box-sizing: border-box;
- display: flex;
- flex-direction: column;
- /*
- * 抽屉最外层**不滚动**(用户 2026-11-08 明确指出):唯一滚动区是下方的
- * .m-drawer-body。这里只负责撑满视口 + 裁掉溢出。
- */
- overflow: hidden;
- /*
- * 底部只预留安全区:底栏在抽屉打开时被 body:has(.m-drawer.show) .m-tabbar
- * 直接隐藏(styles.scss),不需要再为它留 70px —— 那 70px 就是用户看到的
- * 「下面一块空白没利用起来」。
- */
- padding: calc(var(--mob-safe-top) + var(--sp-4)) 0
- calc(var(--mob-safe-bottom) + var(--sp-3));
- border-radius: 0 var(--r-lg) var(--r-lg) 0;
- background: var(--surface);
- box-shadow: var(--shadow-pop);
- transform: translateX(-100%);
- transition: transform var(--motion-panel) var(--ease-standard);
- }
- .m-drawer.show {
- transform: none;
- }
- .m-drawer-head,
- .m-drawer-nav,
- .m-drawer-body {
- /* 固定区不参与收缩:否则高度不够时会被压扁 */
- flex: none;
- padding-left: var(--sp-4);
- padding-right: var(--sp-4);
- }
- .m-drawer-head {
- padding-bottom: var(--sp-3);
- }
- /*
- * 【2026-11-08】导航块(会话/项目/任务/通讯录/更多)固定在抽屉上部,
- * 不随对话列表滚动 —— 它是全局入口,滚走了就找不到。
- */
- .m-drawer-nav {
- padding-bottom: var(--sp-3);
- border-bottom: 1px solid var(--edge);
- }
- /*
- * 【2026-11-08】唯一滚动区:从「对话」标题到抽屉底部。
- *
- * 必须 min-height: 0:flex 子项默认 min-height:auto 会撑到内容高度,
- * flex:1 + overflow-y:auto 失效,溢出被抽屉裁掉(用户反馈「上半截卡住不动」)。
- * 顶部 padding 改由标题自身上边距承担,滚动时标题不会被切掉一半。
- */
- .m-drawer-body {
- flex: 1 1 auto;
- min-height: 0;
- overflow-y: auto;
- overscroll-behavior: contain;
- -webkit-overflow-scrolling: touch;
- }
- .m-entry {
- display: flex;
- align-items: center;
- gap: var(--sp-3);
- width: 100%;
- min-height: var(--touch-min);
- padding: 0 var(--sp-2);
- border: 0;
- border-radius: var(--r-sm);
- background: none;
- color: var(--ink);
- font: inherit;
- font-size: var(--fs-15);
- text-align: left;
- cursor: pointer;
- transition: background var(--motion-fast) var(--ease-standard);
- }
- .m-entry:active {
- background: var(--surface-hover);
- }
- .m-entry-lead {
- display: grid;
- place-items: center;
- flex: none;
- width: 32px;
- height: 32px;
- border-radius: var(--r-sm);
- background: var(--surface-2);
- color: var(--ink-2);
- font-size: var(--fs-14);
- }
- .m-entry b {
- flex: 1;
- font-weight: 500;
- }
- .m-entry--primary .m-entry-lead {
- background: var(--primary-subtle);
- color: var(--primary);
- }
- .m-entry--primary b {
- color: var(--primary);
- font-weight: 600;
- }
- /*
- * 「在本容器新建」(2026-11-14):次于主入口,但不能弱到看不见。
- * 用 surface-2 底 + 常规色文字,视觉上是一条「就近快捷方式」而非第二个主按钮,
- * 避免与上方「新建对话」抢主次。
- */
- .m-entry--quick {
- margin-top: var(--sp-1);
- background: var(--surface-2);
- font-size: var(--fs-13);
- }
- .m-entry--quick .m-entry-lead {
- color: var(--ink-2);
- }
- .m-entry-kbd {
- font-size: var(--fs-11);
- color: var(--muted);
- }
- .m-chev {
- color: var(--muted);
- font-size: var(--fs-12);
- }
- .m-sep {
- margin: var(--sp-3) 0 var(--sp-1);
- border-top: 1px solid var(--edge);
- }
- .m-drawer-caption {
- /* 滚动区自身不加 padding-top,否则滚动时「对话」标题会被切掉一半 */
- margin-top: var(--sp-3);
- padding: var(--sp-1) var(--sp-2);
- color: var(--muted);
- font-size: var(--fs-12);
- }
- .m-tree-toggle {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- width: 100%;
- min-height: 36px;
- padding: 0 var(--sp-2);
- border: 0;
- border-radius: var(--r-sm);
- background: none;
- color: var(--muted);
- font: inherit;
- font-size: var(--fs-12);
- font-weight: 600;
- cursor: pointer;
- }
- /*
- * 分组内的「已归档」子块(2026-11-08):比分组标题轻一档,
- * 缩进一点,视觉上属于上级分组而不是平级。
- */
- .m-arch-toggle {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- width: calc(100% - var(--sp-4));
- min-height: 32px;
- margin-left: var(--sp-4);
- padding: 0 var(--sp-2);
- border: 0;
- border-radius: var(--r-sm);
- background: none;
- color: var(--faint, var(--muted));
- font: inherit;
- font-size: var(--fs-12);
- cursor: pointer;
- }
- .m-arch-toggle:hover {
- background: var(--surface-hover);
- }
- .m-arrow {
- font-size: var(--fs-10);
- transition: transform var(--motion-fast) var(--ease-standard);
- }
- .m-tree-toggle[aria-expanded='false'] .m-arrow {
- transform: rotate(-90deg);
- }
- .m-tree-count {
- margin-left: auto;
- font-weight: 400;
- }
- .m-tree-item {
- display: flex;
- align-items: center;
- gap: var(--sp-2);
- width: 100%;
- min-height: 44px;
- padding: var(--sp-2);
- border: 0;
- border-radius: var(--r-sm);
- background: none;
- color: var(--ink-2);
- font: inherit;
- font-size: var(--fs-15);
- text-align: left;
- cursor: pointer;
- }
- .m-tree-item:active {
- background: var(--surface-hover);
- }
- .m-tree-item.on {
- background: var(--primary-subtle);
- color: var(--primary);
- font-weight: 600;
- }
- .m-tree-text {
- flex: 1;
- min-width: 0;
- display: flex;
- flex-direction: column;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- /* 「更多」展开区:缩进 + 浅底,与一级项区分层级 */
- .m-more {
- margin: 0 0 var(--sp-2) var(--sp-4);
- padding-left: var(--sp-2);
- border-left: 2px solid var(--edge);
- }
- .m-entry--sub {
- min-height: 40px;
- font-size: var(--fs-15);
- }
- /*
- * 一级菜单选中态(2026-11-04):与 PC 左栏一致 —— **整行浅底**,不只改文字色。
- * PC 端选中的是整行(surface-hover 底 + 主色文字),移动端此前只有文字变色,
- * 视觉上几乎看不出「当前在哪一页」。
- */
- .m-entry.on {
- background: var(--surface-hover);
- color: var(--primary);
- }
- .m-entry.on b {
- color: var(--primary);
- font-weight: 600;
- }
- .m-entry.on .m-entry-lead {
- background: var(--primary-subtle);
- color: var(--primary);
- }
- .m-ws-bar {
- display: flex;
- align-items: center;
- justify-content: space-between;
- padding: var(--sp-2) var(--sp-2) var(--sp-1);
- color: var(--muted);
- font-size: var(--fs-12);
- }
- /*
- * 🔴 所有按钮一律 white-space:nowrap(2026-11-04)。
- *
- * 工作区行里有 3 个按钮(展开/新建对话/更多),抽屉宽度只有 84%,
- * 文字按钮会被挤成竖排(「新/建」上下叠,用户实测)。
- * 现在:文字按钮固定不换行,容器 flex 不压缩按钮。
- */
- .m-ws-bar > span,
- .m-ws-create,
- .m-ws-add,
- .m-ws-more,
- .m-tree-more,
- .m-tree-toggle {
- flex: none;
- white-space: nowrap;
- }
- /* 新建工作区:轻量图标按钮(不要实心块,避免与行内「+」混淆) */
- .m-ws-create {
- display: grid;
- place-items: center;
- width: 28px;
- height: 28px;
- border: 0;
- border-radius: var(--r-sm);
- background: none;
- color: var(--primary);
- font-size: var(--fs-12);
- cursor: pointer;
- }
- .m-ws-create:active {
- background: var(--surface-hover);
- }
- .m-ws-row {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- }
- .m-ws-row .m-tree-toggle {
- flex: 1;
- min-width: 0;
- overflow: hidden;
- text-overflow: ellipsis;
- }
- .m-ws-row .m-tree-count {
- flex: none;
- }
- /* 在工作区中新建对话:与「⋯」同尺寸同风格,避免大小不一 */
- .m-ws-add {
- display: grid;
- place-items: center;
- flex: none;
- width: 26px;
- height: 26px;
- border: 0;
- border-radius: var(--r-sm);
- background: none;
- color: var(--muted);
- font-size: var(--fs-11);
- cursor: pointer;
- }
- .m-ws-add:active,
- .m-ws-more:active,
- .m-tree-more:active {
- background: var(--surface-hover);
- }
- .m-ws-more,
- .m-tree-more {
- display: grid;
- place-items: center;
- flex: none;
- width: 26px;
- height: 26px;
- border: 0;
- border-radius: var(--r-sm);
- background: none;
- color: var(--muted);
- cursor: pointer;
- }
- .m-tree-line {
- display: flex;
- align-items: center;
- gap: var(--sp-1);
- }
- .m-tree-line .m-tree-item {
- flex: 1;
- min-width: 0;
- }
- /* 底部操作菜单 */
- .m-act-root {
- position: fixed;
- inset: 0;
- /* 抽屉内弹层:高于抽屉(1100)、低于预览(1300),且盖住底栏 */
- z-index: var(--z-context-menu);
- }
- .m-act-mask {
- position: absolute;
- inset: 0;
- background: var(--overlay);
- }
- /*
- * 操作菜单(2026-11-03 重做样式)。
- * 原实现用「灰底拼块」分隔各项(gap:1px + --edge 背景),
- * 在浅色主题下看起来像一堆碎块,标题行也被挤压。
- * 改为单张白卡 + 细分割线,标题与操作区分层级。
- */
- .m-act {
- position: absolute;
- left: var(--sp-2);
- right: var(--sp-2);
- bottom: calc(var(--mob-safe-bottom) + 52px + var(--sp-3) + var(--sp-2));
- display: flex;
- flex-direction: column;
- overflow: hidden;
- border-radius: var(--r-lg);
- background: var(--surface);
- box-shadow: var(--shadow-overlay);
- }
- .m-grabber {
- width: 36px;
- height: 4px;
- margin: var(--sp-2) auto;
- border-radius: var(--r-tag);
- background: var(--edge-strong);
- }
- .m-act-title {
- margin: 0;
- padding: var(--sp-3) var(--sp-4);
- border-bottom: 1px solid var(--edge);
- color: var(--muted);
- font-size: var(--fs-12);
- text-align: center;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .m-act-item {
- min-height: 52px;
- padding: 0 var(--sp-4);
- border: 0;
- border-top: 1px solid var(--edge);
- background: var(--surface);
- color: var(--ink);
- font: inherit;
- font-size: var(--fs-15);
- cursor: pointer;
- transition: background var(--motion-fast) var(--ease-standard);
- }
- /* 第一项不要顶部分割线(标题行已有下边线) */
- .m-act-title + .m-act-item {
- border-top: 0;
- }
- .m-act-item:active {
- background: var(--surface-hover);
- }
- .m-act-item.danger {
- color: var(--danger);
- }
- /* 取消独立成一张卡片(iOS 惯例),与危险操作拉开距离 */
- .m-act-cancel {
- position: absolute;
- left: var(--sp-2);
- right: var(--sp-2);
- bottom: calc(var(--mob-safe-bottom) + var(--sp-2));
- min-height: 52px;
- border: 0;
- border-radius: var(--r-lg);
- background: var(--surface);
- box-shadow: var(--shadow-overlay);
- color: var(--ink-2);
- font: inherit;
- font-size: var(--fs-15);
- font-weight: 600;
- cursor: pointer;
- }
- .m-tree-title,
- .m-tree-agent {
- display: block;
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
- }
- .m-tree-agent {
- color: var(--muted);
- font-size: var(--fs-11);
- font-weight: 400;
- }
- .m-tree-item.on .m-tree-agent {
- color: var(--primary);
- opacity: 0.75;
- }
- .m-tree-group-icon {
- margin-right: var(--sp-1);
- color: var(--primary);
- font-size: var(--fs-10);
- }
- /* 容器状态点(2026-11-08):与 PC 左栏同尺寸 */
- .m-state-dot {
- display: inline-block;
- width: 6px;
- height: 6px;
- margin-right: 4px;
- border-radius: 50%;
- vertical-align: middle;
- background: var(--faint);
- }
- .m-state-dot[data-tone='success'] {
- background: var(--success);
- }
- .m-state-dot[data-tone='warning'] {
- background: var(--warning);
- }
- .m-state-dot[data-tone='danger'] {
- background: var(--danger);
- }
- .m-state-dot[data-tone='info'] {
- background: var(--primary);
- }
- .m-tree-empty {
- margin: var(--sp-1) var(--sp-2);
- color: var(--muted);
- font-size: var(--fs-12);
- }
- `,
- ],
- })
- export class MDrawerComponent {
- private readonly router = inject(Router);
- private readonly sessionsSvc = inject(ChatSessionService);
- private readonly view = inject(MobileChatViewService);
- private readonly live = inject(LiveService);
- private readonly workspacesSvc = inject(WorkspaceService);
- private readonly navMenu = inject(NavMenuService);
- private readonly prompt = inject(PromptModalService);
- /** 编辑菜单弹层 */
- protected readonly customOpen = signal(false);
- /** 二次确认对话框 */
- protected readonly confirmOpen = model<boolean>(false);
- private readonly confirmState = signal<{ title: string; message: string; text: string; run: () => void } | null>(null);
- protected readonly confirmTitle = computed(() => this.confirmState()?.title ?? '');
- protected readonly confirmMessage = computed(() => this.confirmState()?.message ?? '');
- protected readonly confirmText = computed(() => this.confirmState()?.text ?? '确认');
- /** 底部操作菜单(工作区 / 会话共用) */
- protected readonly menu = signal<{ title: string; items: MenuItem[] } | null>(null);
- /** 当前路由(菜单高亮用) */
- private readonly url = signal('');
- readonly open = input(false);
- /** 当前打开的会话 id(会话详情页高亮用) */
- readonly activeSessionId = input<string | null>(null);
- readonly close = output<void>();
- readonly newChat = output<void>();
- /**
- * 在**当前容器**再开一条对话(2026-11-14)。
- *
- * 聊天页把当前 agentId 传进来,抽屉顶部就多出一个「在本容器新建」快捷入口;
- * 其余页面不传 → `null` → 该入口不渲染,行为与之前完全一致。
- *
- * 为什么不直接放顶栏按钮:聊天页右侧已有两个按钮,再塞第三个会把
- * 标题区挤到 `3×44+28px` 的预留里,长标题更早被省略号截断。
- */
- readonly quickNewAgentId = input<string | null>(null);
- readonly quickNew = output<string>();
- /** 在指定工作区新建对话(payload = workspaceId) */
- readonly newChatWithWs = output<string>();
- /**
- * 一级菜单:来自 `NavMenuService`(与 PC 左栏共用同一份用户偏好)。
- *
- * 「会话」不在 catalog 里(它是两端固定的落地页),单独置顶;
- * `new-chat` 是动作项、不是路由,由抽屉顶部的「新建对话」承担,这里过滤掉。
- */
- /** 直接展示的菜单项数量(与 PC 左栏同值) */
- private static readonly PRIMARY_COUNT = 4;
- protected readonly navItems = computed(() => {
- const fixed: NavRow = {
- key: 'messages',
- path: '/messages',
- label: '会话',
- icon: 'fa-solid fa-comment',
- active: this.url() === '/messages',
- };
- const rows: NavRow[] = this.navMenu
- .visible()
- .filter((i) => i.key !== 'new-chat' && i.path)
- .map((i) => ({
- key: i.key,
- path: i.path!,
- label: i.label,
- icon: `fa-solid ${i.icon}`,
- active: this.url() === i.path,
- }));
- return [fixed, ...rows];
- });
- /**
- * 前 4 项直显、其余进「更多」。
- *
- * 「会话」固定在首位且**计入 4 项**(它是最常用的入口),
- * 与 PC 左栏「前 4 项 + 更多」的语义一致。
- */
- protected readonly primaryNav = computed(() =>
- this.navItems().slice(0, MNavConstants.PRIMARY_COUNT),
- );
- protected readonly moreNav = computed(() =>
- this.navItems().slice(MNavConstants.PRIMARY_COUNT),
- );
- /** 「更多」是否展开 */
- protected readonly moreOpen = signal(false);
- /** 全部工作区(**含空工作区**,与 PC 一致;原实现过滤掉了没有会话的) */
- protected readonly workspaces = computed(() => this.workspacesSvc.sorted());
- protected readonly daily = computed(() => this.view.dailySessions());
- /** 群会话(2026-11-05) */
- protected readonly groupSessions = computed(() => this.view.groupSessions());
- /** 全部已归档(时间倒序) */
- protected readonly archived = this.view.archived;
- /*
- * 已归档会话按**原分组**切开(2026-11-08 用户反馈):
- * 归档后要回到群聊 / 工作区 / 日常各自的分组下,而不是统一堆在底部一个「已归档」里。
- * 判据来自 `groupOf()`,与桌面端共用。
- */
- private readonly archivedGroups = this.view.archivedGroups;
- protected readonly archivedDaily = computed(() => this.archivedGroups().daily);
- protected readonly archivedGroup = computed(() => this.archivedGroups().group);
- /** 某工作区下的已归档会话 */
- protected archivedIn(wsId: string): SessionRow[] {
- return this.archivedGroups().byWorkspace.get(wsId) ?? [];
- }
- /** 会话所属容器的状态(2026-11-08):与 PC 左栏 / 会话列表同一口径 */
- protected stateOf(row: SessionRow): AgentStateView {
- return resolveAgentState({ agent: row.agent });
- }
- constructor() {
- /*
- * 工作区必须在这里 load():原实现**全仓只有桌面左栏**调过,
- * 移动端从不加载 → 云端工作区永远拉不下来,
- * 表现为「工作区筛选永远为空」(用户实测)。
- */
- this.workspacesSvc.load();
- this.navMenu.load();
- this.url.set(this.router.url.replace(/^\/mobile/, '').split('?')[0]);
- this.router.events
- .pipe(filter((e): e is NavigationEnd => e instanceof NavigationEnd))
- .subscribe((e) => this.url.set(e.urlAfterRedirects.replace(/^\/mobile/, '').split('?')[0]));
- /*
- * 会话目录预热。
- *
- * 🔴 effect 只能依赖 `open()`:`ensureLoaded()` 内部会读 `live.agents()`,
- * 若在 effect 里直接调用,agents 每次轮询刷新(15s)都会重新触发本 effect →
- * 反复请求会话目录云函数(实测 12 秒内 13 次)。
- * 这里用 untracked 隔断依赖,只在抽屉「打开」这一次真正执行。
- */
- effect(() => {
- if (!this.open()) return;
- untracked(() => this.ensureLoaded());
- });
- }
- /**
- * 折叠状态:键为工作区 id;`__daily` / `__group` 为固定分组;
- * `arch-*` 前缀为各分组下的「已归档」子块(2026-11-08 新增,默认收起)。
- */
- private readonly expanded = signal<Record<string, boolean>>({ __daily: true, __group: true });
- protected isOpen(key: string): boolean {
- // 归档子块默认收起:没显式点过就当关闭
- return this.expanded()[key] ?? !key.startsWith('arch-');
- }
- /** 只切 `hidden` 与箭头,不重建列表(Demo 同法,避免整块重绘跳动) */
- protected toggle(key: string): void {
- const cur = this.isOpen(key);
- this.expanded.update((m) => ({ ...m, [key]: !cur }));
- }
- protected rowsInWorkspace(wsId: string): SessionRow[] {
- return this.view.active().filter((r) => r.session.workspaceId === wsId);
- }
- protected go(path: string): void {
- this.close.emit();
- void this.router.navigateByUrl(`/mobile${path}`);
- }
- /**
- * 打开会话(2026-11-05 区分群聊)。
- *
- * 群会话指向独立的群聊页 `/mobile/group/:groupId`;
- * 单聊仍是 `/mobile/chat/:agentId/:sessionId`。
- */
- protected goChat(row: { session: { id: string; agentId: string; kind?: 'dm' | 'group' } }): void {
- this.close.emit();
- const s = row.session;
- void this.router.navigateByUrl(
- s.kind === 'group'
- ? `/mobile/group/${encodeURIComponent(s.id)}`
- : `/mobile/chat/${s.agentId}/${encodeURIComponent(s.id)}`,
- );
- }
- // ---------- 工作区操作 ----------
- /** 新建工作区(重名校验与 PC 一致) */
- protected newWorkspace(): void {
- this.prompt.open({
- title: '新建工作区',
- placeholder: '工作区名称',
- value: '',
- allowEmpty: false,
- confirmText: '创建',
- validate: (v) => {
- const n = v.trim();
- if (!n) return '名称不能为空';
- if (this.workspacesSvc.hasName(n)) return '已存在同名工作区';
- return null;
- },
- onConfirm: (v) => {
- this.workspacesSvc.create(v.trim());
- },
- });
- }
- /** 工作区「⋯」:重命名 / 删除 */
- protected openWsMenu(id: string): void {
- const ws = this.workspacesSvc.sorted().find((w) => w.id === id);
- if (!ws) return;
- const count = this.rowsInWorkspace(id).length;
- this.menu.set({
- title: ws.name,
- items: [
- { label: '重命名', run: () => this.renameWorkspace(id) },
- {
- label: `删除工作区${count > 0 ? `(${count} 个会话移到日常对话)` : ''}`,
- danger: true,
- run: () => this.deleteWorkspace(id),
- },
- ],
- });
- }
- private renameWorkspace(id: string): void {
- const ws = this.workspacesSvc.sorted().find((w) => w.id === id);
- if (!ws) return;
- this.prompt.open({
- title: '重命名工作区',
- value: ws.name,
- placeholder: '工作区名称',
- allowEmpty: false,
- confirmText: '保存',
- validate: (v) => {
- const n = v.trim();
- if (!n) return '名称不能为空';
- if (this.workspacesSvc.hasName(n, id)) return '已存在同名工作区';
- return null;
- },
- onConfirm: (v) => {
- this.workspacesSvc.rename(id, v.trim());
- },
- });
- }
- /**
- * 删除工作区:先把其下会话退回「日常对话」,再删工作区。
- * 与桌面 `doDeleteWorkspace`(session-tree.ts)同口径 —— 否则会话会失去归属变成孤儿。
- */
- private deleteWorkspace(id: string): void {
- for (const s of this.sessionsSvc.all().filter((x) => x.workspaceId === id)) {
- this.sessionsSvc.moveTo(s.id, null);
- }
- this.workspacesSvc.remove(id);
- }
- // ---------- 会话操作 ----------
- /** 会话「⋯」:移动到工作区 / 归档 / 删除 */
- protected openSessionMenu(row: SessionRow): void {
- const items: MenuItem[] = [
- {
- label: row.session.workspaceId ? '移出到日常对话' : '移动到工作区',
- run: () => this.openMoveMenu(row),
- },
- {
- label: row.session.archived ? '取消归档' : '归档',
- // 归档也要二次确认(与 PC 一致)
- run: () =>
- row.session.archived
- ? this.sessionsSvc.archive(row.session.id, false)
- : this.ask({
- title: '归档会话',
- message: `归档后「${row.session.title}」将移到本分组的「已归档」,可随时取消归档。`,
- text: '归档',
- run: () => {
- this.sessionsSvc.archive(row.session.id, true);
- /*
- * 归档后自动展开**该会话原分组**的「已归档」子块 ——
- * 让用户看到它去了哪里,而不是以为消失了。
- */
- this.revealArchived(row);
- },
- }),
- },
- {
- label: '删除会话',
- danger: true,
- // 删除不可恢复,必须二次确认(与 PC 一致)
- run: () =>
- this.ask({
- title: '删除会话',
- message: `确定删除「${row.session.title}」?删除后该会话不再出现在列表里,消息记录仍在服务器。`,
- text: '删除',
- run: () => this.sessionsSvc.remove(row.session.id),
- }),
- },
- ];
- this.menu.set({ title: row.session.title, items });
- }
- /**
- * 展开某会话「原分组」下的已归档子块(2026-11-08)。
- *
- * 归档后用户需要**看到它去了哪里**;分组本身若收起着,也一并展开。
- * 键与模板里的 `archBlock` 一致:`arch-<wsId>` / `arch-group` / `arch-daily`。
- */
- private revealArchived(row: SessionRow): void {
- const g = groupOf(row.session);
- const archKey = g.kind === 'workspace' ? `arch-${g.id}` : `arch-${g.kind}`;
- const parentKey = g.kind === 'workspace' ? g.id : `__${g.kind}`;
- this.expanded.update((m) => ({ ...m, [parentKey]: true, [archKey]: true }));
- }
- /** 二级:选择目标工作区 */
- private openMoveMenu(row: SessionRow): void { if (row.session.workspaceId) {
- this.sessionsSvc.moveTo(row.session.id, null);
- return;
- }
- const ws = this.workspacesSvc.sorted();
- if (ws.length === 0) {
- this.newWorkspace();
- return;
- }
- this.menu.set({
- title: '移动到工作区',
- items: ws.map((w) => ({
- label: w.name,
- run: () => this.sessionsSvc.moveTo(row.session.id, w.id),
- })),
- });
- }
- /** 在指定工作区中新建对话:带上 workspaceId 交给页面处理 */
- protected newChatIn(workspaceId: string): void {
- this.close.emit();
- this.newChatWithWs.emit(workspaceId);
- }
- /** 弹出二次确认 */
- private ask(opts: { title: string; message: string; text: string; run: () => void }): void {
- this.confirmState.set(opts);
- this.confirmOpen.set(true);
- }
- protected runConfirm(): void {
- this.confirmOpen.set(false);
- const fn = this.confirmState()?.run;
- this.confirmState.set(null);
- fn?.();
- }
- protected runMenu(item: MenuItem): void {
- this.menu.set(null);
- item.run();
- }
- /** 抽屉首次打开时预热会话目录(与桌面左栏同一口径) */
- ensureLoaded(): void {
- if (this.view.ready()) return;
- const ids = this.live
- .agents()
- .filter((a: Agent) => isMatrixReady(a))
- .map((a) => a.id);
- if (ids.length > 0) void this.sessionsSvc.reloadAll(ids);
- }
- }
|