用 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 - 生产已部署
312 lines
12 KiB
TypeScript
312 lines
12 KiB
TypeScript
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>
|
||
);
|
||
}
|