/* * AgentMail 鸿蒙客户端 — 详情页里的**待办决策面板** * * ★★ 2026-09-23 新建。用户裁定授权栏走 **`navigator_only`** * (照 WebUI 的架构):授权栏只做**导航**,点一条 → 进详情页决策。 * * ── 这次改动推翻了我上一版的做法,理由记在这里 ── * * 我 2026-09-23 早些时候把决策做在了**授权栏内联**(`PermissionTab` 里的 * `RequestCard`:卡片内直接「同意 / 拒绝」)。当时我给自己写的理由是 * "鸿蒙的待决卡片能显示 `question`/`options`/`context`,比 WebUI 更全"。 * * 那个理由**本身没错,但它答的不是"该在哪决策"这个问题** —— * 我把"信息更全"当成了"可以就地决策"。用户裁定照 WebUI 走,因为: * * ① **两端同一种形状**比"某一端更顺手"重要(用户这一路反复强调的东西); * ② 详情页**本来就要能决策** —— WebUI 的 `MailView.tsx:693` 挂着 * `PermissionPanel`,而鸿蒙详情页对 `permission_request` 只显示了一个 * 「权限请求」小标签(`MailDetailPage.ets:986`),**根本没有决策入口**。 * 也就是说:鸿蒙当时是"栏里能决策、点进详情反而不能" —— 反的。 * * ⇒ 本文件是 `components/MailView.tsx` 的 `PermissionPanel`(`:712-870`) * 在鸿蒙侧的对应件,逐块对齐。 * * ── 两种形态(与 WebUI 同)── * · **审批型**(`permission`):同意 / 拒绝,单行备注,点胶囊即提交; * · **主动提问**(`question`):多选/单选胶囊 + 多行回答,要按「提交回答」。 * 还有第三种状态:**已处理** —— 只显示结果 + 失效横幅,不给操作。 */ import { Theme } from '../common/Theme'; import { AmIcon } from '../common/Icons'; import { Motion } from '../common/Motion'; import { MailApi } from '../api/MailApi'; import { ApiClient, ApiError } from '../api/ApiClient'; import { DecideResponse } from '../model/Models'; @Component export struct PermissionPanel { /** 这条待办的邮件 id(决策接口用它) */ @Prop mailId: string = ''; /** 服务端来自哪个账号的网关(多账号时决策要打到对的那台) */ @Prop server: string = ''; @Prop token: string = ''; /** 已有决策结果(空串 = 还没人处理)—— 与 WebUI 的 `mail.permission_result` 同义 */ @Prop result: string = ''; /** * 等待窗口的截止时刻(服务端 `expires_at`)。 * * ★ WebUI 在**详情页**这边读的是 `mail.permission_expires_at`(另一个字段, * 由 inbox 的 `AttachPermissionDeadline` 算出来)。两边值语义相同, * 都是"超过它就别指望那次调用还能恢复"。 */ @Prop expiresAt: string = ''; /** * 待办种类:`question` = 主动提问(勾选 + 自由文本),其余 = 审批(同意/拒绝)。 * 对齐 WebUI `MailView.tsx:713` 的 `mail.permission_kind === 'question'`。 */ @Prop kind: string = ''; /** 预设选项(WebUI 的 `mail.permission_options`) */ @Prop options: string[] = []; /** 多选题(仅 `question` 用;对齐 `mail.permission_multi_select === true`) */ @Prop multiSelect: boolean = false; /** 决策成功后通知外层(让它刷新详情/列表) */ onDecided: (decision: string) => void = (): void => {}; /** * 当前是否深色 —— 用来取"深浅档不同"的令牌。 * * ★ `Theme.chipWarnBgFor(dark)` 这类 `*For()` 入口都要这个参数: * 它们存在的理由就是"静态常量跟不了主题"(本仓 2026-09-18 实测过 * 「三级文字浅色也一样不够」)。默认 false 走浅色档,与 `MailDetailPage` * 的 `@StorageProp` 同源 —— 由挂载处把真值传进来。 */ @Prop isDark: boolean = false; @State note: string = ''; @State busy: boolean = false; /** 本地已决策的结果(提交成功后立即回显,不等外层刷新) */ @State decided: string = ''; /** 问题型已勾选的选项 */ @State picked: string[] = []; /** 服务端在越窗时回的说明(WebUI 的 `staleWarning`) */ @State staleWarning: string = ''; aboutToAppear(): void { /* 进来时若已有结果,直接进"已处理"态(与 WebUI 的 `useState(mail.permission_result)` 同) */ this.decided = this.result; } /** * 这条待办**是否已过等待窗口**。 * * 判法与 WebUI 逐字同口径(`MailView.tsx:727-729`): * `!result && expiresAt 存在 && Date.now() > Date.parse(expiresAt)`。 * * ★ 与授权栏那条(`PermissionTab.isStale`)是**同一件事的两处**: * 栏里给"即将点下去"的人看,这里给"已经点进来"的人看。WebUI 也是两处都有 * (`PermissionList.tsx:249` 与 `MailView.tsx:727`)—— 不是重复,是同一提示 * 出现在用户可能驻足的两个位置。 */ private isStale(): boolean { if (this.decided.length > 0 || this.expiresAt.length === 0) { return false; } const until: number = Date.parse(this.expiresAt); return !Number.isNaN(until) && Date.now() > until; } private staleText(): string { return this.staleWarning.length > 0 ? this.staleWarning : '已超过等待窗口,发起它的 Agent 很可能已不再阻塞等待。' + '现在批准不会恢复当时那次工具调用 —— 决策会作为一条通知投给它,让它重起一轮。'; } /** 是不是"同意"类(与 WebUI `MailView.tsx:844` 同一正则) */ private isApprove(s: string): boolean { const l: string = s.toLowerCase(); return s.indexOf('同意') >= 0 || s.indexOf('允许') >= 0 || s.indexOf('批准') >= 0 || l.indexOf('approve') >= 0 || l.indexOf('yes') >= 0; } /** * 提交决策。 * * ★ 越窗提示**决策前后都要显**(WebUI 那段注释写明了理由): * 只在决策后显示 = 让人先做错一次;只在决策前显示 = 补不上 * 服务端在两次渲染之间越窗的情形。 */ private async submit(decision: string, noteText: string): Promise { const ctx = this.getUIContext().getHostContext(); if (ctx === undefined || this.busy) { return; } this.busy = true; try { const c: ApiClient = new ApiClient(ctx); c.setBase(this.server); c.setToken(this.token); const resp: DecideResponse = await new MailApi(c).decidePermission(this.mailId, decision, noteText); /* * 服务端在请求已越过等待窗口时回 `expired` + `warning`。 * 这不是错误,是"这次批准不会恢复当时那次调用" —— 必须留痕给用户看, * 而不是弹个 toast 就没了(toast 会消失,而这件事需要一直看得见)。 */ if (resp.warning.length > 0) { this.staleWarning = resp.warning; } else if (resp.expired) { this.staleWarning = this.staleText(); } this.decided = decision.length > 0 ? decision : '(自由文本回答)'; this.onDecided(decision); } catch (e) { const ae = e as ApiError; this.getUIContext().getPromptAction().showToast({ message: ae.message.length > 0 ? ae.message : '决策失败', duration: 4000 }); } finally { this.busy = false; } } /** 越窗横幅(WebUI `staleBanner` 的对应物) */ @Builder StaleBanner() { if (this.isStale() || this.staleWarning.length > 0) { Text(this.staleText()) .fontSize(Theme.fontSmall) .lineHeight(19) .fontColor(Theme.warnFgFor()) .backgroundColor(Theme.warnBgFor()) .borderRadius(Theme.radiusControl) .padding({ left: 10, right: 10, top: 8, bottom: 8 }) .margin({ top: 8 }) .width('100%') } } /** * 一颗选项胶囊(对齐 WebUI `ComposerChip`,`Composer.tsx:158`)。 * * 两种变体: * · `action`(审批型):本身即动作按钮,按语义**一直**填色 * (同意 = 实心绿 / 拒绝 = 浅红); * · `toggle`(提问型):选中才填色,未选中是淡的。 */ @Builder Chip(label: string, active: boolean, asAction: boolean) { Text(label) .fontSize(Theme.fontSmall) .fontColor( (asAction || active) ? (asAction ? this.chipFgFor(label) : Theme.accentFg) : Theme.textPrimary ) .backgroundColor( (asAction || active) ? (asAction ? this.chipActionBgFor(label) : Theme.accent) : Theme.chipBgFor() ) .borderRadius(Theme.radiusControl) .padding({ left: 12, right: 12, top: 7, bottom: 7 }) .margin({ right: 8, top: 6 }) .opacity(this.busy ? 0.5 : 1) .onClick(() => { if (this.busy) { return; } if (asAction) { /* 审批:点胶囊即提交(只有批准/拒绝两个动作,不需要再按一次) */ this.submit(label, this.note); } else if (this.multiSelect) { this.picked = this.picked.indexOf(label) >= 0 ? this.picked.filter((p: string) => p !== label) : this.picked.concat([label]); } else { /* 单选:再点同一项则取消,否则替换(与 WebUI `toggle` 同) */ this.picked = this.picked.indexOf(label) >= 0 ? [] : [label]; } }) } /** 动作型胶囊的字色(实心底 ⇒ 恒用前景白/品牌前景) */ private chipFgFor(label: string): string { return Theme.accentFg; } /** 动作型胶囊的底色:同意 = 实心绿,其余 = 危险红(对齐 WebUI `activeCls`) */ private chipActionBgFor(label: string): string { return this.isApprove(label) ? Theme.approve : Theme.danger; } build() { Column() { /* ── 已处理态:只显示结果,不给操作(WebUI `MailView.tsx:762-770`)── */ if (this.decided.length > 0) { Row() { Text('已处理:').fontSize(Theme.fontSmall).fontColor(Theme.textMuted) Text(this.decided) .fontSize(Theme.fontSmall).fontWeight(FontWeight.Bold) .fontColor(Theme.textPrimary) .layoutWeight(1) } .width('100%') .alignItems(VerticalAlign.Top) this.StaleBanner() } else if (this.kind === 'question') { /* * ── 主动提问:勾选 + 自由文本(WebUI `MailView.tsx:772-825`)── * * 回答**必须非空**:空提交会让模型拿到一个什么都没说的结果继续跑。 */ Text(this.options.length === 0 ? '这题没有预设选项,请直接填写回答:' : (this.multiSelect ? '可多选,也可补充说明:' : '请选择一项,也可补充说明:')) .fontSize(Theme.fontSmall).fontColor(Theme.textMuted) .width('100%') this.StaleBanner() if (this.options.length > 0) { Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.options, (opt: string) => { this.Chip(opt, this.picked.indexOf(opt) >= 0, false) }, (opt: string) => 'opt-' + opt) } .width('100%') } TextInput({ placeholder: this.options.length === 0 ? '你的回答(必填)' : '补充说明(可选)', text: this.note }) .width('100%').height(40).margin({ top: 8 }) .fontSize(Theme.fontSmall) .onChange((v: string) => { this.note = v; }) Row() { Button(this.busy ? '提交中…' : '提交回答') .height(38) .fontSize(Theme.fontSmall) /* 空回答时禁用 —— 与 WebUI `submit.disabled = blank` 同 */ .enabled(!this.busy && (this.picked.length > 0 || this.note.trim().length > 0)) .backgroundColor(Theme.accent) .onClick(() => { this.submit(this.picked.join('\n'), this.note.trim()); }) if (this.picked.length === 0 && this.note.trim().length === 0) { Text('请先选择或填写回答') .fontSize(Theme.fontSmall).fontColor(Theme.textSubtleFor()) .margin({ left: 8 }) } } .width('100%') .margin({ top: 8 }) } else { /* * ── 审批型:同意 / 拒绝(WebUI `MailView.tsx:826-870`)── * * 没有预设选项时退回 ['同意', '拒绝'] —— 与 WebUI 同一兜底。 */ this.StaleBanner() Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.actionOptions(), (opt: string) => { this.Chip(opt, false, true) }, (opt: string) => 'act-' + opt) } .width('100%') TextInput({ placeholder: '备注(可选)', text: this.note }) .width('100%').height(40).margin({ top: 8 }) .fontSize(Theme.fontSmall) .onChange((v: string) => { this.note = v; }) } } .width('100%') .alignItems(HorizontalAlign.Start) .padding({ top: 10 }) /* * 与正文之间那条橙色分隔(WebUI `border-t border-orange-200`)。 * * ★ 用 `chipWarnBg`(= tailwind `orange-100`,`#FFEDD5`)而不是新造一个 * `warnBorder` 令牌:本仓的纪律是"令牌要么来自系统、要么是跨端逐字一致的 * Tailwind 值"。WebUI 那边 `orange-200` 只出现在这两处边框上, * 为它单独立一个跨端令牌**反而会把两边绑到一个各自都用不到几次的值上**。 * 用已有的橙色档(差一档、观感同族)比多一个令牌好。 */ .border({ width: { top: 1 }, color: Theme.chipWarnBgFor(this.isDark) }) .margin({ top: 12 }) /* * 入场的淡入位移 —— 详情页正文之后出现的一块内容。 * 与 WebUI 那边 `.rise-in` 同观感(本仓的 `Theme.paneRiseIn`)。 */ .transition(Theme.paneRiseIn()) } /** 审批型胶囊的取值(与 WebUI `mail.permission_options?.length ? … : ['同意','拒绝']` 同) */ private actionOptions(): string[] { return this.options.length > 0 ? this.options : ['同意', '拒绝']; } }