# 自定义背景(新功能)
三选一:不设 / 预设渐变 / 自定义图片,另加压暗与模糊两条滑杆。
**预设的色值全部复用现有调色板变量**,因此自动随主题变化 —— 那一组
(50–300)在深色下本来就是暗的(见 .dark 与 theme.test.mjs 第 19 条),
于是浅色得到柔和 pastel、深色得到低沉暗调,不需要维护两套渐变,也不会
出现「深色模式下原样落下浅色渐变」这类绕过主题变量的错误。
图片路径的关键取舍:
- **先压缩再存**。手机直出照片 4–8MB,而 localStorage 配额约 5MB,直接写会抛
异常,用户看到的是「选了图片没反应」。等比缩到最长边 2560px、转 JPEG;
仍超限则再缩一档;再不行就**明确拒绝并说明原因**(不是静默失败)。
- 失败一律返回 `{ok:false, reason}` 并渲染成 `role="alert"`。
# 背景层为什么不放进主题 store
主题(light/dark/system)是必须全局一致的语义;背景是纯装饰偏好,取值空间
与主题毫无关系。混在一起会让「跟随系统」的实现被背景字段淹没。
# 背景层实现在 CSS,不改 27 个组件
按 Tailwind 生成的实际类名统一接管:背景开启时让出不透明的页面底
(body / bg-gray-50 / bg-slate-100 → 透明),并把卡片(bg-white)与框架
(bg-chrome-800/900)变成半透明 + 背景模糊。
逐个组件加 class 必然漏 —— 漏掉的那块就是一张不透明卡片浮在背景上。
这段 CSS **刻意放在所有 @layer 之外**:它要覆盖的正是 utilities 生成的
`.bg-white`,写进 @layer components 会被 utilities 压过去(静默失效),
而分层 CSS 恒输给未分层 CSS,这是唯一稳定可靠的位置。
**chrome-600/700 刻意保持不透明**:它们不是大面板,而是导航项与 15px 的
计数徽标。真实渲染量得半透明会把徽标上的数字压到 4.46:1,低于 AA 4.5 ——
小控件的可读性优先于装饰效果(已用脚本量出,见下)。
# 「跟随系统」的可见性
三态本来就已实现(system 为默认值 + matchMedia 监听)。这次做的是让它可被
发现与信任:选择器改成分段控件(role=radiogroup + aria-checked),说明文案
写清「跟随系统会随系统的深色开关自动切换」,并保留单选按钮入口的
「当前跟随系统:深色/浅色」提示。
# 外观现代化
- **圆角整体调大一档**(默认 0.25→0.5rem)。原值是几年前的紧凑风格,
在宽屏桌面应用上偏硬。只改比例尺,200 处圆角一次性刷新,不产生
「新组件大圆角、旧组件小圆角」的断层。
- 语义化圆角令牌:`rounded-card` / `rounded-control`(数值档位答的是「多大」,
这两个名字答的是「用在哪」)。
- 自定义滚动条(桌面应用里常驻可见,系统默认样式偏旧)。
- 键盘焦点环(`:focus-visible`,仅键盘导航时出现;可访问性硬要求)。
- 交互元素统一过渡;并尊重 `prefers-reduced-motion`。
# 顺带修正两处真实问题(都由真实渲染量出,不是估算)
1. **实心按钮白字在深色下 4.46:1,低于 AA**。
深色 `--c-on-accent` 是「近白」244 246 250(为了不刺眼),而结构检查第 23
条只拿**浅色**的纯白 255 去算 → 4.83 通过。**测试存在盲区**:
同一个实心底,白字换暗一点点就越过 AA 线。导航未读徽标「12」正是这个组合。
两处都修:把第 23 条改成**两种模式的 on-accent 都算**(闭合盲区),
并把深色 on-accent 抬到 250 250 252(4.65:1,仍非纯白,保留原初衷)。
2. **theme.test.mjs 切颜色块的方式很脆**:它用 `indexOf('.dark')` 切片,于是在
:root 的注释里写一句带点的选择器写法就会把浅色块提前截断(我加注释时
真的踩到了,第 8 条假失败)。更危险的是反向情形:块被截短后变量集合变小,
「覆盖齐全」这类断言可能**真空通过**。改为所有块切分都基于**剥注释后**的文本。
# 测试
- 新增 `test/background.test.mjs`(15 条结构检查):遮罩两主题各一份、
背景层必须负 z-index(0 会盖住界面)、背景开启时必须让出页面底、
玻璃化只在 data-bg=on 下、悬停态一并接管、预设复用调色板变量、
图片上限与失败原因存在、尊重 reduced-motion 等。
- 新增 `test/stores/background.test.ts`(16 条):脏数据归一化(未知预设、
kind=image 却无图、越界数值)、CSS 变量写入与清理成对(残留 --bg-image 会
让「关掉背景」后仍显示旧图)、localStorage 抛异常不打断操作。
- 新增 `test/manual/background-verify.mjs`:连真实 Chromium 验收**渲染结果**
(背景层是否真的可见、玻璃化的计算样式、正文在背景之上是否仍达 WCAG AA、
自动模式在**不刷新**页面时跟随系统切换、显式选择不被系统覆盖)。
它拦住了上面两个真问题,也拦住了我自己两次写错的判据。
# 验证
- typecheck 干净
- 主题 30/30、背景 15/15、vitest 216/216(新增 16)
- 真实渲染验收 23/23(AGENTMAIL_DIST 注入本地构建 + 活 Gateway,未部署即验收)
- 截图对照:浅色/深色 + 极光背景,面板玻璃化与层次均符合预期
251 lines
9.7 KiB
TypeScript
251 lines
9.7 KiB
TypeScript
import { useCallback, useEffect, useState } from 'react';
|
||
import { useAuthStore } from '../stores/authStore';
|
||
import * as api from '../api/client';
|
||
import { LockIcon, LogoutIcon } from './icons';
|
||
import KeyPanel from './KeyPanel';
|
||
import ThemePicker from './ThemePicker';
|
||
import BackgroundPicker from './BackgroundPicker';
|
||
|
||
/** 当前用户个人中心:查看资料、修改密码、管理客户端连接密钥 */
|
||
export default function AccountPage() {
|
||
const user = useAuthStore(s => s.user);
|
||
const logout = useAuthStore(s => s.logout);
|
||
const [oldPw, setOldPw] = useState('');
|
||
const [newPw, setNewPw] = useState('');
|
||
const [confirmPw, setConfirmPw] = useState('');
|
||
const [busy, setBusy] = useState(false);
|
||
const [msg, setMsg] = useState<string | null>(null);
|
||
const [error, setError] = useState<string | null>(null);
|
||
|
||
// 密钥面板状态
|
||
const [keys, setKeys] = useState<api.UserKey[]>([]);
|
||
const [keyBusy, setKeyBusy] = useState(false);
|
||
const [keyError, setKeyError] = useState<string | null>(null);
|
||
const [newToken, setNewToken] = useState<string | null>(null);
|
||
|
||
const loadKeys = useCallback(async () => {
|
||
try {
|
||
const r = await api.listMyKeys();
|
||
setKeys(r.keys);
|
||
setKeyError(null);
|
||
} catch (err) {
|
||
setKeyError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
}, []);
|
||
|
||
useEffect(() => {
|
||
loadKeys();
|
||
}, [loadKeys]);
|
||
|
||
const createKey = async (payload: api.CreateKeyPayload) => {
|
||
setKeyBusy(true);
|
||
setKeyError(null);
|
||
try {
|
||
const r = await api.createMyKey(payload);
|
||
// 全文只在创建响应里出现一次,必须当场展示
|
||
setNewToken(r.key.key_token ?? null);
|
||
await loadKeys();
|
||
} catch (err) {
|
||
setKeyError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setKeyBusy(false);
|
||
}
|
||
};
|
||
|
||
const deleteKey = async (id: string) => {
|
||
setKeyError(null);
|
||
try {
|
||
await api.deleteMyKey(id);
|
||
await loadKeys();
|
||
} catch (err) {
|
||
setKeyError(err instanceof Error ? err.message : String(err));
|
||
}
|
||
};
|
||
|
||
const mismatch = confirmPw !== '' && newPw !== confirmPw;
|
||
const canSubmit = oldPw.length > 0 && newPw.length >= 8 && !mismatch && !busy;
|
||
|
||
const changePw = async (e: React.FormEvent) => {
|
||
e.preventDefault();
|
||
if (!canSubmit) return;
|
||
setBusy(true);
|
||
setError(null);
|
||
setMsg(null);
|
||
try {
|
||
await api.changePassword(oldPw, newPw);
|
||
setMsg('密码已修改,请重新登录');
|
||
setOldPw('');
|
||
setNewPw('');
|
||
setConfirmPw('');
|
||
} catch (err) {
|
||
setError(err instanceof Error ? err.message : String(err));
|
||
} finally {
|
||
setBusy(false);
|
||
}
|
||
};
|
||
|
||
if (!user) return null;
|
||
|
||
return (
|
||
<div className="flex-1 min-w-0 flex flex-col bg-white">
|
||
<div className="shrink-0 px-4 md:px-6 py-3 border-b border-gray-200 flex items-center gap-2">
|
||
<h2 className="text-sm font-semibold text-gray-900">账号信息</h2>
|
||
</div>
|
||
|
||
{/* 滚动容器。
|
||
缺了它的后果:这个页的内容(资料 + 权限 + 改密码 + 密钥 + 退出)
|
||
比视口高,而父级是 overflow-hidden 的 flex 列 —— 超出那段直接被裁掉,
|
||
没有任何办法滚到。实测 390px 下内容需 860px、容器只有 795px;
|
||
1280x800 的桌面上同样看不到最后的「退出登录」。 */}
|
||
<div className="flex-1 overflow-y-auto">
|
||
<div className="max-w-lg px-4 md:px-6 py-6 space-y-6">
|
||
{/* 基本信息 */}
|
||
<section>
|
||
<h3 className="text-xs font-medium text-gray-500 mb-3">基本资料</h3>
|
||
<dl className="text-sm space-y-2">
|
||
<Row label="用户名" value={user.username} mono />
|
||
<Row label="显示名" value={user.display_name} />
|
||
<Row label="角色" value={user.role === 'admin' ? '管理员' : '普通用户'} />
|
||
<Row label="状态" value={user.status === 'active' ? '启用' : '禁用'} />
|
||
<Row label="创建时间" value={user.created_at || '-'} />
|
||
<Row label="最后登录" value={user.last_login || '从未登录'} />
|
||
</dl>
|
||
</section>
|
||
|
||
{/* 权限边界 */}
|
||
{user.role !== 'admin' && (
|
||
<section>
|
||
<h3 className="text-xs font-medium text-gray-500 mb-3">权限范围</h3>
|
||
<dl className="text-sm space-y-2">
|
||
<Row
|
||
label="可调用 Agent"
|
||
value={
|
||
user.allowed_agents.length === 0
|
||
? '不限(全部可用)'
|
||
: user.allowed_agents.join(', ')
|
||
}
|
||
/>
|
||
<Row
|
||
label="可访问目录"
|
||
value={
|
||
user.allowed_paths.length === 0
|
||
? '不限(全部可用)'
|
||
: user.allowed_paths.join(', ')
|
||
}
|
||
/>
|
||
</dl>
|
||
</section>
|
||
)}
|
||
|
||
{/* 修改密码 */}
|
||
<section>
|
||
<h3 className="text-xs font-medium text-gray-500 mb-3 inline-flex items-center gap-1">
|
||
<LockIcon className="w-3.5 h-3.5" />
|
||
修改密码
|
||
</h3>
|
||
<form onSubmit={changePw} className="space-y-3">
|
||
<div>
|
||
<label className="block text-[11px] font-medium text-gray-500 mb-1">当前密码</label>
|
||
<input
|
||
type="password"
|
||
value={oldPw}
|
||
onChange={e => setOldPw(e.target.value)}
|
||
autoComplete="current-password"
|
||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-medium text-gray-500 mb-1">新密码(至少 8 位)</label>
|
||
<input
|
||
type="password"
|
||
value={newPw}
|
||
onChange={e => setNewPw(e.target.value)}
|
||
autoComplete="new-password"
|
||
className="w-full text-sm border border-gray-300 rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 focus:border-blue-400"
|
||
/>
|
||
</div>
|
||
<div>
|
||
<label className="block text-[11px] font-medium text-gray-500 mb-1">确认新密码</label>
|
||
<input
|
||
type="password"
|
||
value={confirmPw}
|
||
onChange={e => setConfirmPw(e.target.value)}
|
||
autoComplete="new-password"
|
||
className={`w-full text-sm border rounded-md px-3 py-1.5 focus:outline-none focus:ring-2 focus:ring-blue-100 ${
|
||
mismatch ? 'border-red-300' : 'border-gray-300 focus:border-blue-400'
|
||
}`}
|
||
/>
|
||
{mismatch && <p className="mt-1 text-[10px] text-red-500">两次密码不一致</p>}
|
||
</div>
|
||
|
||
{error && (
|
||
<p className="text-xs text-red-600 bg-red-50 border border-red-100 rounded-md px-2.5 py-1.5">
|
||
{error}
|
||
</p>
|
||
)}
|
||
{msg && (
|
||
<p className="text-xs text-green-600 bg-green-50 border border-green-100 rounded-md px-2.5 py-1.5">
|
||
{msg}
|
||
</p>
|
||
)}
|
||
|
||
<button
|
||
type="submit"
|
||
disabled={!canSubmit}
|
||
className="px-4 py-1.5 text-sm font-medium rounded-md bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
|
||
>
|
||
{busy ? '保存中' : '保存'}
|
||
</button>
|
||
</form>
|
||
</section>
|
||
|
||
{/* 客户端连接密钥 */}
|
||
<section className="border-t border-gray-200 pt-6">
|
||
<KeyPanel
|
||
variant="user"
|
||
keys={keys}
|
||
loading={keyBusy}
|
||
error={keyError}
|
||
newToken={newToken}
|
||
onCreate={createKey}
|
||
onDelete={deleteKey}
|
||
onDismissToken={() => setNewToken(null)}
|
||
/>
|
||
</section>
|
||
|
||
{/* 外观。放在密钥之后、退出之前:它是一个高频且完全可逆的偏好,
|
||
与「账号自身」的密码/密钥属于不同性质,但同样是「我的设置」。 */}
|
||
<section className="border-t border-gray-200 pt-6 space-y-6">
|
||
<ThemePicker />
|
||
<BackgroundPicker />
|
||
</section>
|
||
|
||
{/* 退出登录。
|
||
放在这里而不是导航里:它是一个低频且不可逆的动作,
|
||
与密码、密钥同属「账号自身」。窄屏下这也是唯一的退出口:
|
||
抽屉式侧栏已删(它的其余入口与底部导航完全重复)。 */}
|
||
<section className="border-t border-gray-200 pt-6">
|
||
<h3 className="text-xs font-medium text-gray-500 mb-3">登录状态</h3>
|
||
<button
|
||
onClick={logout}
|
||
className="tap inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium rounded-md border border-gray-300 text-gray-700 hover:bg-gray-50 active:bg-gray-100 transition-colors"
|
||
>
|
||
<LogoutIcon className="w-4 h-4" />
|
||
退出登录
|
||
</button>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function Row({ label, value, mono }: { label: string; value: string; mono?: boolean }) {
|
||
return (
|
||
<div className="flex items-baseline gap-3">
|
||
<dt className="w-20 shrink-0 text-xs text-gray-400">{label}</dt>
|
||
<dd className={`text-sm text-gray-900 break-all ${mono ? 'font-mono' : ''}`}>{value}</dd>
|
||
</div>
|
||
);
|
||
}
|