feat: 外观设置后端持久化 + 保存按钮 + 跨浏览器同步

后端:
- User 模型新增 Preferences JSON 字段 (theme + login_style)
- 新增 GET/PUT /api/v1/user/preferences (auth) 个人偏好读写
- 新增 GET /api/v1/public/login-style (public) 登录页风格公开端点
- SQLite users 表手动添加 preferences 列

前端:
- AppearanceSettings 从后端加载偏好 + 保存按钮 + dirty 状态提示
- LoginPage 先从后端公开接口获取 login_style (跨浏览器一致)
- useLogin 登录成功后从后端加载偏好并应用主题
- 偏好同时存 localStorage(即时) + 后端DB(跨设备)
This commit is contained in:
logan committed 2026-05-26 22:09:47 +08:00
1 parent 8469df359f
commit 9055cc77ec
7 files changed
+369 -119

No files matched your search

+77 -16
View File
@@ -1,38 +1,77 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useCallback } from 'react';
import { useTheme } from '@/hooks/useTheme';
import { LOGIN_STYLE_OPTIONS, type LoginStyle } from '@/pages/LoginPage';
import { Sun, Moon, Monitor, Check } from 'lucide-react';
import { Sun, Moon, Check, Save, Loader2 } from 'lucide-react';
import api from '@/api/request';
import { toast } from '@/hooks/useToast';
export default function AppearanceSettings() {
const { theme, setTheme, isDark } = useTheme();
const { theme, setTheme } = useTheme();
const [loginStyle, setLoginStyle] = useState<LoginStyle>('split');
const [saving, setSaving] = useState(false);
const [loading, setLoading] = useState(true);
const [dirty, setDirty] = useState(false);
// Load preferences from backend on mount
useEffect(() => {
const stored = localStorage.getItem('login-style') as LoginStyle | null;
if (stored) setLoginStyle(stored);
api.get<{ theme?: string; login_style?: string }>('/user/preferences')
.then(prefs => {
if (prefs?.theme) setTheme(prefs.theme as 'dark' | 'light');
if (prefs?.login_style) setLoginStyle(prefs.login_style as LoginStyle);
})
.catch(() => {
// Fallback to localStorage
const stored = localStorage.getItem('login-style') as LoginStyle | null;
if (stored) setLoginStyle(stored);
})
.finally(() => setLoading(false));
}, []);
const handleThemeChange = (t: 'dark' | 'light') => {
setTheme(t);
setDirty(true);
};
const handleLoginStyleChange = (id: LoginStyle) => {
setLoginStyle(id);
localStorage.setItem('login-style', id);
setDirty(true);
};
const themeOptions = [
{ id: 'dark' as const, icon: Moon, label: '深色模式' },
{ id: 'light' as const, icon: Sun, label: '浅色模式' },
];
const handleSave = useCallback(async () => {
setSaving(true);
try {
await api.put('/user/preferences', {
theme,
login_style: loginStyle,
});
localStorage.setItem('login-style', loginStyle);
setDirty(false);
toast('success', '外观设置已保存');
} catch {
toast('error', '保存失败');
} finally {
setSaving(false);
}
}, [theme, loginStyle]);
if (loading) {
return (
<div className="flex items-center justify-center py-12">
<Loader2 className="w-5 h-5 animate-spin text-[var(--dg-text-muted)]" />
</div>
);
}
return (
<div className="space-y-8">
{/* 主题模式 */}
{/* Theme Mode */}
<div>
<h3 className="text-sm font-semibold mb-3">主题模式</h3>
<div className="flex gap-3">
{themeOptions.map(opt => (
{([
{ id: 'dark' as const, icon: Moon, label: '深色模式' },
{ id: 'light' as const, icon: Sun, label: '浅色模式' },
]).map(opt => (
<button
key={opt.id}
onClick={() => handleThemeChange(opt.id)}
@@ -50,10 +89,10 @@ export default function AppearanceSettings() {
</div>
</div>
{/* 登录页风格 */}
{/* Login Page Style */}
<div>
<h3 className="text-sm font-semibold mb-1">登录页风格</h3>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">选择登录页面的视觉风格,退出登录后生效</p>
<p className="text-xs text-[var(--dg-text-muted)] mb-4">选择登录页面的视觉风格,保存后退出登录即可看到效果</p>
<div className="grid grid-cols-5 gap-3">
{LOGIN_STYLE_OPTIONS.map(opt => (
<button
@@ -65,7 +104,7 @@ export default function AppearanceSettings() {
: 'bg-[var(--dg-bg-tertiary)] hover:bg-[var(--dg-bg-hover)]'
}`}
>
{/* 预览缩略图 */}
{/* Thumbnail preview */}
<div className={`w-full aspect-[4/3] rounded-lg mb-3 overflow-hidden ${
opt.id === 'split' ? 'flex' :
opt.id === 'centered' ? 'flex items-center justify-center dg-gradient-br' :
@@ -133,7 +172,7 @@ export default function AppearanceSettings() {
)}
</div>
{/* 选中标记 */}
{/* Active check mark */}
{loginStyle === opt.id && (
<div className="absolute top-2 right-2 w-5 h-5 rounded-full bg-blue-500 flex items-center justify-center">
<Check className="w-3 h-3 text-white" />
@@ -146,6 +185,28 @@ export default function AppearanceSettings() {
))}
</div>
</div>
{/* Save Button */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={handleSave}
disabled={saving || !dirty}
className={`flex items-center gap-2 px-5 py-2.5 rounded-lg text-sm font-medium transition-all ${
dirty
? 'dg-gradient-bg text-white hover:opacity-90'
: 'bg-[var(--dg-bg-tertiary)] text-[var(--dg-text-muted)] cursor-not-allowed'
}`}
>
{saving ? (
<><Loader2 className="w-4 h-4 animate-spin" /> 保存中...</>
) : (
<><Save className="w-4 h-4" /> 保存设置</>
)}
</button>
{dirty && (
<span className="text-xs text-amber-400">有未保存的更改</span>
)}
</div>
</div>
);
}