用户连续三轮说「webui 还没改」,而我每次都能证明部署是活的(入口 no-cache、 资源哈希 immutable、线上 bundle 与本地构建逐字节一致、无头浏览器实测生效)—— 问题在于**隔着屏幕说不清对方的浏览器跑的是哪一份**。 现在界面里显示一行 `界面构建 <git短哈希>·<月日-时分>`(外观设置面板页脚): - `vite.config.ts` 在构建时注入 `__BUILD_STAMP__`(git 短哈希 + 构建时刻) - `BackgroundPicker` 渲染它,并带 title 说明格式 - 判据 4 条(vite 注入 / 组件渲染 / **产物里真的有** / 判据自检:正则不能匹配随便一段文本) 实测:产物与线上都是 `d2904fc·0914-0910`;刷新后数字变了就是拿到了新构建。 顺带修正一处过时文案:图片说明还写着"保存在本机",而它现在同时存到账号里 (服务端 + 本地缓存)。 前端 254 条 + 新增 4 条、打包一致性、server 10 包全绿;桌面包已重打。
301 lines
10 KiB
TypeScript
301 lines
10 KiB
TypeScript
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>
|
||
);
|
||
}
|