import { APP_VERSION } from '@/types'; import { createContext, useContext, useId, useReducer, useRef, useState, type ComponentProps, type ReactNode, } from 'react'; import { motion } from 'motion/react'; import { Accessibility, AlertTriangle, Bell, Bug, Check, Clock, Code2, Download, ExternalLink, Github, Image as ImageIcon, Info, KeyRound, Loader2, Monitor, Moon, MousePointer2, Palette, PanelTop, Plus, RefreshCw, RotateCcw, Server, ShieldCheck, SlidersHorizontal, Sparkles, Star, Sun, Tag, Upload, Trash2, HardDrive, } from 'lucide-react'; import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { ToggleSwitch } from '@/components/ui/toggle-switch'; import { Slider } from '@/components/ui/slider'; import { Textarea } from '@/components/ui/textarea'; import { ACCENTS, DEFAULT_APPEARANCE, FONT_CUSTOM_ID, FONT_MONO_OPTIONS, FONT_SANS_OPTIONS, GRADIENT_OPTIONS, PALETTE_OPTIONS, paletteResolved, POLL_INTERVAL_OPTIONS, RADIUS_OPTIONS, UI_SCALE, useTheme, type AccentScope, type BackgroundType, type DarkIntensity, type Density, type FontSpec, type SidebarStyle, type ThemeMode, type TimeFormat, } from '@/contexts/ThemeContext'; import { DEFAULT_LAYOUT, DEFAULT_PAGES, reconcileLayoutItems, useLayout, } from '@/contexts/LayoutContext'; import { useActionFeedback } from '@/contexts/ActionFeedbackContext'; import { useSession } from '@/contexts/SessionContext'; import { useDesktopSidebar } from '@/hooks/use-media-query'; import { NAV_ITEMS } from '@/components/layout/sidebar'; import { TOPBAR_CATALOGUE } from '@/components/layout/top-bar'; import { TotpPanel } from '@/components/settings/totp-panel'; import { ChangePasswordDialog } from '@/components/change-password-dialog'; import { ConfirmDialog } from '@/components/confirm-dialog'; import { SkeletonSwap } from '@/components/interior/skeleton-swap'; import { SegmentedControl } from '@/components/interior/segmented-control'; import { useApi } from '@/lib/api'; import { useAppState } from '@/contexts/AppStateContext'; import { cn, downloadBlob, toColorInputValue } from '@/lib/utils'; import { settingsRoute, type SettingsTab } from '@/router'; import { NotificationsPanel } from '@/components/settings/notifications-panel'; import { GlobalConfigPanel } from '@/components/settings/global-config-panel'; import { SystemPanel } from '@/components/settings/system-panel'; import { UpdateInstaller } from '@/components/settings/update-installer'; import { UpdateSources } from '@/components/settings/update-sources'; import { StoragePanel } from '@/components/settings/storage-panel'; import { DeveloperCrashProbe, developerCrashReducer } from '@/components/settings/developer-tools'; interface TabDef { key: SettingsTab; label: string; icon: typeof Sun; } // Three domains, rendered as macOS System-Settings-style sidebar sections: // the dashboard itself (控制台), the all-accounts SnowLuma protocol runtime, // and the host/deployment (平台). Section headers show on the desktop rail // only; the mobile strip stays a flat wrap. const TAB_GROUPS: { title: string; tabs: TabDef[] }[] = [ { title: '控制台', tabs: [ { key: 'appearance', label: '外观', icon: Palette }, { key: 'data', label: '数据与格式', icon: RefreshCw }, { key: 'account', label: '账号安全', icon: ShieldCheck }, ], }, { title: 'SnowLuma', tabs: [ { key: 'globalConfig', label: '全局配置', icon: SlidersHorizontal }, { key: 'storage', label: '存储管理', icon: HardDrive }, { key: 'notifications', label: '通知', icon: Bell }, ], }, { title: '平台', tabs: [ { key: 'system', label: '服务', icon: Server }, { key: 'advanced', label: '高级', icon: Code2 }, { key: 'developer', label: '开发者', icon: Bug }, { key: 'about', label: '关于', icon: Info }, ], }, ]; const TABS: TabDef[] = TAB_GROUPS.flatMap((g) => g.tabs); export function SettingsPage() { // Active tab lives in the URL (`?tab=`) so it's deep-linkable — e.g. the // sidebar update banner jumps straight to 关于. Default tab omits the param. const { tab: urlTab } = settingsRoute.useSearch(); const navigate = settingsRoute.useNavigate(); const tab: SettingsTab = urlTab ?? 'appearance'; const setTab = (t: SettingsTab) => void navigate({ to: '/settings', search: t === 'appearance' ? {} : { tab: t }, replace: true, }); const active = TABS.find((t) => t.key === tab); return (

{active?.label}

{tab === 'appearance' && } {tab === 'data' && } {tab === 'advanced' && } {tab === 'account' && } {tab === 'system' && } {tab === 'storage' && } {tab === 'notifications' && } {tab === 'globalConfig' && } {tab === 'developer' && } {tab === 'about' && }
); } // ─────────────── nav: sticky rail (desktop) / scroll strip (mobile) ─────────────── function SettingsNav({ tab, onChange }: { tab: SettingsTab; onChange: (t: SettingsTab) => void }) { return ( ); } // ─────────────── shared little controls ─────────────── interface Opt { value: T; label: string; icon?: typeof Sun; } // Associates a SettingRow's visible label with the control inside it (so the // segmented radiogroup gets an accessible name without prop-threading). const RowLabelContext = createContext<{ id: string; label: string } | undefined>(undefined); // iOS/macOS segmented control: a recessed track with a raised pill on the // selected segment. Single-select → exposed as a radiogroup. Wraps gracefully. function Segmented({ value, options, onChange, disabled, }: { value: T; options: Opt[]; onChange: (v: T) => void; disabled?: boolean; }) { const rowLabel = useContext(RowLabelContext); return (
({ value: String(option.value), label: option.label, disabled, }))} onValueChange={(next) => { const option = options.find((candidate) => String(candidate.value) === next); if (option) { onChange(option.value); } }} />
); } /** * A grouped-list row. `layout`: * - 'auto' — control sits right of the label on sm+, stacks under it on mobile (short controls) * - 'inline' — control always pinned right (switches) * - 'stack' — control always full-width under the label (color grids, sliders, wide segments) */ function SettingRow({ label, hint, children, layout = 'auto', }: { label: string; hint?: ReactNode; children: ReactNode; layout?: 'auto' | 'inline' | 'stack'; }) { const labelId = useId(); const head = (

{label}

{hint && (

{hint}

)}
); const control = ( {children} ); if (layout === 'stack') { return (
{head} {control}
); } if (layout === 'inline') { return (
{head}
{control}
); } return (
{head}
{control}
); } function SliderSettingRow({ label, hint, ...sliderProps }: { label: string; hint?: ReactNode; } & Omit, 'label'>) { const hintId = useId(); return (
{hint && (

{hint}

)}
); } // A settings group: a card whose header sits above a hairline-divided list of rows. function Group({ title, description, icon: Icon, children, }: { title: string; description?: string; icon?: typeof Sun; children: ReactNode; }) { return ( {Icon && } {title} {description && ( {description} )}
{children}
); } // ─────────────── 外观 ─────────────── const MODE_OPTIONS: Opt[] = [ { value: 'light', label: '浅色', icon: Sun }, { value: 'dark', label: '深色', icon: Moon }, { value: 'system', label: '跟随系统', icon: Monitor }, ]; const DARK_INTENSITY_OPTIONS: Opt[] = [ { value: 'soft', label: '柔和' }, { value: 'black', label: '纯黑 (OLED)' }, ]; const SCOPE_OPTIONS: Opt[] = [ { value: 'global', label: '全局' }, { value: 'sidebar', label: '仅侧栏' }, ]; const SIDEBAR_STYLE_OPTIONS: Opt[] = [ { value: 'follow', label: '跟随背景' }, { value: 'panel', label: '浅色面板' }, { value: 'accent', label: '强调色' }, ]; const BG_TYPE_OPTIONS: Opt[] = [ { value: 'none', label: '无' }, { value: 'solid', label: '纯色' }, { value: 'gradient', label: '渐变' }, { value: 'image', label: '图片' }, ]; const DENSITY_OPTIONS: Opt[] = [ { value: 'cozy', label: '舒适' }, { value: 'compact', label: '紧凑' }, ]; // ── a11y: one-time low-contrast advisory for a custom accent ── // Pure-advisory per the plan: never blocks, never a persistent banner, and // fires at most once per session (sessionStorage) so it can't nag. const CONTRAST_WARNED_KEY = 'snowluma_contrast_warned'; function hexRgb(hex: string): { r: number; g: number; b: number } | null { const m = hex.trim().replace('#', ''); const h = m.length === 3 ? m .split('') .map((c) => c + c) .join('') : m; if (h.length < 6) { return null; } const r = parseInt(h.slice(0, 2), 16), g = parseInt(h.slice(2, 4), 16), b = parseInt(h.slice(4, 6), 16); return Number.isNaN(r + g + b) ? null : { r, g, b }; } function relLuminance({ r, g, b }: { r: number; g: number; b: number }): number { const f = (c: number) => { const s = c / 255; return s <= 0.03928 ? s / 12.92 : ((s + 0.055) / 1.055) ** 2.4; }; return 0.2126 * f(r) + 0.7152 * f(g) + 0.0722 * f(b); } /** WCAG contrast ratio between two hex colours (1…21), or null if unparseable. */ function contrastRatio(a: string, b: string): number | null { const ra = hexRgb(a), rb = hexRgb(b); if (!ra || !rb) { return null; } const la = relLuminance(ra), lb = relLuminance(rb); const [hi, lo] = la > lb ? [la, lb] : [lb, la]; return (hi + 0.05) / (lo + 0.05); } // Top-bar element show/hide. Essential controls (menu, page title, logout) are // not listed — they always render. function TopbarPanel() { const { topbarItems, setTopbarItems } = useLayout(); const reconciled = reconcileLayoutItems( topbarItems, TOPBAR_CATALOGUE.map((t) => t.id), ); const shown = new Map(reconciled.map((i) => [i.id, i.visible])); const toggle = (id: string) => setTopbarItems(reconciled.map((i) => (i.id === id ? { ...i, visible: !i.visible } : i))); return ( {TOPBAR_CATALOGUE.map((t) => ( toggle(t.id)} ariaLabel={t.label} /> ))} ); } function AppearancePanel() { const isDesktop = useDesktopSidebar(); const { savedAppearance: appearance, setAppearance, uploadBackground, removeBackground, resolved, managedBy, } = useTheme(); const a = appearance; // A Catppuccin flavor fixes its own light/dark + darkness, so the mode and // intensity controls are inert while one is active. const paletteFixed = paletteResolved(a.palette) !== null; // Low-contrast accent advisory (applies the change immediately, then — once // per session — offers to revert if the accent reads poorly on the canvas). const [contrastWarn, setContrastWarn] = useState<{ ratio: number } | null>(null); const pickCustomAccent = (hex: string) => { setAppearance({ accentMode: 'custom', accentCustom: hex }); let warned = false; try { warned = sessionStorage.getItem(CONTRAST_WARNED_KEY) === '1'; } catch { /* ignore */ } if (warned) { return; } // Accent doubles as link/icon colour on the page background — flag UI-level // contrast below WCAG AA for large text / non-text (3:1). const bg = resolved === 'dark' ? '#0a0a0a' : '#ffffff'; const ratio = contrastRatio(hex, bg); if (ratio !== null && ratio < 3) { try { sessionStorage.setItem(CONTRAST_WARNED_KEY, '1'); } catch { /* ignore */ } setContrastWarn({ ratio }); } }; return (
{managedBy && (

外观跟随{managedBy} 。原有配色、字体和背景已保留,退出集成后恢复;缩放与无障碍设置仍可调整。

)}
{PALETTE_OPTIONS.map((p) => { const active = a.palette === p.id; return ( ); })}
setAppearance({ mode })} disabled={paletteFixed} /> setAppearance({ darkIntensity })} disabled={paletteFixed} />
{ACCENTS.map((spec) => { const active = a.accentMode === 'preset' && a.accentPreset === spec.id; return ( ); })}
setAppearance({ accentScope })} /> setAppearance({ sidebarStyle })} />
setAppearance({ fontSans })} onCustomChange={(fontSansCustom) => setAppearance({ fontSansCustom })} placeholder="例如:LXGW WenKai, PingFang SC" /> setAppearance({ fontMono })} onCustomChange={(fontMonoCustom) => setAppearance({ fontMonoCustom })} placeholder="例如:Cascadia Code, Sarasa Mono SC" /> ({ value: r.value, label: r.label, }))} onChange={(radius) => setAppearance({ radius })} />
`${Math.round(nextValue * 100)}%`} onValueChange={(uiScale) => setAppearance({ uiScale })} aria-valuetext={`${Math.round(a.uiScale * 100)}%`} /> setAppearance({ density })} />
setAppearance({ cssVars })} />
setAppearance({ customContextMenu })} ariaLabel="自定义右键菜单" /> setAppearance({ customPointerSystem })} ariaLabel="自定义光标" /> setAppearance({ reduceMotion })} ariaLabel="减弱动效" /> setAppearance({ disableMotion })} ariaLabel="关闭全部动效" /> setAppearance({ highContrast })} ariaLabel="高对比模式" /> {isDesktop && ( setAppearance({ sidebarPinned })} ariaLabel="钉住侧栏展开" /> )} { if (!o) { setContrastWarn(null); } }} title="强调色对比度较低" description={`所选强调色与背景的对比度约为 ${contrastWarn?.ratio.toFixed(1)}:1,低于推荐的 3:1,用作链接或图标时可能不易辨认。可以保留,或恢复默认强调色。`} confirmText="恢复默认色" cancelText="仍然使用" onConfirm={() => setAppearance({ accentMode: 'preset', accentPreset: DEFAULT_APPEARANCE.accentPreset, accentCustom: DEFAULT_APPEARANCE.accentCustom, }) } />
); } function BackgroundCard({ appearance: a, setAppearance, uploadBackground, removeBackground, }: { appearance: ReturnType['appearance']; setAppearance: ReturnType['setAppearance']; uploadBackground: ReturnType['uploadBackground']; removeBackground: ReturnType['removeBackground']; }) { const fileRef = useRef(null); const [uploading, setUploading] = useState(false); const [busy, setBusy] = useState(false); const [err, setErr] = useState(null); const onFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (fileRef.current) { fileRef.current.value = ''; } if (!file) { return; } setUploading(true); setErr(null); try { await uploadBackground(file); } catch (ex) { setErr(ex instanceof Error ? ex.message : '上传失败'); } finally { setUploading(false); } }; const onRemove = async () => { setBusy(true); setErr(null); try { await removeBackground(); } catch { setErr('删除失败'); } finally { setBusy(false); } }; return ( setAppearance({ background: { type } })} /> {a.background.type === 'solid' && (
{a.background.color}
)} {a.background.type === 'gradient' && (
{GRADIENT_OPTIONS.map((g) => { const active = a.background.gradient === g.id; return (
)} {a.background.type === 'image' && ( <>
{a.background.hasImage && ( )}
{a.background.hasImage && (
背景预览
)}
{a.background.hasImage && ( <> `${Math.round(nextValue * 100)}%`} onValueChange={(imageOpacity) => setAppearance({ background: { imageOpacity } }) } aria-valuetext={`${Math.round(a.background.imageOpacity * 100)}%`} /> `${nextValue}px`} onValueChange={(imageBlur) => setAppearance({ background: { imageBlur } }) } aria-valuetext={`${a.background.imageBlur} 像素`} /> )} )} {err &&

{err}

}
); } // A font selector: preset segments + a "自定义" segment that reveals a // free-form family input. On Chromium (where `queryLocalFonts` exists) the // input gets a native populated with the user's installed fonts; // everywhere else it stays a plain text box (feature-detect, silent fallback). type LocalFont = { family: string }; type FontQueryWindow = Window & { queryLocalFonts?: () => Promise }; function FontField({ value, custom, presets, onChange, onCustomChange, placeholder, }: { value: string; custom: string; presets: FontSpec[]; onChange: (id: string) => void; onCustomChange: (family: string) => void; placeholder: string; }) { const listId = useId(); const [fonts, setFonts] = useState(null); const [loading, setLoading] = useState(false); const [fontLoadError, setFontLoadError] = useState(null); const canQuery = typeof (window as FontQueryWindow).queryLocalFonts === 'function'; const isCustom = value === FONT_CUSTOM_ID; const options: Opt[] = [ ...presets.map((f) => ({ value: f.id, label: f.label })), { value: FONT_CUSTOM_ID, label: '自定义' }, ]; // Lazily enumerate local fonts the first time the field is focused. const loadFonts = async () => { if (fonts !== null || loading || !canQuery) { return; } setLoading(true); setFontLoadError(null); try { const arr = await (window as FontQueryWindow).queryLocalFonts!(); setFonts( Array.from(new Set(arr.map((f) => f.family))).sort((x, y) => x.localeCompare(y)), ); } catch (error) { console.error('Failed to enumerate local fonts', error); setFontLoadError('无法读取本机字体,请检查浏览器权限后重试。'); } finally { setLoading(false); } }; return (
{isCustom && (
onCustomChange(e.target.value)} onFocus={loadFonts} placeholder={placeholder} spellCheck={false} autoComplete="off" style={custom ? { fontFamily: custom } : undefined} data-ui-control="" className="w-full rounded-lg border bg-card/40 px-3 py-2 text-base outline-none focus:border-primary md:text-sm" /> {canQuery && fonts && fonts.length > 0 && ( {fonts.map((f) => ( )} {fontLoadError ? (
{fontLoadError}
) : (

{canQuery ? '可输入字体名称,或聚焦后从已安装字体中选择;找不到时回退到系统字体。' : '输入已安装的字体名称(可写多个,用逗号分隔);找不到时回退到系统字体。'}

)}
)}
); } // Curated subset of the cssVars whitelist surfaced as colour pickers. The // server accepts the full token set; this is just the operator-friendly view. const CSS_VAR_FIELDS: { token: string; label: string }[] = [ { token: '--background', label: '页面背景' }, { token: '--foreground', label: '主文字' }, { token: '--card', label: '卡片背景' }, { token: '--primary', label: '主色调' }, { token: '--muted-foreground', label: '次要文字' }, { token: '--border', label: '边框' }, { token: '--sidebar', label: '侧栏背景' }, { token: '--destructive', label: '危险 / 错误' }, { token: '--success', label: '成功' }, { token: '--warning', label: '警告' }, ]; // The variable panel: per-token colour overrides layered over the active // palette/accent. Each override is a single hex value (validated server-side); // an unset token falls through to the theme default. function ThemeVarsPanel({ cssVars, onChange, }: { cssVars: Record; onChange: (next: Record) => void; }) { const set = (token: string, hex: string) => onChange({ ...cssVars, [token]: hex }); const clear = (token: string) => { const next = { ...cssVars }; delete next[token]; onChange(next); }; const count = CSS_VAR_FIELDS.filter((f) => cssVars[f.token]).length; return ( {CSS_VAR_FIELDS.map((f) => { const override = cssVars[f.token]; return ( {f.token}} layout="inline" >
{override ? ( {override} ) : ( 默认 )}
); })} {count > 0 && (
已覆盖 {count} 个变量
)}
); } // ─────────────── 数据与格式 ─────────────── const TIME_FORMAT_OPTIONS: Opt[] = [ { value: '24h', label: '24 小时制' }, { value: '12h', label: '12 小时制 (AM/PM)' }, ]; function DataPanel() { const { appearance, setAppearance } = useTheme(); const { pages, setPages } = useLayout(); return (
({ value: o.value, label: o.label, }))} onChange={(pollInterval) => setAppearance({ pollInterval })} /> ({ value: n.to as string, label: n.label }))} onChange={(defaultRoute) => setPages({ defaultRoute })} /> setAppearance({ timeFormat })} />
); } // ─────────────── 高级(自定义 CSS + 备份/重置) ─────────────── // Ready-made CSS snippets, inserted at the cursor / appended. Plain string // constants (no editor library) — the operator tweaks them in the textarea and // native Cmd/Ctrl+Z undoes. Each is a complete, valid rule. const CSS_SNIPPETS: { label: string; css: string }[] = [ { label: '侧栏字号', css: '.text-sidebar-foreground {\n font-size: 1.05em;\n}' }, { label: '卡片毛玻璃', css: '.bg-card {\n -webkit-backdrop-filter: blur(8px);\n backdrop-filter: blur(8px);\n background-color: color-mix(in oklab, var(--card) 80%, transparent);\n}', }, { label: '隐藏滚动条', css: '* {\n scrollbar-width: none;\n}\n*::-webkit-scrollbar {\n width: 0;\n height: 0;\n}', }, { label: '加粗标题', css: 'h1, h2, h3 {\n font-weight: 700;\n letter-spacing: -0.01em;\n}' }, { label: '紧凑卡片', css: '.rounded-xl {\n border-radius: 0.5rem;\n}' }, ]; // On-blur sanity check: parse the CSS once and surface a gentle warning if it // looks broken (unbalanced braces, or non-empty input that parses to zero // rules). Best-effort — returns null when parsing isn't available or the CSS // looks fine. Never blocks saving; the operator can always ?safe-mode=1. function validateCss(css: string): string | null { const trimmed = css.trim(); if (!trimmed) { return null; } const opens = (trimmed.match(/\{/g) ?? []).length; const closes = (trimmed.match(/\}/g) ?? []).length; if (opens !== closes) { return `括号不匹配(${opens} 个 “{” / ${closes} 个 “}”)— 可能有规则未闭合。`; } if (opens === 0) { return '没有检测到任何 CSS 规则(缺少 “{ … }”)。'; } try { if (typeof CSSStyleSheet === 'function' && 'replaceSync' in CSSStyleSheet.prototype) { const sheet = new CSSStyleSheet(); sheet.replaceSync(trimmed); if (sheet.cssRules.length === 0) { return '未解析出有效规则,请检查语法。'; } } } catch { return 'CSS 解析失败,请检查语法。'; } return null; } function AdvancedPanel() { const { savedAppearance: appearance, setAppearance, managedBy } = useTheme(); const { runAction } = useActionFeedback(); const cssRef = useRef(null); const [cssWarn, setCssWarn] = useState(null); const api = useApi(); const fileRef = useRef(null); const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null); const [confirmReset, setConfirmReset] = useState(false); const [pendingImport, setPendingImport] = useState(null); const [busy, setBusy] = useState(false); const onExport = async () => { setMsg(null); try { const config = await runAction( { title: '正在导出界面配置', detail: '外观、布局与自定义 CSS', successTitle: '界面配置已导出', errorTitle: '界面配置导出失败', }, () => api.ui.get(), ); downloadBlob( new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' }), 'snowluma-ui-config.json', ); } catch (error) { console.error('export UI config failed', error); setMsg({ kind: 'err', text: '导出失败' }); } }; const onImportFile = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (fileRef.current) { fileRef.current.value = ''; } if (!file) { return; } if (file.size > 256 * 1024) { setMsg({ kind: 'err', text: '导入失败:文件过大(上限 256KB)' }); return; } setPendingImport(file); }; const importUiConfig = async (file: File) => { setMsg(null); setBusy(true); try { const parsed = JSON.parse(await file.text()) as unknown; if (typeof parsed !== 'object' || parsed === null) { throw new Error('shape'); } await runAction( { title: '正在导入界面配置', detail: file.name, successTitle: '界面配置已导入', successDetail: '即将重新载入界面', errorTitle: '界面配置导入失败', surviveReload: true, }, () => api.ui.save(parsed as Parameters[0]), ); // The server normalized it; reload so appearance + layout both re-read. window.location.reload(); } catch (error) { setBusy(false); setMsg({ kind: 'err', text: error instanceof SyntaxError || (error instanceof Error && error.message === 'shape') ? '导入失败:不是有效的配置 JSON' : `导入失败:${error instanceof Error ? error.message : '未知错误'}`, }); throw error; } }; const doReset = async () => { setBusy(true); try { await runAction( { title: '正在重置界面配置', detail: '恢复外观、布局、导航与自定义 CSS', successTitle: '界面配置已重置', successDetail: '即将重新载入界面', errorTitle: '界面配置重置失败', surviveReload: true, }, () => api.ui.save({ appearance: DEFAULT_APPEARANCE, layout: DEFAULT_LAYOUT, pages: DEFAULT_PAGES, }), ); window.location.reload(); } catch (error) { setBusy(false); setMsg({ kind: 'err', text: '重置失败' }); throw error; } }; // Insert a template at the cursor (or append), then re-validate. const insertSnippet = (snippet: string) => { const ta = cssRef.current; const cur = appearance.customCss; let next: string; if (ta && ta.selectionStart != null) { const before = cur.slice(0, ta.selectionStart); const after = cur.slice(ta.selectionEnd ?? ta.selectionStart); const sep = before && !before.endsWith('\n') ? '\n' : ''; next = `${before}${sep}${snippet}\n${after}`; } else { next = cur ? `${cur}\n${snippet}\n` : `${snippet}\n`; } setAppearance({ customCss: next.slice(0, 50000) }); setCssWarn(validateCss(next)); }; return (
{managedBy && (

自定义 CSS 已保留,跟随{managedBy}外观期间暂停应用。

)}
自定义 CSS 高级用户可注入自定义样式,登录后全局生效(登录页不受影响)。 若改坏了界面,在地址后加{' '} ?safe-mode=1 {' '} 可临时禁用自定义 CSS 进来修复。
插入模板: {CSS_SNIPPETS.map((s) => ( ))}