用户两项裁定落地(均为 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,产物待重构建)
340 lines
14 KiB
Plaintext
340 lines
14 KiB
Plaintext
/*
|
||
* 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 : ['同意', '拒绝'];
|
||
}
|
||
}
|