Files
MailUI4Agents/client/electron/src/components/BackgroundPicker.tsx
JianFeeeee be13ae5959 feat(webui): 界面加构建戳 —— 让"我这边改了没"变成可核对的
用户连续三轮说「webui 还没改」,而我每次都能证明部署是活的(入口 no-cache、
资源哈希 immutable、线上 bundle 与本地构建逐字节一致、无头浏览器实测生效)——
问题在于**隔着屏幕说不清对方的浏览器跑的是哪一份**。

现在界面里显示一行 `界面构建 <git短哈希>·<月日-时分>`(外观设置面板页脚):

- `vite.config.ts` 在构建时注入 `__BUILD_STAMP__`(git 短哈希 + 构建时刻)
- `BackgroundPicker` 渲染它,并带 title 说明格式
- 判据 4 条(vite 注入 / 组件渲染 / **产物里真的有** / 判据自检:正则不能匹配随便一段文本)

实测:产物与线上都是 `d2904fc·0914-0910`;刷新后数字变了就是拿到了新构建。

顺带修正一处过时文案:图片说明还写着"保存在本机",而它现在同时存到账号里
(服务端 + 本地缓存)。

前端 254 条 + 新增 4 条、打包一致性、server 10 包全绿;桌面包已重打。
2026-09-14 09:11:36 +08:00

301 lines
10 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 { useRef, useState } from 'react';
import { useAppearanceSync } from '../stores/appearanceSync';
import {
DEFAULT_BACKGROUND,
MAX_DATA_URL_BYTES,
PRESETS,
prepareImage,
useBackgroundStore,
type BackgroundKind
} from '../stores/backgroundStore';
import { UploadIcon, TrashIcon } from './icons';
/**
* 自定义背景设置。
*
* 三条设计约束:
*
* 1. **预览就是真实效果**。预设缩略图直接复用 `.bg-preset-*` 类(与全屏背景
* 同一份 CSS),不是另画一张示意图 —— 否则预览与结果必然会漂移。
* 2. **不让用户交出控制权**。背景会直接影响正文对比度,所以「压暗」与「模糊」
* 都给显式滑杆,而不是自动选一个值。默认值取偏保守的一侧。
* 3. **失败要出声**。图片过大/解码失败时给出具体原因;静默失败会让人以为
* 按钮坏了(本仓库在权限按钮上踩过同类问题)。
*/
export default function BackgroundPicker() {
const kind = useBackgroundStore(s => s.kind);
const presetId = useBackgroundStore(s => s.presetId);
const imageDataUrl = useBackgroundStore(s => s.imageDataUrl);
const dim = useBackgroundStore(s => s.dim);
const blur = useBackgroundStore(s => s.blur);
const setKind = useBackgroundStore(s => s.setKind);
const setPreset = useBackgroundStore(s => s.setPreset);
const setImage = useBackgroundStore(s => s.setImage);
const setDim = useBackgroundStore(s => s.setDim);
const setBlur = useBackgroundStore(s => s.setBlur);
const reset = useBackgroundStore(s => s.reset);
const fileRef = useRef<HTMLInputElement>(null);
const [error, setError] = useState('');
const [busy, setBusy] = useState(false);
const active = kind !== 'none';
const pick = async (file: File | undefined) => {
if (!file) return;
setBusy(true);
setError('');
const result = await prepareImage(file);
setBusy(false);
if (result.ok) {
setImage(result.dataUrl);
} else {
setError(result.reason);
}
};
const optionClass = (on: boolean) =>
`px-3 py-2.5 rounded-control border text-xs flex flex-col items-center gap-1.5 transition-colors ${
on
? 'border-blue-500 bg-blue-50 text-blue-700'
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
}`;
return (
<div>
<div className="flex items-center gap-2 mb-1">
<h3 className="text-sm font-medium text-gray-900">背景</h3>
<SyncBadge />
{active && (
<button
onClick={reset}
className="text-xs text-gray-500 hover:text-gray-700 inline-flex items-center gap-1"
>
<TrashIcon className="w-3 h-3" />
恢复默认
</button>
)}
</div>
<p className="text-xs text-gray-500 mb-3">
背景会显示在卡片后面,两种主题各有对应色调。
</p>
{/* 三选一:不设 / 预设 / 自定义图片。与主题选择器同一套控件形态,
让「外观」这一组看起来是一件事。 */}
<div className="grid grid-cols-3 gap-2 mb-3">
{(
[
{ value: 'none', label: '无' },
{ value: 'preset', label: '预设' },
{ value: 'image', label: '图片' }
] as { value: BackgroundKind; label: string }[]
).map(o => (
<button
key={o.value}
onClick={() => {
setError('');
setKind(o.value);
}}
className={optionClass(kind === o.value)}
>
{o.label}
</button>
))}
</div>
{kind === 'preset' && (
<div className="grid grid-cols-3 gap-2">
{PRESETS.map(p => (
<button
key={p.id}
onClick={() => setPreset(p.id)}
title={p.label}
className={`h-14 rounded-control border overflow-hidden relative transition-transform hover:scale-[1.02] ${
presetId === p.id ? 'border-blue-500 ring-1 ring-blue-500' : 'border-gray-300'
}`}
>
{/* 用与全屏背景同一份 CSS 变量渲染缩略图 —— 预览必然等于结果 */}
<span
className={`bg-preset-${p.id} absolute inset-0 block`}
style={{ backgroundImage: 'var(--bg-image)', backgroundSize: 'cover' }}
aria-hidden="true"
/>
<span className="absolute bottom-0 inset-x-0 text-3xs py-0.5 bg-black/45 text-white">
{p.label}
</span>
</button>
))}
</div>
)}
{kind === 'image' && (
<div className="space-y-2">
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={e => {
void pick(e.target.files?.[0]);
// 清空 value:否则连续选同一张图不会再触发 change
e.target.value = '';
}}
/>
<button
onClick={() => fileRef.current?.click()}
disabled={busy}
className="w-full px-3 py-2.5 rounded-control border border-gray-300 bg-white text-xs text-gray-700 hover:bg-gray-50 inline-flex items-center justify-center gap-2 disabled:opacity-60"
>
<UploadIcon className="w-3.5 h-3.5" />
{busy ? '处理中…' : imageDataUrl ? '更换图片' : '选择图片'}
</button>
{imageDataUrl && (
<div className="h-20 rounded-control border border-gray-300 overflow-hidden">
<img src={imageDataUrl} alt="背景预览" className="w-full h-full object-cover" />
</div>
)}
<p className="text-2xs text-gray-500">
图片会等比缩放到最长边 2560px 后保存在账号里(上限约 2.4MB),本地同时留一份缓存。
</p>
{error && (
<p role="alert" className="text-2xs text-red-600">
{error}
</p>
)}
</div>
)}
{/* 只有真的启用了背景才显示这两条 —— 背景为「无」时它们没有任何作用,
摆在界面上只会让人疑惑「调了为什么没变化」。 */}
{active && (
<div className="mt-3 space-y-3">
<Slider
label="压暗"
hint="背景越花,正文越需要一层遮罩才读得动"
value={dim}
min={0}
max={80}
suffix="%"
onChange={setDim}
/>
<Slider
label="模糊"
hint="虚化细节,避免背景与正文抢注意力"
value={blur}
min={0}
max={24}
suffix="px"
onChange={setBlur}
/>
{/* 配额保护的下限提示:告诉用户上限是怎么来的,而不是神秘失败 */}
<p className="text-2xs text-gray-500">
当前背景数据约 {Math.round((imageDataUrl.length || 1) / 1024)}KB,上限{' '}
{Math.round(MAX_DATA_URL_BYTES / 1024)}KB。
</p>
</div>
)}
<BuildStamp />
</div>
);
}
/** 带数值显示的滑杆。input[type=range] 的原生外观各平台差异很大,这里统一掉。 */
function Slider({
label,
hint,
value,
min,
max,
suffix,
onChange
}: {
label: string;
hint: string;
value: number;
min: number;
max: number;
suffix: string;
onChange: (v: number) => void;
}) {
return (
<label className="block">
<span className="flex items-center justify-between text-xs text-gray-700">
<span className="font-medium">{label}</span>
<span className="text-gray-500 tabular-nums">
{value}
{suffix}
</span>
</span>
<input
type="range"
min={min}
max={max}
value={value}
onChange={e => onChange(Number(e.target.value))}
className="w-full mt-1 accent-blue-600"
aria-label={label}
/>
<span className="block text-2xs text-gray-500">{hint}</span>
</label>
);
}
/** 「恢复默认」用到,导出以便测试断言默认值形状。 */
export { DEFAULT_BACKGROUND };
/**
* 界面构建戳。
*
* 用户连续三轮说"webui 还没改",而每次都能证明部署是活的 —— 问题在于浏览器跑的
* 是哪份 bundle,隔着屏幕说不清。这行字把这件事变成可核对的:它与部署产物同源
* (vite define 注入,见 vite.config.ts),刷新后数字变了就是拿到了新构建。
*/
function BuildStamp() {
return (
<p className="text-2xs text-gray-400 mt-4 pt-3 border-t border-gray-200" title="界面构建(git 短哈希·月日-时分)">
界面构建 {typeof __BUILD_STAMP__ === 'string' ? __BUILD_STAMP__ : 'unknown'}
</p>
);
}
/**
* 同步状态徽标。
*
* 存在的理由:外观已经搬到服务端(账号级),但**服务端不可达/未登录时只能留在本机**。
* 那种情况下必须让用户看见 —— 静默降级会让人以为"已经同步了",然后在另一台机器上
* 发现没有,而这正是 2026-09-13 报的那个缺陷("为什么背景是保存在本地而不是服务器")。
*/
function SyncBadge() {
const status = useAppearanceSync(s => s.status);
if (status === 'idle') return null;
const map: Record<string, { text: string; cls: string; title: string }> = {
synced: {
text: '已同步',
cls: 'text-green-700 bg-green-50',
title: '已保存到你的账号,换设备/换浏览器也在'
},
pending: {
text: '待同步',
cls: 'text-amber-700 bg-amber-50',
title: '改动只在本机,服务端暂时没存上(网络或权限问题),稍后会自动重试'
},
'local-only': {
text: '仅本机',
cls: 'text-gray-600 bg-gray-100',
title: '未登录或服务端不可达,这份外观只保存在这台设备上'
}
};
const it = map[status];
if (!it) return null;
return (
<span
className={`ml-auto text-2xs px-1.5 py-0.5 rounded-full ${it.cls}`}
title={it.title}
>
{it.text}
</span>
);
}