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:
1 parent
8469df359f
commit
9055cc77ec
7 files changed
+369
-119
No files matched your search
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user