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 (
{TAB_GROUPS.map((group, gi) => (
{group.title}
{group.tabs.map((t) => {
const Icon = t.icon;
const active = tab === t.key;
return (
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',
)}
>
{t.label}
);
})}
))}
);
}
// ─────────────── 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 (
);
}
return (
);
}
function SliderSettingRow({
label,
hint,
...sliderProps
}: {
label: string;
hint?: ReactNode;
} & Omit, 'label'>) {
const hintId = useId();
return (
);
}
// 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({ 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',
)}
>
{p.preview.dots.map((d, i) => (
))}
{p.label}
);
})}
setAppearance({ mode })}
disabled={paletteFixed}
/>
setAppearance({ darkIntensity })}
disabled={paletteFixed}
/>
{ACCENTS.map((spec) => {
const active =
a.accentMode === 'preset' && a.accentPreset === spec.id;
return (
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 && (
)}
);
})}
pickCustomAccent(e.target.value)}
className="absolute inset-0 cursor-pointer opacity-0"
aria-label="自定义强调色"
/>
{a.accentMode === 'custom' && (
)}
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' && (
setAppearance({ background: { color: e.target.value } })
}
className="absolute inset-0 cursor-pointer opacity-0"
aria-label="背景颜色"
/>
{a.background.color}
)}
{a.background.type === 'gradient' && (
{GRADIENT_OPTIONS.map((g) => {
const active = a.background.gradient === g.id;
return (
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 }}
/>
);
})}
)}
{a.background.type === 'image' && (
<>
fileRef.current?.click()}
disabled={uploading}
>
{uploading ? (
) : (
)}
{a.background.hasImage ? '更换图片' : '上传图片'}
{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}
void loadFonts()}
>
重试
) : (
{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}
) : (
默认
)}
set(f.token, e.target.value)}
className="absolute inset-0 cursor-pointer opacity-0"
aria-label={`${f.label}颜色`}
/>
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"
>
);
})}
{count > 0 && (
已覆盖 {count} 个变量
onChange({})}
className="text-destructive hover:text-destructive"
>
全部恢复默认
)}
);
}
// ─────────────── 数据与格式 ───────────────
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) => (
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"
>
{s.label}
))}
备份与重置
导出 / 导入全部界面配置(外观 + 布局 + 自定义
CSS)。背景图片需另行重新上传。
导出
fileRef.current?.click()}
disabled={busy}
>
{busy ? (
) : (
)}{' '}
导入
setConfirmReset(true)}
disabled={busy}
className="text-destructive hover:text-destructive"
>
重置全部
{msg && (
{msg.text}
)}
{
if (!open) {
setPendingImport(null);
}
}}
title="导入并覆盖界面配置?"
description={
pendingImport
? `将使用 ${pendingImport.name} 覆盖当前外观、布局、导航与自定义 CSS,随后重新载入界面。`
: ''
}
confirmText="确认导入"
destructive
onConfirm={async () => {
if (!pendingImport) {
return;
}
await importUiConfig(pendingImport);
setPendingImport(null);
}}
/>
);
}
// ─────────────── 账号安全 ───────────────
function AccountPanel() {
const { onLogout } = useAppState();
const [showChangePwd, setShowChangePwd] = useState(false);
const [pwdSavedAt, setPwdSavedAt] = useState(null);
const [confirmLogout, setConfirmLogout] = useState(false);
return (
已更新({new Date(pwdSavedAt).toLocaleTimeString()})—
其他设备的会话已失效。
) : (
'修改后其他设备的会话将立即失效。'
)
}
>
setShowChangePwd(true)}>
修改密码
setConfirmLogout(true)}
className="text-destructive hover:text-destructive"
>
退出登录
setPwdSavedAt(Date.now())}
/>
);
}
// ─────────────── 开发者 ───────────────
function DeveloperPanel() {
const { restartOnboarding } = useSession();
const [confirmOnboarding, setConfirmOnboarding] = useState(false);
const [confirmCrash, setConfirmCrash] = useState(false);
const [crashRequested, requestCrash] = useReducer(developerCrashReducer, false);
return (
{/* Event-handler errors bypass React boundaries; the probe throws on render. */}
setConfirmOnboarding(true)}>
重新进入
setConfirmCrash(true)}>
触发崩溃
requestCrash('confirm')}
/>
);
}
// ─────────────── 关于 ───────────────
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 = 无法检查 ;
} else if (!updateInfo) {
status = null;
} else if (disabledCheck) {
status = 检查已关闭 ;
} else if (updateInfo.error) {
status = 无法检查 ;
} else if (updateInfo.hasUpdate && updateInfo.latest) {
status = 发现新版本 ;
} else {
status = (
已是最新
);
}
// 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 = (
v{latest}
{updateInfo.publishedAt && (
{new Date(updateInfo.publishedAt).toLocaleDateString()}
)}
当前 v{updateInfo.current} → 最新 v{latest}
{updateInfo.notes && (
{updateInfo.notes}
)}
{updateInfo.htmlUrl && (
发布说明
)}
);
}
return (
v{APP_VERSION}
{resources.updateInfo.ready ? status : null}
{!disabledCheck && (
{checking ? (
) : (
)}
重新检查
)}
{detail}
{updateInfo?.error && !disabledCheck && (
{updateInfo.error}
)}
);
}
function AboutPanel() {
return (
{/* brand hero */}
SnowLuma
v{APP_VERSION}
Next Remote Protocol Framework · 下一代远程协议框架
以轻量注入驱动 QQ NT,对外提供标准 OneBot v11
接口,让机器人一次部署、长期稳定运行。
{/* software update */}
{/* support + links */}
喜欢 SnowLuma?给个 Star 支持一下
开源不易,你的 ⭐ 是我们持续维护的最大动力。
去 GitHub 点 Star
);
}
function AboutLink({
href,
icon: Icon,
children,
}: {
href: string;
icon: typeof Sun;
children: ReactNode;
}) {
return (
{children}
);
}