Files
MailUI4Agents/web/src/components/WorkCard.tsx
JianFeeeee be61724314 P5 前端:权限档位选择器 + 卡片徽标 + 对话页改档
前端完整实现:
- types: Session/HumanSession/Mail/Contact 加 permission_mode + permission_enforcement 字段
- api/client: sendMail 支持 permission_mode 参数;新增 updateSessionPermission API
- PermissionChip 组件:plan=蓝/只读, workspace=绿/目录内, full=橙/全权
  native 实心点=平台强制, advisory 空心点=仅提示;hover 显示 tooltip
- ComposePage: 新建会话时显示三档按钮(plan/workspace/full),传入 sendMail
- MailView: 会话头部加 PermissionEditor(点击徽标展开三档选择,点保存调 API 改档)
- WorkCard: 卡片底部与 BudgetChip 并排显示 PermissionChip(compact 模式)
- sessionStore: 新增 setPermissionMode action
- 177 前端测试全过,gateway 8 包全绿,已部署
2026-09-06 21:24:41 +08:00

151 lines
5.3 KiB
TypeScript
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.

import type { Contact } from '../types';
import {
ArchiveIcon,
ComposeIcon,
ChevronRightIcon,
GaugeIcon,
PersonIcon,
BotIcon
} from './icons';
import PermissionChip from './PermissionChip';
/**
* 工作卡片:中间栏的另一种呈现。
*
* 与列表行ContactPanel 的 ContactRow的分工
* 列表答「跟谁在聊」,卡片答「在聊什么、进展如何」。
* 一条线索是一件正在进行的工作,卡片上要能直接看出:
* - 主题(多由 Agent 平台的模型生成的摘要)
* - 最新一封说了什么、谁说的
* - 往返预算还剩多少 —— 预算是任务的属性,快跑满的任务需要人介入
*
* 容器(列表/滚动/空态)由 ContactPanel 负责:两种视图共用同一份数据与同一套
* 打开/写信/归档动作,只有单项的渲染不同。竖向堆叠而非网格 ——
* 卡片在中间栏里320~400px 放不下多列。
*/
export function WorkCard({
contact: c,
active,
onOpen,
onCompose,
onArchive
}: {
contact: Contact;
active: boolean;
onOpen: () => void;
onCompose: () => void;
onArchive: () => void;
}) {
const time = new Date(c.last_activity).toLocaleString('zh-CN', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit'
});
const fromHuman = c.last_from !== c.agent_name;
return (
<div
className={`group flex flex-col rounded-lg border bg-white transition-colors ${
active ? 'border-blue-300 ring-1 ring-blue-100' : 'border-gray-200 hover:border-blue-300'
}`}
>
<button onClick={onOpen} className="flex-1 text-left p-3 min-w-0">
<div className="flex items-center gap-1.5">
<span className="text-xs font-semibold text-gray-900 truncate">{c.agent_name}</span>
<span className="text-[10px] text-gray-400 font-mono truncate">{c.path}</span>
{c.unread_count > 0 && (
<span className="ml-auto shrink-0 min-w-[16px] h-4 px-1 rounded-full bg-blue-500 text-white text-[9px] font-bold flex items-center justify-center">
{c.unread_count}
</span>
)}
</div>
<div className="flex items-center gap-1 mt-0.5">
<ChevronRightIcon className="w-3 h-3 text-blue-400 shrink-0" />
<span className="text-[11px] text-blue-600 font-mono truncate">
{c.session_alias || '(未命名会话)'}
</span>
</div>
{/* 主题是这张卡片的主角:它回答「这条线索在干什么」 */}
<p className="text-xs text-gray-800 mt-1.5 line-clamp-2 leading-snug">
{c.subject || '(无主题)'}
</p>
{c.last_preview && (
<div className="flex items-start gap-1 mt-1.5">
{fromHuman ? (
<PersonIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
) : (
<BotIcon className="w-3 h-3 text-gray-400 shrink-0 mt-0.5" />
)}
<p className="text-[11px] text-gray-500 line-clamp-2 leading-snug">
{c.last_preview}
</p>
</div>
)}
<div className="flex items-center gap-2 mt-2">
<span className="text-[10px] text-gray-400">
{c.mail_count} · {time}
</span>
<div className="flex-1" />
<PermissionChip mode={c.permission_mode} enforcement={c.permission_enforcement} compact />
<BudgetChip max={c.max_rounds} used={c.used_rounds} />
</div>
</button>
<div className="reveal flex gap-1 px-3 pb-2.5">
<button
onClick={onCompose}
title="写信给该地址"
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50"
>
<ComposeIcon className="w-3 h-3" />
</button>
<button
onClick={onArchive}
title="归档该 name@path.session"
className="tap inline-flex items-center gap-1 px-2 py-0.5 rounded border border-gray-300 text-[10px] text-gray-600 hover:bg-gray-50 hover:text-red-600 hover:border-red-300"
>
<ArchiveIcon className="w-3 h-3" />
</button>
</div>
</div>
);
}
/**
* 预算指示条。
*
* 0 = 不限,此时不显示 —— 一个「0/0」或「不限」的徽标对每张卡片都成立
* 等于纯噪声。只在真正设了上限时才占位置。
* 剩 1 个来回时转红:那是需要人介入的时刻(要么加预算,要么让它收尾)。
*
* 导出供测试单独渲染 —— 它是纯展示件,而通过 WorkCard 渲染要先造一整个 Contact。
*/
export function BudgetChip({ max, used }: { max: number; used: number }) {
if (!max || max <= 0) return null;
const remaining = Math.max(max - used, 0);
const tone =
remaining === 0
? 'bg-red-100 text-red-700'
: remaining <= 1
? 'bg-orange-100 text-orange-700'
: 'bg-gray-100 text-gray-500';
return (
<span
className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 rounded-full text-[9px] font-medium shrink-0 ${tone}`}
title={`往返预算:已用 ${used}/${max}${remaining === 0 ? '(已用尽)' : ''}`}
>
<GaugeIcon className="w-2.5 h-2.5" />
{remaining}/{max}
</span>
);
}