+ {/*
+ 下拉面板用显式的 bg-white / dark:bg-neutral-900 避免依赖 bg-popover
+ CSS 变量(原色值在某些主题下与 background 几乎同色导致看不清)。
+ 每一项都显式 text-neutral-900 / dark:text-neutral-100 确保文字可读。
+ */}
+
+ {/* 账号信息区 */}
+
+
{user.name ?? "Signed in"}
{user.email ? (
-
+
{user.email}
) : null}
+ {/* 设置入口:登录用户均可见,指向 /settings 偏好页 */}
+
+ 设置
+
+
{provider === "github" ? (
切换 GitHub 账号(将在新标签页登出 GitHub)
@@ -61,7 +80,7 @@ export function UserMenu({ user, provider, logout }: UserMenuProps) {
diff --git a/app/settings/SettingsForm.tsx b/app/settings/SettingsForm.tsx
new file mode 100644
index 0000000..00f81bc
--- /dev/null
+++ b/app/settings/SettingsForm.tsx
@@ -0,0 +1,284 @@
+"use client";
+
+// 用户偏好设置表单(Client Component)
+// 负责:拉取偏好数据、渲染编辑 UI、提交保存、同步 ThemeProvider
+
+import { useEffect, useRef, useState } from "react";
+import { useRouter } from "next/navigation";
+import { useAuth } from "@/lib/use-auth";
+import { useTheme } from "@/app/components/ThemeProvider";
+
+// 与后端 preferences 字段一一对应
+interface UserPreferences {
+ theme: "light" | "dark" | "system";
+ language: "zh" | "en";
+ aiDefaultProvider: "intern" | "openai" | "gemini";
+}
+
+const DEFAULT_PREFS: UserPreferences = {
+ theme: "system",
+ language: "zh",
+ aiDefaultProvider: "intern",
+};
+
+// 从 localStorage 读取 satoken
+function getToken(): string | null {
+ if (typeof window === "undefined") return null;
+ return localStorage.getItem("satoken");
+}
+
+// 骨架屏占位
+function SkeletonRow() {
+ return (
+
+ );
+}
+
+export function SettingsForm() {
+ const { status } = useAuth();
+ const { setTheme } = useTheme();
+ const router = useRouter();
+
+ const [prefs, setPrefs] = useState
(DEFAULT_PREFS);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState(false);
+ const [toast, setToast] = useState<{
+ type: "success" | "error";
+ msg: string;
+ } | null>(null);
+ // toast 定时器 ref:新 toast / 卸载时清掉旧 timer,避免 setState on unmounted
+ const toastTimerRef = useRef | null>(null);
+
+ // 未登录时重定向
+ useEffect(() => {
+ if (status === "unauthenticated") {
+ router.replace("/login?redirect=/settings");
+ }
+ }, [status, router]);
+
+ // 拉取偏好数据
+ useEffect(() => {
+ if (status !== "authenticated") return;
+ const token = getToken();
+ // token 缺失时立刻结束 loading 并提示 + 跳转,否则页面会卡在骨架屏
+ if (!token) {
+ setLoading(false);
+ showToast("error", "登录态丢失,请重新登录");
+ router.replace("/login?redirect=/settings");
+ return;
+ }
+
+ fetch("/api/user-center/preferences", {
+ headers: { satoken: token },
+ })
+ .then((res) => {
+ if (!res.ok) throw new Error("获取偏好失败");
+ return res.json();
+ })
+ .then((body) => {
+ if (body?.success && body?.data) {
+ const merged = { ...DEFAULT_PREFS, ...body.data };
+ setPrefs(merged);
+ // 加载出来的 theme 立即同步到 ThemeProvider,避免"已保存设置与当前主题不一致"
+ setTheme(merged.theme);
+ }
+ })
+ .catch(() => {
+ showToast("error", "无法加载偏好设置,已显示默认值");
+ })
+ .finally(() => setLoading(false));
+ // setTheme 是 ThemeProvider 提供的稳定引用,router 同理;这里依赖 status 变化触发
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [status]);
+
+ // 组件卸载时清掉残留 toast timer
+ useEffect(() => {
+ return () => {
+ if (toastTimerRef.current) {
+ clearTimeout(toastTimerRef.current);
+ }
+ };
+ }, []);
+
+ function showToast(type: "success" | "error", msg: string) {
+ if (toastTimerRef.current) {
+ clearTimeout(toastTimerRef.current);
+ }
+ setToast({ type, msg });
+ toastTimerRef.current = setTimeout(() => setToast(null), 3000);
+ }
+
+ async function handleSave() {
+ const token = getToken();
+ // token 缺失时给明确反馈并跳转登录,而不是静默返回让用户摸不着头脑
+ if (!token) {
+ showToast("error", "登录态丢失,请重新登录后再保存");
+ router.replace("/login?redirect=/settings");
+ return;
+ }
+ setSaving(true);
+ try {
+ const res = await fetch("/api/user-center/preferences", {
+ method: "PATCH",
+ headers: {
+ "Content-Type": "application/json",
+ satoken: token,
+ },
+ body: JSON.stringify(prefs),
+ });
+ if (!res.ok) throw new Error("保存失败");
+ const body = await res.json();
+ if (body?.data) {
+ const merged: UserPreferences = { ...DEFAULT_PREFS, ...body.data };
+ setPrefs(merged);
+ // 主题变化立即同步到 ThemeProvider(同步写 localStorage)
+ setTheme(merged.theme);
+ }
+ showToast("success", "偏好设置已保存");
+ } catch {
+ showToast("error", "保存失败,请稍后重试");
+ } finally {
+ setSaving(false);
+ }
+ }
+
+ // 加载中或未登录均显示骨架屏,避免闪烁
+ if (status === "loading" || loading) {
+ return (
+
+
+
+
+
+ );
+ }
+
+ // 未认证时页面已重定向,此处不需要渲染
+ if (status === "unauthenticated") return null;
+
+ const themeOptions: { value: UserPreferences["theme"]; label: string }[] = [
+ { value: "light", label: "浅色" },
+ { value: "dark", label: "深色" },
+ { value: "system", label: "跟随系统" },
+ ];
+
+ const langOptions: { value: UserPreferences["language"]; label: string }[] = [
+ { value: "zh", label: "中文" },
+ { value: "en", label: "English" },
+ ];
+
+ const aiOptions: {
+ value: UserPreferences["aiDefaultProvider"];
+ label: string;
+ }[] = [
+ { value: "intern", label: "书生(InternLM)" },
+ { value: "openai", label: "OpenAI" },
+ { value: "gemini", label: "Gemini" },
+ ];
+
+ return (
+
+ {/* Toast 提示 */}
+ {toast && (
+
+ {toast.msg}
+
+ )}
+
+ {/* 主题设置 */}
+
+
+
+ {themeOptions.map(({ value, label }) => (
+
+ ))}
+
+
+
+ {/* 语言设置 */}
+
+
+
+ {langOptions.map(({ value, label }) => (
+
+ ))}
+
+
+
+ {/* AI 默认提供商 */}
+
+
+
+
+
+ {/* 提交按钮 */}
+
+
+
+
+ );
+}
diff --git a/app/settings/page.tsx b/app/settings/page.tsx
new file mode 100644
index 0000000..12a2b48
--- /dev/null
+++ b/app/settings/page.tsx
@@ -0,0 +1,28 @@
+// 用户偏好设置页(Server Component)
+// 登录态由客户端 SettingsForm 内部的 useAuth 处理:token 存在 localStorage,服务端无法读取,
+// 所以这里不做服务端鉴权,仅负责渲染页面壳。未登录 → 客户端 router.replace 到 /login?redirect=/settings。
+import { Header } from "@/app/components/Header";
+import { Footer } from "@/app/components/Footer";
+import { SettingsForm } from "./SettingsForm";
+
+export default function SettingsPage() {
+ return (
+ <>
+
+
+
+
+
+ Settings
+
+
+ User Preferences — Customize your experience
+
+
+
+
+
+
+ >
+ );
+}
diff --git a/next.config.mjs b/next.config.mjs
index 8430484..69dcbee 100644
--- a/next.config.mjs
+++ b/next.config.mjs
@@ -40,6 +40,17 @@ const config = {
source: "/analytics/:path*",
destination: `${backendUrl}/analytics/:path*`,
},
+ {
+ // 用户中心 API(偏好设置等)代理到后端,避免浏览器跨域
+ source: "/api/user-center/:path*",
+ destination: `${backendUrl}/api/user-center/:path*`,
+ },
+ {
+ // OAuth 跳转入口(/oauth/render/github),走 rewrite 让前端不感知后端端口
+ // 后端返回 302 到 GitHub 授权页,Next.js 透传 302 给浏览器完成跳转
+ source: "/oauth/:path*",
+ destination: `${backendUrl}/oauth/:path*`,
+ },
];
},
images: {