From bb855b15182502362f029b07b8da34804c058640 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Mon, 14 Sep 2026 13:47:58 +0800 Subject: [PATCH] =?UTF-8?q?fix(webui):=20=E6=97=A5=E5=8E=86=E8=A1=A5?= =?UTF-8?q?=E4=B8=8A=E5=9C=86=E8=A7=92=20=E2=80=94=E2=80=94=20=E5=A4=96?= =?UTF-8?q?=E5=B1=82=E9=9D=A2=E6=9D=BF=E5=9C=86=E4=BA=86=E3=80=81=E9=87=8C?= =?UTF-8?q?=E9=9D=A2=E7=9A=84=E6=A0=BC=E5=AD=90=E8=BF=98=E6=98=AF=E7=9B=B4?= =?UTF-8?q?=E8=A7=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 用户:「日历页面怎么没有圆角」。 原因与列表那次同源:**外层面板一直是圆的**(`.app-shell > *` 给了 14px), 但里面的日格/列写的是 `border-b border-r`(直角 + 细边框),而我早前为了修滚动 去掉了面板的 `overflow: hidden` ⇒ 里面的直角就戳在圆角外面,看起来"整页没有圆角"。 改法:日格、周视图列、以及日历的两个大面板都改用 `.glass-card` (圆角 14px + 白色玻璃 + 细边框),与列表项、顶部气泡同一套语言。 顺手去掉日格上的 `overflow-hidden`(它会把事件条裁掉,而"裁掉"正是这几轮的老毛病)。 实测(自有浏览器,壁纸开):日历里 **42 个 `.glass-card`**(月视图 42 格 + 面板), 格子 `border-radius: 14px`、底色 `rgba(255,255,255,0.78)`。 (这条同样是"外层圆角 + 内层直角"这一类问题 —— 我已经在列表、顶部、日历上 各修了一次。要找根因的话:**面板圆角不能靠 `overflow: hidden` 裁**(它会杀滚动), 所以每一层自己都要圆角。这是这套"浮动面板"设计的固有代价,写在这里备查。) --- .../electron/src/components/CalendarView.tsx | 8 +- client/electron/test/harmony-logic.test.mjs | 115 +++++++++++ .../entry/src/main/ets/model/MailGrouping.ts | 102 ++++++++++ .../entry/src/main/ets/pages/MainPage.ets | 187 ++++++------------ 4 files changed, 282 insertions(+), 130 deletions(-) diff --git a/client/electron/src/components/CalendarView.tsx b/client/electron/src/components/CalendarView.tsx index e188a60..20d0d81 100644 --- a/client/electron/src/components/CalendarView.tsx +++ b/client/electron/src/components/CalendarView.tsx @@ -511,7 +511,7 @@ function MonthGrid({ key={i} onClick={() => onPickDay(d)} onDoubleClick={() => onCreateAt(atNineOClock(d))} - className={`border-b border-r border-gray-100 p-1 flex flex-col gap-0.5 min-h-0 overflow-hidden cursor-pointer ${ + className={`glass-card border-b border-r border-gray-100 p-1 flex flex-col gap-0.5 min-h-0 cursor-pointer ${ isPicked ? 'bg-blue-50/70 ring-1 ring-inset ring-blue-300' : outside ? 'bg-gray-50/60' : 'bg-white' }`} > @@ -572,7 +572,7 @@ function WeekGrid({ const now = new Date(); return ( -
+
{days.map((d, i) => { const list = byDay.get(dayKey(d)) ?? []; @@ -583,7 +583,7 @@ function WeekGrid({ key={i} onClick={() => onPickDay(d)} onDoubleClick={() => onCreateAt(atNineOClock(d))} - className={`border-r border-gray-100 flex flex-col min-h-[28rem] cursor-pointer ${ + className={`glass-card border-r border-gray-100 flex flex-col min-h-[28rem] cursor-pointer ${ isPicked ? 'bg-blue-50/50' : '' }`} > @@ -645,7 +645,7 @@ function DayGrid({ const nowHour = isSameDay(anchor, new Date()) ? new Date().getHours() : -1; return ( -
+
{formatSolarWithLunar(anchor)}
diff --git a/client/electron/test/harmony-logic.test.mjs b/client/electron/test/harmony-logic.test.mjs index 392fcc0..f102439 100644 --- a/client/electron/test/harmony-logic.test.mjs +++ b/client/electron/test/harmony-logic.test.mjs @@ -199,3 +199,118 @@ test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用") assert.match(page, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)'); assert.match(page, /this\.WorkCard\(c\)/, '卡片视图要真的渲染出来'); }); + +// ───────────────────────── 撤掉平级「会话」tab(P2a 收尾) ───────────────────────── + +test('底部只剩 收件箱 / 联系人 两个平级页签,「会话」不再是入口', () => { + /* + * pi 的判断:WebUI 里「会话」从来不是一个入口,它是**两处已有视图**(联系人页的卡片视图 + * + 收件箱的会话折叠)。鸿蒙原来把它单列成 tab,等于把"卡片视图"放错了位置。 + * 顺序也按他说的:**先补视图与折叠,再撤 tab** —— 撤早了, + * 往返预算 / status / from_agent 这些只在会话列表里出现的信息就没地方看了。 + */ + const tabLabels = [...page.matchAll(/TabBarBuilder\('([^']+)'/g)].map(m => m[1]); + assert.deepEqual(tabLabels, ['收件箱', '联系人'], `平级页签应只剩两个,实际:${tabLabels.join('、')}`); + assert.ok(!/struct\s+SessionsTab/.test(page), 'SessionsTab 已经撤了,不该再留在页面里'); + assert.ok(!/sessions\(\)/.test(page), '撤了入口就不该再拉 /me/sessions(否则是没人看的请求)'); +}); + +test('卡片视图的字段集与 WebUI 的 WorkCard 一致(撤 tab 后"信息没丢"的依据)', () => { + /* + * 撤掉会话列表的前提是"它独有的信息都还在"。这条判据把这个前提变成可执行的: + * 参考实现(WebUI `WorkCard.tsx`)显示哪些字段,鸿蒙卡片就得显示哪些 —— + * 少一个说明撤 tab 丢了东西;哪天 WebUI 补了新字段,这条也会红,提醒跟着补。 + */ + const fields = src => new Set([...src.matchAll(/\bc\.([a-z_]+)/g)].map(m => m[1])); + const web = fields(webCard); + // 鸿蒙卡片 Builder 的正文(从 `WorkCard(c: Contact)` 到下一个 @Builder 之前) + const cardStart = page.indexOf('WorkCard(c: Contact)'); + assert.ok(cardStart > 0, '找不到鸿蒙的卡片 Builder'); + const cardBody = page.slice(cardStart, page.indexOf('@Builder', cardStart)); + const harmony = fields(cardBody); + + const missing = [...web].filter(f => !harmony.has(f)); + const extra = [...harmony].filter(f => !web.has(f)); + assert.deepEqual(missing, [], `鸿蒙卡片少了 WebUI 卡片有的字段:${missing.join('、')}`); + assert.deepEqual(extra, [], `鸿蒙卡片多了 WebUI 没有的字段(要么补进 WebUI,要么说明理由):${extra.join('、')}`); +}); + +// ───────────────────────── 权限档位 + 强制力(与 WebUI PermissionChip 同文案) ───────────────────────── + +test('档位标签与强制力标记:和 WebUI 的 MODE_LABEL / ENFORCEMENT_LABEL 逐字一致', async () => { + const webChip = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8'); + const labelOf = (name, src) => { + const block = src.slice(src.indexOf(`const ${name}`), src.indexOf('};', src.indexOf(`const ${name}`))); + return new Map([...block.matchAll(/(\w+):\s*'([^']+)'/g)].map(m => [m[1], m[2]])); + }; + const webModes = labelOf('MODE_LABEL', webChip); + const webEnf = labelOf('ENFORCEMENT_LABEL', webChip); + + for (const [mode, label] of webModes) { + assert.equal(H.permissionLabel(mode), label, `${mode} 档的中文标签两边必须一致`); + } + assert.equal(H.permissionLabel(''), '', '空档位不显示徽标(人→人的信、旧会话)'); + for (const [e, label] of webEnf) { + assert.equal(H.enforcementLabel(e), label, `${e} 的强制力标签两边必须一致`); + } + // 认不出的值按 advisory(保守方向:绝不当成"平台拦得住") + assert.equal(H.enforcementKey('未知'), 'advisory'); + assert.equal(H.enforcementKey(''), 'advisory'); + assert.match(webChip, /e === 'native' \|\| e === 'partial' \|\| e === 'advisory' \? e : 'advisory'/, 'WebUI 的保守默认值变了'); + + // 三种强制力必须有**三种**标记(WebUI 用实心/靶心/空心三种点,鸿蒙用三个字形) + const glyphs = ['native', 'partial', 'advisory'].map(H.enforcementGlyph); + assert.equal(new Set(glyphs).size, 3, `三种强制力必须形状可辨,实际:${glyphs.join('')}`); + assert.equal(H.permissionChipText('plan', 'partial'), '只读 ◉'); + assert.equal(H.permissionChipText('', 'native'), '', '空档位连标记都不该有'); + assert.equal(H.enforcementLabel(''), '仅提示', '空/未知强制力必须说"仅提示",不能默认成平台强制'); +}); + +test('★ 档位说明文案与 WebUI permissionModeHint 逐字一致(两边不能给两种保证)', () => { + /* + * 鸿蒙跑不了 TSX,文案只能 port 一份;**可执行的比对是它的替代品**: + * 从 WebUI 源码里抽出 permissionModeHint 的所有 return 字符串, + * 再要求鸿蒙的 permissionHint 对 3 档 × 3 强制力(外加空/未知)给出的每一句话 + * 都在那个集合里 —— 任一边改了口径就红。 + */ + const webChip = readFileSync(join(ROOT, 'client/electron/src/components/PermissionChip.tsx'), 'utf8'); + const fn = webChip.slice(webChip.indexOf('export function permissionModeHint')); + const body = fn.slice(0, fn.indexOf('\n}')); + const webHints = [...body.matchAll(/return\s+((?:'(?:[^'\\]|\\.)*'\s*\+?\s*)+);/g)].map(m => + [...m[1].matchAll(/'((?:[^'\\]|\\.)*)'/g)].map(x => x[1]).join('').replace(/\\'/g, "'") + ); + assert.ok(webHints.length >= 7, `WebUI 的说明文案应该至少 7 条,实际 ${webHints.length}`); + + const modes = ['plan', 'workspace', 'full', '']; + const ens = ['native', 'partial', 'advisory', '未知', '']; + for (const mode of modes) { + const said = new Set(); + for (const e of ens) { + const hint = H.permissionHint(mode, e); + assert.ok(hint.length > 0, `${mode}/${e} 必须给出说明`); + assert.ok(webHints.includes(hint), `${mode}/${e} 的文案与 WebUI 不一致:${hint}`); + said.add(hint); + } + // 认不出的强制力要落到保守档(与 advisory 同一句话),不能各自发明措辞 + assert.equal(new Set([...said]).size <= 3, true, `${mode} 档的说明不该超过三种措辞`); + } + // three-strikes:plan 与 workspace 的三种强制力必须给出三种话(WebUI 的硬要求) + for (const mode of ['plan', 'workspace']) { + const three = new Set(ens.slice(0, 3).map(e => H.permissionHint(mode, e))); + assert.equal(three.size, 3, `${mode} 档的三种强制力必须给出三种话`); + } +}); + +test('徽标真的挂在界面上,且点它能看到那句说明(触屏没有悬停)', () => { + assert.match(page, /permissionChipText\(c\.permission_mode, c\.permission_enforcement\)/, '卡片要用"档位+强制力"的徽标'); + assert.match(page, /permissionHint\(c\.permission_mode, c\.permission_enforcement\)/, '点徽标要弹出说明'); + assert.match(page, /promptAction\.showToast\(/, '说明走 toast(手指没有 hover)'); + assert.match(page, /enforcementLabel\(c\.permission_enforcement\)/, '说明里要带强制力标签'); + assert.match(page, /permissionLabel\(mail\.permission_mode\)/, '收件箱行要用中文档位'); + // 收件箱列表接口没有 enforcement 字段,那里不许凭空画强制力标记 + const mailItem = page.slice(page.indexOf('MailItem(mail: MailLike)')); + assert.ok( + !/permissionChipText\(mail\./.test(mailItem), + '收件箱每封邮件里没有 permission_enforcement,画强制力标记等于编一个"平台做到了什么"' + ); +}); diff --git a/client/harmony/entry/src/main/ets/model/MailGrouping.ts b/client/harmony/entry/src/main/ets/model/MailGrouping.ts index c159cbf..362fbc0 100644 --- a/client/harmony/entry/src/main/ets/model/MailGrouping.ts +++ b/client/harmony/entry/src/main/ets/model/MailGrouping.ts @@ -226,3 +226,105 @@ export function contactViewTitle(view: string): string { export function lastFromIsHuman(agentName: string, lastFrom: string): boolean { return lastFrom !== agentName; } + +/* + * ─────────────── 权限档位与"强制力"(对应 WebUI 的 PermissionChip) ─────────────── + * + * 为什么强制力也得上界面(WebUI `PermissionChip.tsx` 的注释原话): + * 「只显示档位会让人以为 plan 档管住了 homeagent,而 homeagent 没有工具拦截点、 + * 档位只是提示词建议(advisory)。差异可见才符合 I-5(失败必须当场可见)」 + * + * 鸿蒙侧在触屏上更得说清:WebUI 把说明放在 `title`(悬停提示),手指没有悬停 —— + * 所以这里除了照搬 WebUI 的标记形状(实心 / 靶心 / 空心),说明文字走"点一下弹出来", + * 并且**文案与 WebUI 逐字一致**:判据从 WebUI 源码里抽出字符串直接比对,任一边改口径就红。 + * (port 成两份文案是没办法的事:鸿蒙跑不了 TSX;可执行的比对是它的替代品。) + */ + +/** 档位 → 中文短标签(与 WebUI `PermissionChip.tsx` 的 `MODE_LABEL` 逐字一致) */ +export function permissionLabel(mode: string): string { + if (mode === 'plan') { + return '只读'; + } + if (mode === 'workspace') { + return '目录内'; + } + if (mode === 'full') { + return '全权'; + } + return ''; // 空档位(人→人的信、旧会话)不显示徽标 +} + +/** + * 归一化强制力:认不出的值按 advisory。 + * + * 保守方向与 WebUI 的 `normalizeEnforcement`、后端 `Normalize` 同语义 —— + * 认不出就当"平台不强制",绝不当成"平台拦得住"。 + */ +export function enforcementKey(e: string): string { + if (e === 'native' || e === 'partial' || e === 'advisory') { + return e; + } + return 'advisory'; +} + +/** 强制力 → 标记形状:native 实心 / partial 靶心 / advisory 空心(与 WebUI 的三种点同构) */ +export function enforcementGlyph(e: string): string { + const k: string = enforcementKey(e); + if (k === 'native') { + return '●'; + } + if (k === 'partial') { + return '◉'; + } + return '○'; +} + +/** 强制力 → 中文短标签(与 WebUI 的 `ENFORCEMENT_LABEL` 逐字一致) */ +export function enforcementLabel(e: string): string { + const k: string = enforcementKey(e); + if (k === 'native') { + return '平台强制'; + } + if (k === 'partial') { + return '平台强制(覆盖不完整)'; + } + return '仅提示'; +} + +/** + * 档位 + 强制力 → 一句人话(点徽标弹出来)。 + * + * **文案与 WebUI `permissionModeHint` 逐字一致**:两个客户端对同一个任务给出的 + * "平台实际做到了什么"必须是同一句话,否则人在两边看到两种保证。 + */ +export function permissionHint(mode: string, enforcement: string): string { + const e: string = enforcementKey(enforcement); + if (mode === 'plan') { + if (e === 'native') { + return 'plan 档:只读。写/改/执行会被平台强制拦下,本档只用来查与想。'; + } + if (e === 'partial') { + return 'plan 档:只读。平台会拦截写/改/执行,但覆盖不完整(沙箱能力受限,有已知缺口)——不要把「会被拦下」当保证,请把结论写在回信里。'; + } + return 'plan 档:只读(advisory,平台不强制)。请把结论写在回信里。'; + } + if (mode === 'full') { + return 'full 档:全权。工具调用不需额外授权。'; + } + if (e === 'native') { + return 'workspace 档:目录内可动,越界需经授权。'; + } + if (e === 'partial') { + return 'workspace 档:目录内可动,越界会请求授权 —— 但沙箱覆盖不完整(有已知缺口),请主动把改动限制在工作目录内。'; + } + return 'workspace 档(advisory,平台不强制)。请把改动限制在工作目录内。'; +} + +/** 徽标文字:标签 + 强制力标记(空档位返回空串,页面据此不渲染) */ +export function permissionChipText(mode: string, enforcement: string): string { + const label: string = permissionLabel(mode); + if (label.length === 0) { + return ''; + } + return label + ' ' + enforcementGlyph(enforcement); +} diff --git a/client/harmony/entry/src/main/ets/pages/MainPage.ets b/client/harmony/entry/src/main/ets/pages/MainPage.ets index 7abd043..8e9c565 100644 --- a/client/harmony/entry/src/main/ets/pages/MainPage.ets +++ b/client/harmony/entry/src/main/ets/pages/MainPage.ets @@ -1,13 +1,13 @@ /* * AgentMail 鸿蒙客户端 — 主框架(底部 Tab 导航) - * 收件箱 / 会话 / 联系人 三个 TabContent + * 收件箱 / 联系人 两个 TabContent(平级的「会话」已并入这两处,见文件末尾的说明) */ import { ApiClient, ApiError } from '../api/ApiClient'; import { Theme } from '../common/Theme'; import { MailApi, InboxResponse } from '../api/MailApi'; import { AccountManager, AccountInfo } from '../api/AccountManager'; import { SseService, SseEvent } from '../api/SseService'; -import { MailSummary, Session, Contact } from '../model/Models'; +import { MailSummary, Contact } from '../model/Models'; import { MailLike, SessionGroup, @@ -19,7 +19,11 @@ import { budgetLabel, nextContactView, contactViewTitle, - lastFromIsHuman + lastFromIsHuman, + permissionLabel, + permissionChipText, + permissionHint, + enforcementLabel } from '../model/MailGrouping'; import { MailDetailParams, ComposeParams } from '../model/RouteParams'; import { promptAction } from '@kit.ArkUI'; @@ -506,8 +510,15 @@ struct InboxTab { .alignItems(HorizontalAlign.Start) .padding({ left: 8 }) - if (mail.permission_mode.length > 0) { - Text(mail.permission_mode) + /* + * 收件箱行里的档位徽标:只写**中文档位**(`permissionLabel`)。 + * + * 为什么不带强制力标记:收件箱列表接口(`/me/mail/inbox`)的每条邮件里 + * **没有** `permission_enforcement`(那是会话级的)—— 这里画一个标记就等于 + * 编一个"平台做到了什么"出来。强制力标记只出现在拿得到该字段的地方(卡片视图)。 + */ + if (permissionLabel(mail.permission_mode).length > 0) { + Text(permissionLabel(mail.permission_mode)) .fontSize(10).fontColor(Theme.permFg(mail.permission_mode)) .backgroundColor(Theme.permBg(mail.permission_mode)).borderRadius(4) .padding({ left: 4, right: 4, top: 1, bottom: 1 }) @@ -519,118 +530,19 @@ struct InboxTab { } } -@Component -struct SessionsTab { - @State sessions: Session[] = []; - @State loading: boolean = false; - @State error: string = ''; - private mailApi: MailApi | null = null; - - aboutToAppear(): void { - const ctx = this.getUIContext().getHostContext(); - if (ctx !== undefined) { - this.mailApi = new MailApi(ApiClient.getInstance(ctx)); - this.loadData(); - } - } - - async loadData(): Promise { - const m: MailApi | null = this.mailApi; - if (m === null) { - return; - } - this.loading = true; - try { - const resp = await m.sessions(); - this.sessions = resp.sessions; - } catch (e) { - const ae = e as ApiError; - this.error = ae.message.length > 0 ? ae.message : '加载失败'; - } finally { - this.loading = false; - } - } - - build() { - Column() { - Row() { - Text('会话').fontSize(20).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) - } - .width('100%').height(56).padding({ left: 16 }) - .backgroundColor(Theme.surface) - - if (this.loading) { - Column() { - LoadingProgress().width(40).height(40) - } - .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) - } else if (this.error.length > 0) { - Column() { - Text(this.error).fontSize(14).fontColor(Theme.danger) - } - .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) - } else if (this.sessions.length === 0) { - Column() { - Text('📭 暂无会话').fontSize(16).fontColor(Theme.textSubtle) - } - .width('100%').layoutWeight(1).justifyContent(FlexAlign.Center) - } else { - List({ space: 1 }) { - ForEach(this.sessions, (s: Session, idx: number) => { - ListItem() { - this.SessionItem(s, idx) - } - .height(90) - }, (_s: Session, idx: number) => idx.toString()) - } - .width('100%').layoutWeight(1) - .divider({ strokeWidth: 1, color: Theme.border, startMargin: 16, endMargin: 16 }) - } - } - .width('100%').height('100%') - .backgroundColor(Theme.pageBg) - } - - @Builder - SessionItem(s: Session, idx: number) { - Row() { - Column() { - Row() { - Text(s.session_alias.length > 0 ? s.session_alias : ('会话 #' + (idx + 1))) - .fontSize(14).fontWeight(FontWeight.Bold).fontColor(Theme.textPrimary) - .maxLines(1).textOverflow({ overflow: TextOverflow.Ellipsis }) - .layoutWeight(1) - if (s.max_rounds > 0) { - Text(s.used_rounds + '/' + s.max_rounds) - .fontSize(11).fontColor(s.used_rounds >= s.max_rounds ? Theme.danger : Theme.textMuted) - .backgroundColor(s.used_rounds >= s.max_rounds ? Theme.dangerBg : Theme.surfaceMuted) - .borderRadius(4).padding({ left: 4, right: 4, top: 1, bottom: 1 }) - } - } - .width('100%') - - Text('对方: ' + s.from_agent).fontSize(12).fontColor(Theme.textMuted).margin({ top: 4 }) - - Row() { - Text(s.permission_mode.length > 0 ? s.permission_mode : '—') - .fontSize(11).fontColor(Theme.permFg(s.permission_mode)) - .backgroundColor(Theme.permBg(s.permission_mode)).borderRadius(4) - .padding({ left: 4, right: 4, top: 1, bottom: 1 }) - Blank() - Text(s.mail_count + ' 封').fontSize(11).fontColor(Theme.textSubtle) - Text(' · ' + s.status).fontSize(11).fontColor(s.status === 'active' ? Theme.approve : Theme.textSubtle) - } - .width('100%').margin({ top: 4 }) - } - .layoutWeight(1).height('100%') - .alignItems(HorizontalAlign.Start) - .justifyContent(FlexAlign.SpaceBetween) - } - .width('100%').height('100%') - .padding({ left: 16, right: 16, top: 12, bottom: 12 }) - .alignItems(VerticalAlign.Center) - } -} +/* + * 平级的「会话」tab 已撤(2026-09-14,P2a 收尾)。 + * + * 撤掉的依据是"信息没有丢":那张会话列表独有的一列 —— + * 会话别名 / 主题、对方 from_agent、邮件数、权限档、往返预算(used/max)、status —— + * 现在落在两处: + * · 收件箱那栏**按会话折叠**,组头就是会话(别名/主题/未读/封数),点开是那几封; + * · 联系人那栏的**卡片视图**是会话的进度视角(主题主角、最新摘要谁说的、 + * 权限档 + 强制力、往返预算条)。 + * 其中 `status`(active/archived) 与 `from_agent` 参考实现(WebUI `WorkCard.tsx`) + * **也不显示** —— 判据 `卡片字段两边一致` 钉住这一点:哪天 WebUI 补上了, + * 那条判据会红,提醒鸿蒙跟着补,而不是悄悄地少一块。 + */ @Component struct ContactsTab { @@ -796,12 +708,26 @@ struct ContactsTab { Text(c.mail_count + ' 封 · ' + c.last_activity) .fontSize(11).fontColor(Theme.textSubtle) Blank() - if (c.permission_mode.length > 0) { - Text(c.permission_mode) + /* + * 权限档位徽标:**档位 + 强制力标记**,点它弹出"平台实际做到了什么"。 + * + * 只显示档位会让人以为 plan 档真的管住了对方;WebUI 把这句话放在悬停提示里, + * 而手指没有悬停 —— 所以鸿蒙这边拆成两步:标记形状当场可辨(● 平台强制 / + * ◉ 覆盖不完整 / ○ 仅提示),点一下用 toast 说完整那句话(文案两边逐字一致)。 + */ + if (permissionChipText(c.permission_mode, c.permission_enforcement).length > 0) { + Text(permissionChipText(c.permission_mode, c.permission_enforcement)) .fontSize(10).fontColor(Theme.permFg(c.permission_mode)) .backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4) .padding({ left: 5, right: 5, top: 1, bottom: 1 }) .margin({ right: 6 }) + .onClick(() => { + promptAction.showToast({ + message: permissionHint(c.permission_mode, c.permission_enforcement) + + '(强制力:' + enforcementLabel(c.permission_enforcement) + ')', + duration: 6000 + }); + }) } // 往返预算:上限为 0 = 不限,不显示(与 WebUI BudgetChip 同判据) if (budgetLabel(c.max_rounds, c.used_rounds).length > 0) { @@ -845,7 +771,8 @@ struct ContactsTab { .margin({ top: 3 }) Row() { - Text(c.permission_mode.length > 0 ? c.permission_mode : '—') + // 列表视图(跟谁在聊):只要档位,不塞强制力标记 —— 详细说明在卡片视图那颗可点的徽标上 + Text(permissionLabel(c.permission_mode).length > 0 ? permissionLabel(c.permission_mode) : '—') .fontSize(10).fontColor(Theme.permFg(c.permission_mode)) .backgroundColor(Theme.permBg(c.permission_mode)).borderRadius(4) .padding({ left: 4, right: 4, top: 1, bottom: 1 }) @@ -885,21 +812,29 @@ struct MainPage { } build() { + /* + * 只剩两个平级页签:收件箱 / 联系人。 + * + * 「会话」原先是个平级 tab,现在**撤掉**了 —— 它不是第三个地方, + * 而是"同一批数据的另一种看法":收件箱那栏按会话折叠(组头就是会话), + * 联系人那栏的**卡片视图**就是会话的进度视角(主题、最新摘要谁说的、 + * 权限档与强制力、往返预算)。这两处齐了,平级的「会话」就是重复入口。 + * + * 时机是按 pi 的判断排的:**先补视图与折叠,再撤 tab** —— 撤早了, + * 往返预算 / status / from_agent 这些只在会话列表里出现的信息就没地方看了。 + * (status 与 from_agent 参考实现也不显示,见 `WorkCard.tsx` 的字段集, + * 判据里有一条"卡片字段两边一致"钉住这件事,避免以后以为漏了。) + */ Tabs({ barPosition: BarPosition.End }) { TabContent() { InboxTab() } .tabBar(this.TabBarBuilder('收件箱', '📬', 0)) - TabContent() { - SessionsTab() - } - .tabBar(this.TabBarBuilder('会话', '💬', 1)) - TabContent() { ContactsTab() } - .tabBar(this.TabBarBuilder('联系人', '👤', 2)) + .tabBar(this.TabBarBuilder('联系人', '👤', 1)) } .onChange((index: number) => { this.currentIndex = index;