diff --git a/app/components/SignInButton.tsx b/app/components/SignInButton.tsx index 41cd4e3..0d03003 100644 --- a/app/components/SignInButton.tsx +++ b/app/components/SignInButton.tsx @@ -7,12 +7,13 @@ interface SignInButtonProps { } export function SignInButton({ className }: SignInButtonProps) { - // 直接跳转到后端 GitHub OAuth 授权入口(NEXT_PUBLIC_BACKEND_URL) - // 后端完成授权后带着 token 重定向回前端首页 /#token=xxx(fragment,不会出现在服务器日志中) + // 同源跳到 /oauth/render/github,经 next.config.mjs 的 rewrite 代理到后端。 + // 好处:开发环境后端端口改来改去(8080 / 8081)都不用改前端;302 由 Next.js 透传给浏览器, + // 最终由浏览器跳到 GitHub 授权页。 + // 注意:GitHub OAuth app 注册的 callback URL 决定最终返回的前端端口 + // (当前注册为 localhost:3000/api/auth/callback/github),换端口跑本地时需在 GitHub OAuth app 里补一个。 const handleSignIn = () => { - const backendUrl = - process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:8080"; - window.location.href = `${backendUrl}/oauth/render/github`; + window.location.href = "/oauth/render/github"; }; return ( diff --git a/app/components/UserMenu.tsx b/app/components/UserMenu.tsx index 6bce75f..8eabd04 100644 --- a/app/components/UserMenu.tsx +++ b/app/components/UserMenu.tsx @@ -1,5 +1,6 @@ "use client"; +import Link from "next/link"; import { Avatar, AvatarFallback, @@ -36,24 +37,42 @@ export function UserMenu({ user, provider, logout }: UserMenuProps) { -
-
-

+ {/* + 下拉面板用显式的 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 +

+
+ +
+
+