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(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 (

背景

{active && ( )}

背景会显示在卡片后面,两种主题各有对应色调。

{/* 三选一:不设 / 预设 / 自定义图片。与主题选择器同一套控件形态, 让「外观」这一组看起来是一件事。 */}
{( [ { value: 'none', label: '无' }, { value: 'preset', label: '预设' }, { value: 'image', label: '图片' } ] as { value: BackgroundKind; label: string }[] ).map(o => ( ))}
{kind === 'preset' && (
{PRESETS.map(p => ( ))}
)} {kind === 'image' && (
{ void pick(e.target.files?.[0]); // 清空 value:否则连续选同一张图不会再触发 change e.target.value = ''; }} /> {imageDataUrl && (
背景预览
)}

图片会等比缩放到最长边 2560px 后保存在账号里(上限约 2.4MB),本地同时留一份缓存。

{error && (

{error}

)}
)} {/* 只有真的启用了背景才显示这两条 —— 背景为「无」时它们没有任何作用, 摆在界面上只会让人疑惑「调了为什么没变化」。 */} {active && (
{/* 配额保护的下限提示:告诉用户上限是怎么来的,而不是神秘失败 */}

当前背景数据约 {Math.round((imageDataUrl.length || 1) / 1024)}KB,上限{' '} {Math.round(MAX_DATA_URL_BYTES / 1024)}KB。

)}
); } /** 带数值显示的滑杆。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 ( ); } /** 「恢复默认」用到,导出以便测试断言默认值形状。 */ export { DEFAULT_BACKGROUND }; /** * 界面构建戳。 * * 用户连续三轮说"webui 还没改",而每次都能证明部署是活的 —— 问题在于浏览器跑的 * 是哪份 bundle,隔着屏幕说不清。这行字把这件事变成可核对的:它与部署产物同源 * (vite define 注入,见 vite.config.ts),刷新后数字变了就是拿到了新构建。 */ function BuildStamp() { return (

界面构建 {typeof __BUILD_STAMP__ === 'string' ? __BUILD_STAMP__ : 'unknown'}

); } /** * 同步状态徽标。 * * 存在的理由:外观已经搬到服务端(账号级),但**服务端不可达/未登录时只能留在本机**。 * 那种情况下必须让用户看见 —— 静默降级会让人以为"已经同步了",然后在另一台机器上 * 发现没有,而这正是 2026-09-13 报的那个缺陷("为什么背景是保存在本地而不是服务器")。 */ function SyncBadge() { const status = useAppearanceSync(s => s.status); if (status === 'idle') return null; const map: Record = { 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 ( {it.text} ); }