Files
SnowLuma/packages/webui/src/components/pages/settings-page.tsx
T
TanTan fa9c73363d
Dev Build / cleanup (push) Waiting to run
Dev Build / typecheck (push) Waiting to run
Publish MCP to npm / Build, test, and publish MCP (push) Waiting to run
Publish SDK to npm / Build, test, and publish SDK (push) Waiting to run
Sync action catalog to docs / sync (push) Waiting to run
Publish UI to npm / Build and publish UI (push) Waiting to run
Build and Release / preflight (push) Waiting to run
SnowLuma v2.4.2 source snapshot
2026-11-13 09:02:00 +08:00

2162 lines
88 KiB
TypeScript

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 (
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:gap-6">
<SettingsNav tab={tab} onChange={setTab} />
<div className="min-w-0 flex-1">
<motion.div
key={tab}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.2, ease: [0.16, 1, 0.3, 1] }}
className="flex flex-col gap-5"
>
<h2 className="px-0.5 text-base font-semibold tracking-tight">
{active?.label}
</h2>
{tab === 'appearance' && <AppearancePanel />}
{tab === 'data' && <DataPanel />}
{tab === 'advanced' && <AdvancedPanel />}
{tab === 'account' && <AccountPanel />}
{tab === 'system' && <SystemPanel />}
{tab === 'storage' && <StoragePanel />}
{tab === 'notifications' && <NotificationsPanel />}
{tab === 'globalConfig' && <GlobalConfigPanel />}
{tab === 'developer' && <DeveloperPanel />}
{tab === 'about' && <AboutPanel />}
</motion.div>
</div>
</div>
);
}
// ─────────────── nav: sticky rail (desktop) / scroll strip (mobile) ───────────────
function SettingsNav({ tab, onChange }: { tab: SettingsTab; onChange: (t: SettingsTab) => void }) {
return (
<nav
data-ui-surface="panel"
aria-label="设置导航"
className={cn(
'flex flex-wrap gap-1 rounded-xl border bg-card p-1.5',
'lg:w-52 lg:shrink-0 lg:flex-col lg:flex-nowrap lg:self-start lg:sticky lg:top-4 lg:max-h-[calc(100dvh-6rem)] lg:overflow-y-auto lg:overscroll-y-contain',
)}
>
{TAB_GROUPS.map((group, gi) => (
<div
key={group.title}
role="group"
aria-labelledby={`settings-nav-group-${gi}`}
className="contents lg:flex lg:flex-col lg:gap-1"
>
<p
id={`settings-nav-group-${gi}`}
className={cn(
'hidden select-none px-3 pb-0.5 text-xs font-semibold uppercase tracking-wider text-muted-foreground/60 lg:block',
gi === 0 ? 'lg:pt-1' : 'lg:pt-3',
)}
>
{group.title}
</p>
{group.tabs.map((t) => {
const Icon = t.icon;
const active = tab === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => onChange(t.key)}
aria-current={active ? 'page' : undefined}
className={cn(
'inline-flex shrink-0 items-center gap-2.5 whitespace-nowrap rounded-lg px-3 py-2 text-sm font-medium transition-colors cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 lg:w-full',
active
? 'bg-accent text-foreground ring-1 ring-primary/20'
: 'text-muted-foreground hover:bg-accent/50 hover:text-foreground',
)}
>
<Icon className={cn('size-4 shrink-0', active && 'text-primary')} />
{t.label}
</button>
);
})}
</div>
))}
</nav>
);
}
// ─────────────── shared little controls ───────────────
interface Opt<T> {
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<T extends string | number>({
value,
options,
onChange,
disabled,
}: {
value: T;
options: Opt<T>[];
onChange: (v: T) => void;
disabled?: boolean;
}) {
const rowLabel = useContext(RowLabelContext);
return (
<div
aria-labelledby={rowLabel?.id}
aria-disabled={disabled || undefined}
className={cn('max-w-full overflow-x-auto', disabled && 'opacity-50')}
>
<SegmentedControl
label={rowLabel?.label ?? '选项'}
value={String(value)}
options={options.map((option) => ({
value: String(option.value),
label: option.label,
disabled,
}))}
onValueChange={(next) => {
const option = options.find((candidate) => String(candidate.value) === next);
if (option) {
onChange(option.value);
}
}}
/>
</div>
);
}
/**
* 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 = (
<div className="min-w-0">
<p id={labelId} className="text-sm font-medium">
{label}
</p>
{hint && (
<p className="mt-0.5 text-[12px] leading-snug text-muted-foreground">{hint}</p>
)}
</div>
);
const control = (
<RowLabelContext.Provider value={{ id: labelId, label }}>
{children}
</RowLabelContext.Provider>
);
if (layout === 'stack') {
return (
<div className="flex flex-col gap-3 px-5 py-4">
{head}
{control}
</div>
);
}
if (layout === 'inline') {
return (
<div className="flex items-center justify-between gap-4 px-5 py-4">
{head}
<div className="shrink-0">{control}</div>
</div>
);
}
return (
<div className="flex flex-col gap-3 px-5 py-4 sm:flex-row sm:items-center sm:justify-between sm:gap-6">
{head}
<div className="sm:shrink-0">{control}</div>
</div>
);
}
function SliderSettingRow({
label,
hint,
...sliderProps
}: {
label: string;
hint?: ReactNode;
} & Omit<ComponentProps<typeof Slider>, 'label'>) {
const hintId = useId();
return (
<div className="flex flex-col gap-2.5 px-5 py-4">
<Slider {...sliderProps} label={label} aria-describedby={hint ? hintId : undefined} />
{hint && (
<p id={hintId} className="text-[12px] leading-snug text-muted-foreground">
{hint}
</p>
)}
</div>
);
}
// 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 (
<Card className="overflow-hidden">
<CardHeader className="gap-1 pb-4">
<CardTitle className="flex items-center gap-2 text-[15px]">
{Icon && <Icon className="size-4 text-primary" />}
{title}
</CardTitle>
{description && (
<CardDescription className="text-[12px] leading-relaxed">
{description}
</CardDescription>
)}
</CardHeader>
<div className="divide-y divide-border/60 border-t border-border/60">{children}</div>
</Card>
);
}
// ─────────────── 外观 ───────────────
const MODE_OPTIONS: Opt<ThemeMode>[] = [
{ value: 'light', label: '浅色', icon: Sun },
{ value: 'dark', label: '深色', icon: Moon },
{ value: 'system', label: '跟随系统', icon: Monitor },
];
const DARK_INTENSITY_OPTIONS: Opt<DarkIntensity>[] = [
{ value: 'soft', label: '柔和' },
{ value: 'black', label: '纯黑 (OLED)' },
];
const SCOPE_OPTIONS: Opt<AccentScope>[] = [
{ value: 'global', label: '全局' },
{ value: 'sidebar', label: '仅侧栏' },
];
const SIDEBAR_STYLE_OPTIONS: Opt<SidebarStyle>[] = [
{ value: 'follow', label: '跟随背景' },
{ value: 'panel', label: '浅色面板' },
{ value: 'accent', label: '强调色' },
];
const BG_TYPE_OPTIONS: Opt<BackgroundType>[] = [
{ value: 'none', label: '无' },
{ value: 'solid', label: '纯色' },
{ value: 'gradient', label: '渐变' },
{ value: 'image', label: '图片' },
];
const DENSITY_OPTIONS: Opt<Density>[] = [
{ 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 (
<Group
title="顶栏"
icon={PanelTop}
description="控制顶栏右侧元素的显示;菜单、页面标题与登出始终保留。"
>
{TOPBAR_CATALOGUE.map((t) => (
<SettingRow key={t.id} label={t.label} layout="inline">
<ToggleSwitch
value={shown.get(t.id) ?? true}
onChange={() => toggle(t.id)}
ariaLabel={t.label}
/>
</SettingRow>
))}
</Group>
);
}
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 (
<div className="flex flex-col gap-5">
{managedBy && (
<p className="text-sm leading-relaxed text-muted-foreground" role="status">
外观跟随{managedBy}
。原有配色、字体和背景已保留,退出集成后恢复;缩放与无障碍设置仍可调整。
</p>
)}
<fieldset disabled={!!managedBy} className="contents" aria-label="主题、配色与背景">
<Group
title="主题"
icon={Sun}
description={
managedBy
? '下方显示已保留的个人设置;当前界面外观由宿主管理。'
: '配色方案、明暗模式与深色风格。所有改动立即生效并保存到服务器,跨设备同步。'
}
>
<SettingRow
label="配色方案"
hint="“默认”跟随下方明暗模式;Catppuccin 为整套配色,会自行决定明暗。"
layout="stack"
>
<div
className="flex flex-wrap gap-2.5"
role="radiogroup"
aria-label="配色方案"
>
{PALETTE_OPTIONS.map((p) => {
const active = a.palette === p.id;
return (
<button
key={p.id}
type="button"
role="radio"
aria-checked={active}
aria-label={p.label}
onClick={() => setAppearance({ palette: p.id })}
className={cn(
'flex w-[5.5rem] flex-col items-center gap-1.5 rounded-xl border p-1.5 transition-[border-color,box-shadow] duration-150 ease-out cursor-pointer outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40',
active
? 'border-primary ring-2 ring-primary/30'
: 'border-border hover:border-foreground/30',
)}
>
<span
className="flex h-11 w-full items-center justify-center rounded-lg"
style={{ backgroundColor: p.preview.bg }}
>
<span
className="flex gap-1 rounded-md px-1.5 py-1 shadow-sm"
style={{ backgroundColor: p.preview.surface }}
>
{p.preview.dots.map((d, i) => (
<span
key={i}
className="size-1.5 rounded-full"
style={{ backgroundColor: d }}
/>
))}
</span>
</span>
<span className="text-xs font-medium">{p.label}</span>
</button>
);
})}
</div>
</SettingRow>
<SettingRow
label="显示模式"
hint={
managedBy
? '明暗模式跟随宿主。'
: paletteFixed
? '当前由 Catppuccin 配色决定明暗。'
: undefined
}
>
<Segmented
value={a.mode}
options={MODE_OPTIONS}
onChange={(mode) => setAppearance({ mode })}
disabled={paletteFixed}
/>
</SettingRow>
<SettingRow
label="深色强度"
hint={
managedBy
? '深色材质由宿主外观决定。'
: paletteFixed
? 'Catppuccin 配色自带明暗,无需调节。'
: '“纯黑”将深色背景压到接近纯黑,适合 OLED 屏幕(仅深色模式下可见)。'
}
layout="stack"
>
<Segmented
value={a.darkIntensity}
options={DARK_INTENSITY_OPTIONS}
onChange={(darkIntensity) => setAppearance({ darkIntensity })}
disabled={paletteFixed}
/>
</SettingRow>
</Group>
<Group
title="强调色"
icon={Palette}
description="按钮、链接与高亮使用的主色调;默认配色下,界面底色也会轻微跟随。"
>
<SettingRow label="预设色" layout="stack">
<div className="flex flex-wrap items-center gap-2">
{ACCENTS.map((spec) => {
const active =
a.accentMode === 'preset' && a.accentPreset === spec.id;
return (
<button
key={spec.id}
type="button"
onClick={() =>
setAppearance({
accentMode: 'preset',
accentPreset: spec.id,
})
}
title={spec.label}
aria-label={spec.label}
className={cn(
'relative flex size-9 items-center justify-center rounded-full border transition-transform cursor-pointer hover:scale-105',
active
? 'border-foreground/30 ring-2 ring-primary'
: 'border-border',
)}
style={{ backgroundColor: spec.swatch }}
>
{active && (
<Check
className="size-4 text-white drop-shadow-sm"
strokeWidth={3}
/>
)}
</button>
);
})}
<label
title="自定义颜色"
className={cn(
'relative flex size-9 cursor-pointer items-center justify-center overflow-hidden rounded-full border transition-transform hover:scale-105',
a.accentMode === 'custom'
? 'border-foreground/30 ring-2 ring-primary'
: 'border-border',
)}
style={{
background:
a.accentMode === 'custom'
? a.accentCustom
: 'conic-gradient(from 0deg, #f43f5e, #f59e0b, #10b981, #0ea5e9, #8b5cf6, #f43f5e)',
}}
>
<input
type="color"
value={toColorInputValue(a.accentCustom)}
onChange={(e) => pickCustomAccent(e.target.value)}
className="absolute inset-0 cursor-pointer opacity-0"
aria-label="自定义强调色"
/>
{a.accentMode === 'custom' && (
<Check
className="pointer-events-none size-4 text-white drop-shadow-sm"
strokeWidth={3}
/>
)}
</label>
</div>
</SettingRow>
<SettingRow
label="应用范围"
hint="“仅侧栏”时,主区域保持默认色,仅调整侧栏的底色与高亮。"
>
<Segmented
value={a.accentScope}
options={SCOPE_OPTIONS}
onChange={(accentScope) => setAppearance({ accentScope })}
/>
</SettingRow>
<SettingRow label="侧栏样式">
<Segmented
value={a.sidebarStyle}
options={SIDEBAR_STYLE_OPTIONS}
onChange={(sidebarStyle) => setAppearance({ sidebarStyle })}
/>
</SettingRow>
</Group>
<BackgroundCard
appearance={a}
setAppearance={setAppearance}
uploadBackground={uploadBackground}
removeBackground={removeBackground}
/>
</fieldset>
<Group title="排版与界面" icon={Palette} description="字体、圆角、缩放与密度。">
<fieldset disabled={!!managedBy} className="contents" aria-label="字体与圆角">
<SettingRow label="界面字体" layout="stack">
<FontField
value={a.fontSans}
custom={a.fontSansCustom}
presets={FONT_SANS_OPTIONS}
onChange={(fontSans) => setAppearance({ fontSans })}
onCustomChange={(fontSansCustom) => setAppearance({ fontSansCustom })}
placeholder="例如:LXGW WenKai, PingFang SC"
/>
</SettingRow>
<SettingRow label="等宽字体" hint="用于日志与代码。" layout="stack">
<FontField
value={a.fontMono}
custom={a.fontMonoCustom}
presets={FONT_MONO_OPTIONS}
onChange={(fontMono) => setAppearance({ fontMono })}
onCustomChange={(fontMonoCustom) => setAppearance({ fontMonoCustom })}
placeholder="例如:Cascadia Code, Sarasa Mono SC"
/>
</SettingRow>
<SettingRow label="圆角">
<Segmented
value={a.radius}
options={RADIUS_OPTIONS.map((r) => ({
value: r.value,
label: r.label,
}))}
onChange={(radius) => setAppearance({ radius })}
/>
</SettingRow>
</fieldset>
<SliderSettingRow
label="界面缩放"
hint="整体放大或缩小界面,适合高分屏或视力需要。"
min={UI_SCALE.min}
max={UI_SCALE.max}
step={UI_SCALE.step}
value={a.uiScale}
valueLabelFormat={(nextValue) => `${Math.round(nextValue * 100)}%`}
onValueChange={(uiScale) => setAppearance({ uiScale })}
aria-valuetext={`${Math.round(a.uiScale * 100)}%`}
/>
<SettingRow label="显示密度">
<Segmented
value={a.density}
options={DENSITY_OPTIONS}
onChange={(density) => setAppearance({ density })}
/>
</SettingRow>
</Group>
<fieldset disabled={!!managedBy} className="contents" aria-label="主题变量">
<ThemeVarsPanel
cssVars={a.cssVars}
onChange={(cssVars) => setAppearance({ cssVars })}
/>
</fieldset>
<TopbarPanel />
<Group
title="右键菜单"
icon={MousePointer2}
description="控制网页内右键操作的呈现方式。"
>
<SettingRow
label="自定义右键菜单"
hint="默认开启;关闭后立即恢复浏览器原生右键菜单。"
layout="inline"
>
<ToggleSwitch
value={a.customContextMenu}
onChange={(customContextMenu) => setAppearance({ customContextMenu })}
ariaLabel="自定义右键菜单"
/>
</SettingRow>
</Group>
<Group
title="交互与无障碍"
icon={Accessibility}
description="光标、动效、对比度与侧栏行为。"
>
<SettingRow
label="自定义光标"
hint="默认关闭;开启后使用主题化自适应光标。"
layout="inline"
>
<ToggleSwitch
value={a.customPointerSystem}
disabled={!!managedBy}
onChange={(customPointerSystem) => setAppearance({ customPointerSystem })}
ariaLabel="自定义光标"
/>
</SettingRow>
<SettingRow
label="减弱动效"
hint="弱化页面切换、弹簧等装饰性动画(保留轻微淡入),对低端设备与晕动敏感者更友好。"
layout="inline"
>
<ToggleSwitch
value={a.reduceMotion}
onChange={(reduceMotion) => setAppearance({ reduceMotion })}
ariaLabel="减弱动效"
/>
</SettingRow>
<SettingRow
label="关闭全部动效"
hint="比“减弱动效”更彻底:移除所有界面动画,包括入场淡入与状态点闪烁。"
layout="inline"
>
<ToggleSwitch
value={a.disableMotion}
onChange={(disableMotion) => setAppearance({ disableMotion })}
ariaLabel="关闭全部动效"
/>
</SettingRow>
<SettingRow label="高对比模式" hint="加强边框与次要文字的对比度。" layout="inline">
<ToggleSwitch
value={a.highContrast}
onChange={(highContrast) => setAppearance({ highContrast })}
ariaLabel="高对比模式"
/>
</SettingRow>
{isDesktop && (
<SettingRow
label="钉住侧栏展开"
hint="侧栏常驻展开,不再收成图标轨、也不随悬停自动收起。"
layout="inline"
>
<ToggleSwitch
value={a.sidebarPinned}
onChange={(sidebarPinned) => setAppearance({ sidebarPinned })}
ariaLabel="钉住侧栏展开"
/>
</SettingRow>
)}
</Group>
<ConfirmDialog
open={contrastWarn !== null}
onOpenChange={(o) => {
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,
})
}
/>
</div>
);
}
function BackgroundCard({
appearance: a,
setAppearance,
uploadBackground,
removeBackground,
}: {
appearance: ReturnType<typeof useTheme>['appearance'];
setAppearance: ReturnType<typeof useTheme>['setAppearance'];
uploadBackground: ReturnType<typeof useTheme>['uploadBackground'];
removeBackground: ReturnType<typeof useTheme>['removeBackground'];
}) {
const fileRef = useRef<HTMLInputElement>(null);
const [uploading, setUploading] = useState(false);
const [busy, setBusy] = useState(false);
const [err, setErr] = useState<string | null>(null);
const onFile = async (e: React.ChangeEvent<HTMLInputElement>) => {
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 (
<Group
title="背景"
icon={ImageIcon}
description="纯色、渐变或自定义壁纸,会显示在卡片之间与登录页。"
>
<SettingRow label="背景类型">
<Segmented
value={a.background.type}
options={BG_TYPE_OPTIONS}
onChange={(type) => setAppearance({ background: { type } })}
/>
</SettingRow>
{a.background.type === 'solid' && (
<SettingRow label="背景颜色">
<div className="flex items-center gap-3">
<label
className="relative size-10 cursor-pointer overflow-hidden rounded-lg border"
style={{ backgroundColor: a.background.color }}
>
<input
type="color"
value={toColorInputValue(a.background.color)}
onChange={(e) =>
setAppearance({ background: { color: e.target.value } })
}
className="absolute inset-0 cursor-pointer opacity-0"
aria-label="背景颜色"
/>
</label>
<span className="font-mono text-sm text-muted-foreground">
{a.background.color}
</span>
</div>
</SettingRow>
)}
{a.background.type === 'gradient' && (
<SettingRow label="渐变预设" layout="stack">
<div className="flex flex-wrap gap-2">
{GRADIENT_OPTIONS.map((g) => {
const active = a.background.gradient === g.id;
return (
<button
key={g.id}
type="button"
onClick={() =>
setAppearance({ background: { gradient: g.id } })
}
title={g.label}
aria-label={g.label}
className={cn(
'h-12 w-20 rounded-lg border transition-transform cursor-pointer hover:scale-105',
active
? 'border-foreground/30 ring-2 ring-primary'
: 'border-border',
)}
style={{ backgroundImage: g.css }}
/>
);
})}
</div>
</SettingRow>
)}
{a.background.type === 'image' && (
<>
<SettingRow
label="壁纸"
hint="支持 PNG / JPEG / WebP,最大 5MB。"
layout="stack"
>
<div className="flex flex-wrap items-center gap-3">
<input
ref={fileRef}
type="file"
accept="image/png,image/jpeg,image/webp"
onChange={onFile}
className="hidden"
/>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
disabled={uploading}
>
{uploading ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}
{a.background.hasImage ? '更换图片' : '上传图片'}
</Button>
{a.background.hasImage && (
<Button
variant="ghost"
size="sm"
onClick={onRemove}
disabled={busy}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-4" /> 移除
</Button>
)}
</div>
{a.background.hasImage && (
<div className="overflow-hidden rounded-lg border">
<img
src={`/ui-asset/background?v=${a.background.imageVersion}`}
alt="背景预览"
className="h-32 w-full object-cover"
/>
</div>
)}
</SettingRow>
{a.background.hasImage && (
<>
<SliderSettingRow
label="遮罩强度"
hint="越高越能盖住壁纸、提升前景可读性。"
min={0}
max={1}
step={0.05}
value={a.background.imageOpacity}
valueLabelFormat={(nextValue) => `${Math.round(nextValue * 100)}%`}
onValueChange={(imageOpacity) =>
setAppearance({ background: { imageOpacity } })
}
aria-valuetext={`${Math.round(a.background.imageOpacity * 100)}%`}
/>
<SliderSettingRow
label="背景模糊"
min={0}
max={40}
step={1}
value={a.background.imageBlur}
valueLabelFormat={(nextValue) => `${nextValue}px`}
onValueChange={(imageBlur) =>
setAppearance({ background: { imageBlur } })
}
aria-valuetext={`${a.background.imageBlur} 像素`}
/>
</>
)}
</>
)}
{err && <p className="px-5 py-3 text-[12px] text-destructive">{err}</p>}
</Group>
);
}
// A font selector: preset segments + a "自定义" segment that reveals a
// free-form family input. On Chromium (where `queryLocalFonts` exists) the
// input gets a native <datalist> 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<LocalFont[]> };
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<string[] | null>(null);
const [loading, setLoading] = useState(false);
const [fontLoadError, setFontLoadError] = useState<string | null>(null);
const canQuery = typeof (window as FontQueryWindow).queryLocalFonts === 'function';
const isCustom = value === FONT_CUSTOM_ID;
const options: Opt<string>[] = [
...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 (
<div className="flex flex-col gap-3">
<Segmented value={value} options={options} onChange={onChange} />
{isCustom && (
<div className="flex flex-col gap-1.5">
<input
type="text"
value={custom}
list={canQuery ? listId : undefined}
onChange={(e) => 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 && (
<datalist id={listId}>
{fonts.map((f) => (
<option key={f} value={f} />
))}
</datalist>
)}
<SkeletonSwap
ready={!loading}
reserve={28}
lines={1}
label="本机字体"
className={!loading ? 'skeleton-swap-fluid' : ''}
>
{fontLoadError ? (
<div className="flex items-center gap-2 text-xs leading-snug text-destructive">
<span>{fontLoadError}</span>
<button
type="button"
className="shrink-0 font-medium underline underline-offset-2"
onClick={() => void loadFonts()}
>
重试
</button>
</div>
) : (
<p className="text-xs leading-snug text-muted-foreground">
{canQuery
? '可输入字体名称,或聚焦后从已安装字体中选择;找不到时回退到系统字体。'
: '输入已安装的字体名称(可写多个,用逗号分隔);找不到时回退到系统字体。'}
</p>
)}
</SkeletonSwap>
</div>
)}
</div>
);
}
// 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<string, string>;
onChange: (next: Record<string, string>) => 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 (
<Group
title="主题微调"
icon={Palette}
description="逐个覆盖主题颜色变量,叠加在当前配色之上;未设置的保持配色默认值。登录页也会应用(安全:仅白名单变量与颜色值)。"
>
{CSS_VAR_FIELDS.map((f) => {
const override = cssVars[f.token];
return (
<SettingRow
key={f.token}
label={f.label}
hint={<code className="font-mono text-meta">{f.token}</code>}
layout="inline"
>
<div className="flex items-center gap-2.5">
{override ? (
<span className="font-mono text-meta text-muted-foreground tabular-nums">
{override}
</span>
) : (
<span className="text-meta text-muted-foreground">默认</span>
)}
<label
title="选择颜色"
className="relative size-7 cursor-pointer overflow-hidden rounded-md border"
style={
override
? { backgroundColor: override }
: {
background:
'repeating-conic-gradient(#cbd5e1 0% 25%, #fff 0% 50%) 50% / 10px 10px',
}
}
>
<input
type="color"
value={toColorInputValue(override || '#888888', '#888888')}
onChange={(e) => set(f.token, e.target.value)}
className="absolute inset-0 cursor-pointer opacity-0"
aria-label={`${f.label}颜色`}
/>
</label>
<button
type="button"
onClick={() => clear(f.token)}
disabled={!override}
aria-label={`重置${f.label}`}
className="rounded-md p-1 text-muted-foreground transition-colors outline-none focus-visible:ring-[3px] focus-visible:ring-ring/40 enabled:hover:text-destructive disabled:opacity-30"
>
<RotateCcw className="size-3.5" />
</button>
</div>
</SettingRow>
);
})}
{count > 0 && (
<div className="flex items-center justify-between gap-4 px-5 py-3">
<span className="text-meta text-muted-foreground">已覆盖 {count} 个变量</span>
<Button
variant="ghost"
size="sm"
onClick={() => onChange({})}
className="text-destructive hover:text-destructive"
>
<RotateCcw className="size-4" /> 全部恢复默认
</Button>
</div>
)}
</Group>
);
}
// ─────────────── 数据与格式 ───────────────
const TIME_FORMAT_OPTIONS: Opt<TimeFormat>[] = [
{ value: '24h', label: '24 小时制' },
{ value: '12h', label: '12 小时制 (AM/PM)' },
];
function DataPanel() {
const { appearance, setAppearance } = useTheme();
const { pages, setPages } = useLayout();
return (
<div className="flex flex-col gap-5">
<Group
title="数据刷新"
icon={RefreshCw}
description="总览页轮询刷新 QQ 列表、进程列表与系统状态的间隔。"
>
<SettingRow
label="轮询间隔"
hint={
appearance.pollInterval === 0
? '已暂停轮询'
: `当前 ${appearance.pollInterval} 毫秒`
}
layout="stack"
>
<Segmented
value={appearance.pollInterval}
options={POLL_INTERVAL_OPTIONS.map((o) => ({
value: o.value,
label: o.label,
}))}
onChange={(pollInterval) => setAppearance({ pollInterval })}
/>
</SettingRow>
</Group>
<Group title="登录后落地页" description="登录后默认打开的页面。">
<SettingRow label="默认页面" layout="stack">
<Segmented
value={pages.defaultRoute}
options={NAV_ITEMS.map((n) => ({ value: n.to as string, label: n.label }))}
onChange={(defaultRoute) => setPages({ defaultRoute })}
/>
</SettingRow>
</Group>
<Group title="时间格式" icon={Clock} description="日志与告警等时间戳的显示方式。">
<SettingRow label="时制" layout="stack">
<Segmented
value={appearance.timeFormat}
options={TIME_FORMAT_OPTIONS}
onChange={(timeFormat) => setAppearance({ timeFormat })}
/>
</SettingRow>
</Group>
</div>
);
}
// ─────────────── 高级(自定义 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<HTMLTextAreaElement>(null);
const [cssWarn, setCssWarn] = useState<string | null>(null);
const api = useApi();
const fileRef = useRef<HTMLInputElement>(null);
const [msg, setMsg] = useState<{ kind: 'ok' | 'err'; text: string } | null>(null);
const [confirmReset, setConfirmReset] = useState(false);
const [pendingImport, setPendingImport] = useState<File | null>(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<HTMLInputElement>) => {
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<typeof api.ui.save>[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 (
<div className="flex flex-col gap-5">
{managedBy && (
<p className="text-sm text-muted-foreground">
自定义 CSS 已保留,跟随{managedBy}外观期间暂停应用。
</p>
)}
<fieldset disabled={!!managedBy} className="contents" aria-label="自定义 CSS">
<Card>
<CardHeader className="gap-1">
<CardTitle className="flex items-center gap-2 text-[15px]">
<Code2 className="size-4 text-primary" /> 自定义 CSS
</CardTitle>
<CardDescription className="text-[12px] leading-relaxed">
高级用户可注入自定义样式,登录后全局生效(登录页不受影响)。
若改坏了界面,在地址后加{' '}
<code className="rounded bg-muted px-1 py-0.5 font-mono text-meta">
?safe-mode=1
</code>{' '}
可临时禁用自定义 CSS 进来修复。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-1.5">
<span className="mr-1 text-xs text-muted-foreground">插入模板:</span>
{CSS_SNIPPETS.map((s) => (
<button
key={s.label}
type="button"
onClick={() => insertSnippet(s.css)}
className="inline-flex items-center gap-1 rounded-md border bg-muted/40 px-2 py-1 text-xs font-medium text-muted-foreground transition-colors cursor-pointer outline-none hover:text-foreground focus-visible:ring-[3px] focus-visible:ring-ring/40"
>
<Plus className="size-3" /> {s.label}
</button>
))}
</div>
<Textarea
ref={cssRef}
value={appearance.customCss}
onChange={(e) => {
setAppearance({ customCss: e.target.value });
if (cssWarn) {
setCssWarn(null);
}
}}
onBlur={(e) => setCssWarn(validateCss(e.target.value))}
maxLength={50000}
spellCheck={false}
placeholder={
'/* 例如:放大侧栏字号 */\n.text-sidebar-foreground { font-size: 1.05em; }'
}
className="h-64 resize-y rounded-lg bg-card/40 p-3 font-mono text-base leading-relaxed md:text-[12px]"
/>
{cssWarn && (
<p className="flex items-start gap-1.5 text-xs text-warning">
<AlertTriangle className="mt-px size-3.5 shrink-0" /> {cssWarn}
</p>
)}
<div className="flex items-center justify-between gap-2">
<span className="text-meta text-muted-foreground">
{appearance.customCss.length} / 50000 字符
</span>
<Button
variant="ghost"
size="sm"
onClick={() => {
setAppearance({ customCss: '' });
setCssWarn(null);
}}
disabled={!appearance.customCss}
className="text-destructive hover:text-destructive"
>
<Trash2 className="size-4" /> 清空
</Button>
</div>
</CardContent>
</Card>
</fieldset>
<Card>
<CardHeader className="gap-1">
<CardTitle className="text-[15px]">备份与重置</CardTitle>
<CardDescription className="text-[12px] leading-relaxed">
导出 / 导入全部界面配置(外观 + 布局 + 自定义
CSS)。背景图片需另行重新上传。
</CardDescription>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<div className="flex flex-wrap items-center gap-2">
<input
ref={fileRef}
type="file"
accept="application/json,.json"
onChange={onImportFile}
className="hidden"
/>
<Button variant="outline" size="sm" onClick={onExport} disabled={busy}>
<Download className="size-4" /> 导出
</Button>
<Button
variant="outline"
size="sm"
onClick={() => fileRef.current?.click()}
disabled={busy}
>
{busy ? (
<Loader2 className="size-4 animate-spin" />
) : (
<Upload className="size-4" />
)}{' '}
导入
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmReset(true)}
disabled={busy}
className="text-destructive hover:text-destructive"
>
<RotateCcw className="size-4" /> 重置全部
</Button>
</div>
{msg && (
<span
className={cn(
'text-xs',
msg.kind === 'ok' ? 'text-success' : 'text-destructive',
)}
>
{msg.text}
</span>
)}
</CardContent>
</Card>
<ConfirmDialog
open={confirmReset}
onOpenChange={setConfirmReset}
title="重置全部界面配置"
description="将外观、仪表盘布局、导航与自定义 CSS 全部恢复为默认。此操作不可撤销。"
confirmText="重置"
destructive
onConfirm={doReset}
/>
<ConfirmDialog
open={pendingImport !== null}
onOpenChange={(open) => {
if (!open) {
setPendingImport(null);
}
}}
title="导入并覆盖界面配置?"
description={
pendingImport
? `将使用 ${pendingImport.name} 覆盖当前外观、布局、导航与自定义 CSS,随后重新载入界面。`
: ''
}
confirmText="确认导入"
destructive
onConfirm={async () => {
if (!pendingImport) {
return;
}
await importUiConfig(pendingImport);
setPendingImport(null);
}}
/>
</div>
);
}
// ─────────────── 账号安全 ───────────────
function AccountPanel() {
const { onLogout } = useAppState();
const [showChangePwd, setShowChangePwd] = useState(false);
const [pwdSavedAt, setPwdSavedAt] = useState<number | null>(null);
const [confirmLogout, setConfirmLogout] = useState(false);
return (
<div className="flex flex-col gap-5">
<Group
title="账号安全"
icon={ShieldCheck}
description="WebUI 仅有 admin 一个账号,密码以 scrypt 哈希持久化到 config/webui.json。"
>
<SettingRow
label="访问密码"
hint={
pwdSavedAt ? (
<span className="text-success">
已更新({new Date(pwdSavedAt).toLocaleTimeString()})—
其他设备的会话已失效。
</span>
) : (
'修改后其他设备的会话将立即失效。'
)
}
>
<Button variant="outline" size="sm" onClick={() => setShowChangePwd(true)}>
<KeyRound className="size-4" /> 修改密码
</Button>
</SettingRow>
<SettingRow label="退出登录" hint="立即清除当前浏览器的会话令牌。">
<Button
variant="ghost"
size="sm"
onClick={() => setConfirmLogout(true)}
className="text-destructive hover:text-destructive"
>
退出登录
</Button>
</SettingRow>
<ConfirmDialog
open={confirmLogout}
onOpenChange={setConfirmLogout}
title="确认退出登录?"
description="退出后将清除当前会话令牌,需要重新输入访问密码才能进入控制台。"
confirmText="退出登录"
destructive
activity={{
title: '正在退出登录',
successTitle: '已退出登录',
errorTitle: '退出登录失败',
}}
onConfirm={onLogout}
/>
<ChangePasswordDialog
open={showChangePwd}
onOpenChange={setShowChangePwd}
onSuccess={() => setPwdSavedAt(Date.now())}
/>
</Group>
<TotpPanel />
</div>
);
}
// ─────────────── 开发者 ───────────────
function DeveloperPanel() {
const { restartOnboarding } = useSession();
const [confirmOnboarding, setConfirmOnboarding] = useState(false);
const [confirmCrash, setConfirmCrash] = useState(false);
const [crashRequested, requestCrash] = useReducer(developerCrashReducer, false);
return (
<div className="flex flex-col gap-5">
{/* Event-handler errors bypass React boundaries; the probe throws on render. */}
<DeveloperCrashProbe requested={crashRequested} />
<Group
title="界面测试"
icon={Bug}
description="直接验证首次使用流程与页面级异常恢复;这些工具始终可见,不受环境开关限制。"
>
<SettingRow
label="首次使用引导"
hint="以演练模式重新进入完整引导,不会修改协议确认记录或真实访问密码。"
>
<Button variant="outline" size="sm" onClick={() => setConfirmOnboarding(true)}>
<RotateCcw className="size-4" /> 重新进入
</Button>
</SettingRow>
<SettingRow
label="崩溃界面"
hint="主动抛出测试异常并交给页面错误边界;在崩溃界面点击“重试”即可返回。"
>
<Button variant="destructive" size="sm" onClick={() => setConfirmCrash(true)}>
<AlertTriangle className="size-4" /> 触发崩溃
</Button>
</SettingRow>
</Group>
<ConfirmDialog
open={confirmOnboarding}
onOpenChange={setConfirmOnboarding}
title="重新进入首次使用引导?"
description="将以演练模式展示完整流程,不会修改协议确认记录或真实访问密码。"
confirmText="进入引导"
onConfirm={restartOnboarding}
/>
<ConfirmDialog
open={confirmCrash}
onOpenChange={setConfirmCrash}
title="触发 WebUI 崩溃测试?"
description="当前设置页面将主动抛出测试异常,并交由崩溃界面接管。"
confirmText="触发崩溃"
destructive
onConfirm={() => requestCrash('confirm')}
/>
</div>
);
}
// ─────────────── 关于 ───────────────
const REPO_URL = 'https://github.com/SnowLuma/SnowLuma';
function UpdateGroup() {
const { updateInfo, refreshUpdate, resources } = useAppState();
const { runAction } = useActionFeedback();
const [checking, setChecking] = useState(false);
const onCheck = async () => {
setChecking(true);
try {
await runAction(
{
title: '正在检查更新',
detail: `当前版本 v${APP_VERSION}`,
successTitle: '更新检查完成',
successDetail: (result) =>
result?.hasUpdate && result.latest
? `发现新版本 v${result.latest}`
: '当前已是最新版本',
errorTitle: '更新检查失败',
resultError: (result) => {
if (!result) {
return '更新检查未返回结果';
}
if (result.error === 'disabled') {
return '更新检查已关闭';
}
return result.error || null;
},
},
() => refreshUpdate(true),
);
} catch (error) {
console.error('manual update check failed', error);
} finally {
setChecking(false);
}
};
const disabledCheck = updateInfo?.error === 'disabled';
let status: ReactNode;
if (!updateInfo && resources.updateInfo.error) {
status = <span className="text-muted-foreground">无法检查</span>;
} else if (!updateInfo) {
status = null;
} else if (disabledCheck) {
status = <span className="text-muted-foreground">检查已关闭</span>;
} else if (updateInfo.error) {
status = <span className="text-muted-foreground">无法检查</span>;
} else if (updateInfo.hasUpdate && updateInfo.latest) {
status = <span className="font-medium text-primary">发现新版本</span>;
} else {
status = (
<span className="inline-flex items-center gap-1.5 text-success">
<Check className="size-3.5" /> 已是最新
</span>
);
}
// Rich detail block, only when an update is available (narrows `latest`).
let detail: ReactNode = null;
if (updateInfo?.hasUpdate && updateInfo.latest) {
const latest = updateInfo.latest;
detail = (
<div className="bg-primary/[0.05] px-5 py-4 text-left">
<div className="flex items-center gap-2">
<Sparkles className="size-4 text-primary" />
<span className="text-sm font-medium">v{latest}</span>
{updateInfo.publishedAt && (
<span className="ml-auto text-meta tabular-nums text-muted-foreground">
{new Date(updateInfo.publishedAt).toLocaleDateString()}
</span>
)}
</div>
<p className="mt-1 text-xs text-muted-foreground">
当前 v{updateInfo.current} → 最新 v{latest}
</p>
{updateInfo.notes && (
<pre className="mt-3 max-h-40 overflow-auto whitespace-pre-wrap rounded-lg bg-muted/50 p-3 text-xs leading-relaxed text-muted-foreground">
{updateInfo.notes}
</pre>
)}
{updateInfo.htmlUrl && (
<Button asChild size="sm" className="mt-3">
<a href={updateInfo.htmlUrl} target="_blank" rel="noreferrer noopener">
<ExternalLink className="size-4" /> 发布说明
</a>
</Button>
)}
</div>
);
}
return (
<Group title="软件更新" icon={Sparkles}>
<SettingRow label="当前版本" layout="inline">
<code className="rounded-md border bg-muted/40 px-2 py-0.5 font-mono text-xs tabular-nums">
v{APP_VERSION}
</code>
</SettingRow>
<SettingRow label="检查更新">
<div className="flex items-center gap-2.5 text-[12px]">
<SkeletonSwap
ready={resources.updateInfo.ready}
reserve={24}
lines={1}
lineHeight={24}
barHeight={8}
label="更新状态"
className={
resources.updateInfo.ready
? 'skeleton-swap-fluid min-h-6 min-w-24'
: 'w-24'
}
>
{resources.updateInfo.ready ? status : null}
</SkeletonSwap>
{!disabledCheck && (
<Button variant="outline" size="sm" onClick={onCheck} disabled={checking}>
{checking ? (
<Loader2 className="size-4 animate-spin" />
) : (
<RefreshCw className="size-4" />
)}
重新检查
</Button>
)}
</div>
</SettingRow>
{detail}
{updateInfo?.error && !disabledCheck && (
<p role="alert" className="px-5 py-3 text-xs text-destructive">
{updateInfo.error}
</p>
)}
<UpdateSources />
<UpdateInstaller version={updateInfo?.hasUpdate ? updateInfo.latest : null} />
</Group>
);
}
function AboutPanel() {
return (
<div className="flex flex-col gap-5">
{/* brand hero */}
<Card className="overflow-hidden">
<CardContent className="flex flex-col items-center gap-3 px-6 py-9 text-center">
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.96 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ type: 'spring', stiffness: 260, damping: 22 }}
className="flex flex-col items-center gap-3"
>
<div className="relative">
<div
aria-hidden
className="absolute inset-0 -z-10 scale-125 rounded-full bg-primary/20 blur-2xl"
/>
<img
src="/logo.png"
alt="SnowLuma"
className="size-20 rounded-2xl object-cover drop-shadow-sm"
/>
</div>
<div className="flex items-center gap-2">
<span className="text-2xl font-semibold tracking-tight">SnowLuma</span>
<span className="rounded-full border border-primary/30 bg-primary/10 px-2 py-0.5 font-mono text-meta text-primary tabular-nums">
v{APP_VERSION}
</span>
</div>
<p className="text-sm text-muted-foreground">
Next Remote Protocol Framework · 下一代远程协议框架
</p>
</motion.div>
<p className="max-w-md text-[13px] leading-relaxed text-muted-foreground">
以轻量注入驱动 QQ NT,对外提供标准 OneBot v11
接口,让机器人一次部署、长期稳定运行。
</p>
</CardContent>
</Card>
{/* software update */}
<UpdateGroup />
{/* support + links */}
<Card className="overflow-hidden">
<CardContent className="flex flex-col items-center gap-4 px-6 py-7 text-center">
<Star className="size-7 fill-primary/15 text-primary" />
<div className="flex flex-col items-center gap-1">
<p className="text-sm font-medium">喜欢 SnowLuma?给个 Star 支持一下</p>
<p className="max-w-xs text-xs leading-relaxed text-muted-foreground">
开源不易,你的 ⭐ 是我们持续维护的最大动力。
</p>
</div>
<Button asChild className="w-full max-w-xs">
<a href={REPO_URL} target="_blank" rel="noreferrer noopener">
<Github className="size-4" /> 去 GitHub 点 Star
</a>
</Button>
<div className="flex flex-wrap items-center justify-center gap-x-5 gap-y-2 pt-1 text-[12px]">
<AboutLink href={REPO_URL} icon={Github}>
仓库
</AboutLink>
<AboutLink href={`${REPO_URL}/releases`} icon={Tag}>
发行版
</AboutLink>
<AboutLink href={`${REPO_URL}/issues`} icon={Bug}>
反馈问题
</AboutLink>
</div>
</CardContent>
</Card>
</div>
);
}
function AboutLink({
href,
icon: Icon,
children,
}: {
href: string;
icon: typeof Sun;
children: ReactNode;
}) {
return (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1.5 text-muted-foreground transition-colors hover:text-foreground"
>
<Icon className="size-3.5" />
{children}
<ExternalLink className="size-3 opacity-50" />
</a>
);
}