fix(webui): 日历补上圆角 —— 外层面板圆了、里面的格子还是直角

用户:「日历页面怎么没有圆角」。

原因与列表那次同源:**外层面板一直是圆的**(`.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` 裁**(它会杀滚动),
所以每一层自己都要圆角。这是这套"浮动面板"设计的固有代价,写在这里备查。)
This commit is contained in:
2026-09-14 13:47:58 +08:00
parent dfaeab0e4d
commit bb855b1518
4 changed files with 282 additions and 130 deletions

View File

@ -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 (
<div className="flex-1 min-h-0 overflow-auto">
<div className="glass-card flex-1 min-h-0 overflow-auto">
<div className="grid grid-cols-7 min-w-[36rem] h-full">
{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 (
<div className="flex-1 min-h-0 overflow-y-auto">
<div className="glass-card flex-1 min-h-0 overflow-y-auto">
<div className="px-3 py-2 text-xs text-gray-500 border-b border-gray-100">
{formatSolarWithLunar(anchor)}
</div>

View File

@ -199,3 +199,118 @@ test('页面把折叠逻辑真正接上了(不是"逻辑写好了没人用"
assert.match(page, /toggleExpanded\(g\.key\)/, '组头要能点开(用户真正会点的那一层)');
assert.match(page, /this\.WorkCard\(c\)/, '卡片视图要真的渲染出来');
});
// ───────────────────────── 撤掉平级「会话」tabP2a 收尾) ─────────────────────────
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-strikesplan 与 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画强制力标记等于编一个"平台做到了什么"'
);
});

View File

@ -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);
}

View File

@ -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<void> {
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-14P2a 收尾)。
*
* 撤掉的依据是"信息没有丢":那张会话列表独有的一列 ——
* 会话别名 / 主题、对方 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;