Files
MailUI4Agents/client/harmony/entry/src/main/ets/pages/PermissionPanel.ets
JianFeeeee 65de1c3884 跨端对齐:授权栏 navigator_only + 组件按页拆分 + 服务器补 permission_options
用户两项裁定落地(均为 ask_user 明确选择):

① 授权栏口径 = navigator_only(照 WebUI 架构)
   · 新建 pages/PermissionPanel.ets —— 详情页的决策面板,
     对应 MailView.tsx:693 的 PermissionPanel(审批型 / 主动提问 / 已处理 三态)
   · 决策入口从授权栏移到 MailDetailPage;MailDetailPage 原来只显示一个
     「权限请求」小标签、根本没有决策入口(比 WebUI 少一整块,且反了:
     栏里能决策、点进详情反而不能)
   · PermissionTab 删掉内联「同意/拒绝」+ 备注框 + decide():
     整卡可点 → onOpenMail(对齐 WebUI PermissionList.tsx:81 的 pick())
   · PermissionRequest 补 source_account_id(客户端侧记来源,跳详情要定位网关)

② 服务器补 permission_options —— 修一条真实的、跨端共有的缺口
   · mails.permission_options 从 INSERT 起就写进去,但**从来没有任何读路径
     选过它** ⇒ 详情端点永远返回空。WebUI 的决策面板读 mail.permission_options,
     所以提问型的预设选项**两端全部落空**(审批型靠 ['同意','拒绝'] 兜底蒙混)
   · GetMailByID 补选该列 + JSON 反序列化(与 cc_list 同款)

③ 组件按页封装(用户要求「以便与 WebUI 一一对应」)
   MainPage.ets 4592 → 3192 行
   · pages/PermissionTab.ets    720 行  ↔ PermissionList.tsx
   · pages/ContactsTab.ets      796 行  ↔ ContactPanel.tsx
   · pages/NavDestinations.ets  181 行  ↔ Navigation 壳
   · pages/NavShared.ets         65 行  ↔ 跨栏共用件

④ 判据跟着组件搬家(否则静默失效,不是红)
   harmony-logic 的 pageCode / harmony-nav 的 navSrc 改为显式文件名单;
   harmony-appearance 的 PANE_SOURCES 补 ContactsTab;harmony-contacts 三个
   test 并入 ContactsTab;harmony-logic 的决策断言改指 PermissionPanel,
   并新增「授权栏不许再有内联决策」两条(navigator_only 的正形状)。

   animation-audit:共享元素转场判据从「同文件共址」改为「按 id 找驱动」。
   旧形状把 in/out 端必须在同一文件当成代理,而两端**天然在两处**;
   抽出写信页(NavDestinations 持有 in 端)后误报。新判据仍要求每个 id
   都有 Motion.morph 驱动 —— 变异实测:把驱动换成裸 animateTo 仍判红。

判据:files=34 checks=556 red=1(仅 build-stamp,产物待重构建)
2026-09-24 10:10:32 +08:00

340 lines
14 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/*
* 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<void> {
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 : ['同意', '拒绝'];
}
}