Files
MailUI4Agents/web/src/components/KeyPanel.tsx
JianFeeeee 342282b92c fix: 窄屏实测修复 —— 删抽屉、44px 命中区、触摸设备可见的次要动作
用 playwright 连本机共享 Chromium,在 390px(iPhone 14 Pro)与 320px
(iPhone SE)量真实盒子。之前的窄屏适配是「照着规则写对」,实测发现
一个功能性 bug 加五处可用性问题。

## 抽屉式侧栏遮挡底部导航(真 bug)

抽屉是 `fixed left-0 top-0 bottom-0 z-50`,铺满整个视口高度;底部导航没有
z-index。抽屉打开时点最左那一项「收件」,elementFromPoint 命中的是抽屉里的
SVG,不是导航按钮 —— 按钮在那里、尺寸也够、CSS 规则也没写错,只有命中测试
才能发现。

**删掉抽屉而不是给导航加 z-index**:抽屉装的六项(收件/发件/联系/用户/新建/
管理)与底部导航完全重复,唯一独有的是退出登录。为一个按钮维护一套 fixed
层级加遮罩不划算,而它还附带「Esc 关不掉」「底层未锁滚」两个毛病。

退出登录移到「我的」页 —— 它与密码、密钥同属「账号自身」,而那页此前根本
没有退出入口。`NavToggle` 与 uiStore 的 navOpen/toggleNav/closeNav 一并删除。

## 触摸命中区:新增 .tap

详情页那排工具按钮视觉高度只有 15-16px(实测「标记已读」48x16、「对话树」
54x16、「转发」42x16、「抄送」20x15),移动端下限是 44x44。

直接加 padding 会把本来就挤的头部撑散、320px 下换行,因此用居中的透明伪元素
扩大命中区:**视觉一像素不动**。只在 max-width:767px 生效 —— 桌面用鼠标精度
足够,而扩大后的命中区在密排工具栏里会互相重叠,点一个可能命中隔壁那个。

覆盖 MailView / ContactPanel / WorkCard / ModelScopePanel / AdminUsersPage /
ComposePage / ThreadView / KeyPanel / QuotaPanel / Attachments / BackButton。

## 看不见却按得动的按钮:新增 .reveal

`opacity-0 group-hover:opacity-100` 在没有 hover 的设备上永远是 opacity:0,
**但仍然接收点击** —— 实测联系人列表里 elementFromPoint 命中的就是那个看不见的
「归档」。一个看不见却按得动的破坏性按钮比没有按钮更糟:人以为点的是卡片,
实际归档了一条会话。

改为默认可见,只在 `(hover: hover) and (pointer: fine)` 时隐藏。
单看 hover 会把带触摸板的平板算进去。

## 对话树

- 缩进随屏宽自适应:固定「每级 20px、上限 8 级」= 最多 160px,320px 屏还要
  去掉 px-4 的 32px 与连接线 18px,卡片只剩 110px,发件人一行直接被 truncate
  吃掉。窄屏改为每级 10px、上限 5 级
- 补返回出口:原先只有「关闭」。两者语义不同 —— 返回退出整个详情栏回到列表,
  关闭只收起树、留在这封邮件上

## 把实测脚本留进仓库

`web/test/manual/`(`npm run test:narrow` / `test:wide`),不进 npm test ——
要一个跑着的浏览器加一个活的 Gateway。

留着而不是用完即删,是因为结构性断言守不住「按钮实际多大、点下去命中谁」,
而这次最严重的 bug 恰好只有 elementFromPoint 能发现。helper 里两个函数专门
为此:tapTargets() 量 .tap 的真实命中区(伪元素尺寸,不是 boundingBox),
hitTest() 验每个元素点下去是否命中自己。

## 验证

- 窄屏 13 项 + 宽屏 5 项全通过。宽屏回归特意验了两件只该在窄屏生效的事:
  .tap 伪元素 content 为 none、没有返回按钮
- narrow-layout.test.mjs 从 20 条扩到 28 条,逐条钉住上面每个修复
- 无横向溢出:390px 与 320px 下 scrollWidth === clientWidth
- 生产已部署
2026-09-02 23:53:59 +08:00

312 lines
12 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 { useState } from 'react';
import * as api from '../api/client';
import { KeyIcon, CopyIcon, TrashIcon, PlusIcon, CheckIcon } from './icons';
/** 密钥类型的中文说明,创建表单与列表共用一份文案 */
export const KEY_TYPE_LABEL: Record<api.KeyType, string> = {
permanent: '长期',
one_time: '一次性',
timed: '限时'
};
const KEY_TYPE_HINT: Record<api.KeyType, string> = {
permanent: '永不过期,可重复使用',
one_time: '首次使用后立即失效',
timed: '指定小时数后过期'
};
/** 一条密钥在列表里的状态:过期/已用完/可用 */
function keyState(k: { key_type: api.KeyType; expires_at: string | null; used_at: string | null }) {
if (k.key_type === 'one_time' && k.used_at) return { text: '已使用', cls: 'text-gray-400' };
if (k.key_type === 'timed' && k.expires_at && new Date(k.expires_at) < new Date())
return { text: '已过期', cls: 'text-red-500' };
return { text: '可用', cls: 'text-green-600' };
}
/**
* 新签发密钥的一次性展示条。
*
* 密钥全文只在创建响应里出现一次,服务端之后只返回前 8 位,
* 所以这里必须明确提示「关掉就再也看不到」,而不是让用户以为随时能回来复制。
*/
function NewKeyBanner({ token, onDismiss }: { token: string; onDismiss: () => void }) {
const [copied, setCopied] = useState(false);
const copy = async () => {
try {
await navigator.clipboard.writeText(token);
setCopied(true);
setTimeout(() => setCopied(false), 1500);
} catch {
/* 无剪贴板权限时用户可手动选中 */
}
};
return (
<div className="border border-amber-300 bg-amber-50 rounded-md p-3 space-y-2">
<div className="text-xs font-medium text-amber-900">
</div>
<div className="flex items-center gap-2">
<code className="flex-1 text-[11px] font-mono bg-white border border-amber-200 rounded px-2 py-1.5 break-all">
{token}
</code>
<button
onClick={copy}
className="tap shrink-0 flex items-center gap-1 text-xs px-2 py-1.5 border border-amber-300 rounded hover:bg-amber-100"
>
{copied ? <CheckIcon className="w-3.5 h-3.5" /> : <CopyIcon className="w-3.5 h-3.5" />}
{copied ? '已复制' : '复制'}
</button>
<button onClick={onDismiss} className="tap shrink-0 text-xs text-amber-800 hover:underline">
</button>
</div>
</div>
);
}
interface CreateFormProps {
/** Agent 密钥面板会多出「绑定 Agent」与「登记已有密钥」两项 */
variant: 'agent' | 'user';
busy: boolean;
onSubmit: (payload: api.CreateKeyPayload) => void;
}
function CreateForm({ variant, busy, onSubmit }: CreateFormProps) {
const [open, setOpen] = useState(false);
const [keyType, setKeyType] = useState<api.KeyType>('permanent');
const [label, setLabel] = useState('');
const [hours, setHours] = useState(24);
const [agentName, setAgentName] = useState('');
const [keyToken, setKeyToken] = useState('');
if (!open) {
return (
<button
onClick={() => setOpen(true)}
className="flex items-center gap-1.5 text-xs px-3 py-1.5 border border-gray-300 rounded-md hover:bg-gray-50"
>
<PlusIcon className="w-3.5 h-3.5" />
</button>
);
}
const submit = () => {
const payload: api.CreateKeyPayload = { key_type: keyType, label: label.trim() };
if (keyType === 'timed') payload.expires_hours = hours;
if (variant === 'agent') {
if (agentName.trim()) payload.agent_name = agentName.trim();
if (keyToken.trim()) payload.key_token = keyToken.trim();
}
onSubmit(payload);
setOpen(false);
setLabel('');
setAgentName('');
setKeyToken('');
};
return (
<div className="border border-gray-200 rounded-md p-3 space-y-2.5 bg-gray-50">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
{(Object.keys(KEY_TYPE_LABEL) as api.KeyType[]).map(t => (
<button
key={t}
onClick={() => setKeyType(t)}
className={`text-left px-2.5 py-2 rounded border text-xs ${
keyType === t
? 'border-blue-400 bg-white ring-2 ring-blue-100'
: 'border-gray-200 bg-white hover:border-gray-300'
}`}
>
<div className="font-medium text-gray-900">{KEY_TYPE_LABEL[t]}</div>
<div className="text-[10px] text-gray-500 mt-0.5">{KEY_TYPE_HINT[t]}</div>
</button>
))}
</div>
<div className="flex gap-2">
<input
value={label}
onChange={e => setLabel(e.target.value)}
placeholder="备注(如 我的笔记本 / CI 机器)"
className="flex-1 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
{keyType === 'timed' && (
<div className="flex items-center gap-1">
<input
type="number"
min={1}
value={hours}
onChange={e => setHours(Math.max(1, Number(e.target.value) || 1))}
className="w-20 text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<span className="text-[11px] text-gray-500"></span>
</div>
)}
</div>
{variant === 'agent' && (
<div className="space-y-2">
<input
value={agentName}
onChange={e => setAgentName(e.target.value)}
placeholder="绑定到 Agent留空 = 首次注册时自动落定)"
className="w-full text-xs border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
<input
value={keyToken}
onChange={e => setKeyToken(e.target.value)}
placeholder="登记插件本地生成的密钥(留空 = 由服务器生成)"
className="w-full text-xs font-mono border border-gray-300 rounded px-2 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100"
/>
</div>
)}
<div className="flex items-center gap-2">
<div className="flex-1" />
<button onClick={() => setOpen(false)} className="text-xs text-gray-600 hover:text-gray-900">
</button>
<button
onClick={submit}
disabled={busy}
className="tap text-xs px-3 py-1.5 rounded bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40"
>
</button>
</div>
</div>
);
}
/**
* 密钥面板。Agent 密钥(管理员)与用户连接密钥共用同一套渲染,
* 差异用 variant 表达:只有 Agent 密钥能绑定 Agent 名、能登记客户端已生成的密钥。
*/
export default function KeyPanel({
variant,
keys,
loading,
error,
newToken,
onCreate,
onDelete,
onBind,
onDismissToken
}: {
variant: 'agent' | 'user';
keys: (api.AgentKey | api.UserKey)[];
loading: boolean;
error: string | null;
newToken: string | null;
onCreate: (payload: api.CreateKeyPayload) => void;
onDelete: (id: string) => void;
onBind?: (id: string, agentName: string) => void;
onDismissToken: () => void;
}) {
const [bindingID, setBindingID] = useState<string | null>(null);
const [bindName, setBindName] = useState('');
return (
<div className="space-y-3">
<div className="flex items-center gap-2">
<KeyIcon className="w-4 h-4 text-gray-500" />
<h3 className="text-sm font-semibold text-gray-900">
{variant === 'agent' ? 'Agent 接入密钥' : '客户端连接密钥'}
</h3>
<div className="flex-1" />
<CreateForm variant={variant} busy={loading} onSubmit={onCreate} />
</div>
<p className="text-[11px] text-gray-500">
{variant === 'agent'
? 'Agent 用该密钥注册、收发邮件与订阅通知。插件首次安装会在本地生成一把密钥并打印出来,把它填到「登记」框即可。'
: '第三方客户端用该密钥访问自己的邮箱Authorization: Bearer。它不能用于注册 Agent。'}
</p>
{error && <div className="text-xs text-red-600">{error}</div>}
{newToken && <NewKeyBanner token={newToken} onDismiss={onDismissToken} />}
{keys.length === 0 ? (
<div className="text-xs text-gray-400 py-3"></div>
) : (
<div className="border border-gray-200 rounded-md divide-y divide-gray-100">
{keys.map(k => {
const st = keyState(k);
const agentKey = variant === 'agent' ? (k as api.AgentKey) : null;
return (
<div key={k.key_id} className="px-3 py-2.5 flex items-center gap-x-3 gap-y-1 flex-wrap">
<code className="text-[11px] font-mono text-gray-700 w-24 shrink-0">
{k.token_hint}
</code>
<div className="min-w-0 flex-1">
<div className="text-xs text-gray-900 truncate">
{k.label || <span className="text-gray-400"></span>}
</div>
<div className="text-[10px] text-gray-500 mt-0.5">
{KEY_TYPE_LABEL[k.key_type]}
{k.expires_at && ` · ${new Date(k.expires_at).toLocaleString()} 过期`}
{agentKey &&
(agentKey.agent_name ? ` · ${agentKey.agent_name}` : ' · 待绑定')}
</div>
</div>
<span className={`text-[10px] shrink-0 ${st.cls}`}>{st.text}</span>
{agentKey && onBind && bindingID === k.key_id ? (
<div className="flex items-center gap-1 shrink-0">
<input
value={bindName}
onChange={e => setBindName(e.target.value)}
placeholder="Agent 名"
className="w-28 text-[11px] border border-gray-300 rounded px-1.5 py-1"
/>
<button
onClick={() => {
if (bindName.trim()) onBind(k.key_id, bindName.trim());
setBindingID(null);
setBindName('');
}}
className="text-[11px] text-blue-600 hover:underline"
>
</button>
<button
onClick={() => setBindingID(null)}
className="text-[11px] text-gray-500 hover:underline"
>
</button>
</div>
) : (
agentKey &&
onBind && (
<button
onClick={() => {
setBindingID(k.key_id);
setBindName(agentKey.agent_name ?? '');
}}
className="text-[11px] text-gray-500 hover:text-gray-900 shrink-0"
>
</button>
)
)}
<button
onClick={() => onDelete(k.key_id)}
title="吊销"
className="shrink-0 text-gray-400 hover:text-red-600"
>
<TrashIcon className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
)}
</div>
);
}