Skip to content

Commit ed1817a

Browse files
authored
feat(settings): /settings 用户偏好编辑页 (#278)
* feat: 添加 /api/user-center/* 代理 rewrite 到后端 * feat: 添加 /settings 路由页(Server Component,含 Newspaper 风格布局) * feat: 实现 SettingsForm 客户端组件(主题/语言/AI提供商偏好 + ThemeProvider 同步) * fix(settings): header 名用 satoken(Sa-Token 约定)而不是 x-satoken Sa-Token 后端配置 sa-token.token-name=satoken,从 HTTP header 中读取的是裸 satoken 字段;之前写成 x-satoken 是 Next.js /api/analytics 内部 resolveUserId 的约定,两者不通用。调后端 /api/user-center/* 必须走 Sa-Token 本尊的 header 名。 * chore(settings): CR - 删 server 端死代码 / loading 结束 / 同步 theme / timer 清理 Copilot CR #278: - page.tsx 删除 getServerUser 死代码:token 存 localStorage 服务端读不到,注释误导 - SettingsForm: token 缺失时 setLoading(false) 结束骨架屏 + 提示 + 跳登录页 - SettingsForm: 加载到偏好后立刻 setTheme,让已保存 theme 与当前主题一致 - SettingsForm: 用 useRef 存 toast timer,新 toast/卸载时 clearTimeout,避免 setState on unmounted - SettingsForm: handleSave 的 token 缺失分支也给 toast + 跳登录,与加载逻辑一致 * chore(settings): CR - 移除服务端读 cookie 的死代码 接前 commit:page.tsx 里 getServerUser 从 cookie 读 satoken,但本项目 token 存 localStorage,服务端拿不到,user 变量被 void 掉也没用。直接删死代码,页面纯 Server Component 壳,登录态由 SettingsForm 的 useAuth 处理。 * feat(settings): UserMenu 加 /settings 入口 之前只做了 /settings 页面本身,没任何入口,用户只能手打 URL 才能访问。 在登录用户右上角 Avatar 下拉菜单里加一条「设置」,指向 /settings。 位置:账号信息下方、GitHub 切换/登出之上,符合常见产品 pattern。 * fix(UserMenu): 下拉面板改用显式颜色,修复看不清的问题 之前用 bg-popover / text-foreground / bg-muted 等语义色 token, 在当前主题下 popover 色与 background 几乎同色(白-近白 / 黑-近黑), 面板看起来像透明的悬浮,文字勉强可辨。 改为显式 neutral 色阶: - 容器:bg-white dark:bg-neutral-900 - 边框:border-neutral-200 dark:border-neutral-700 - 账号信息区:bg-neutral-50 dark:bg-neutral-800 做背景分层 - 文字:text-neutral-900 dark:text-neutral-100 - hover:bg-neutral-100 dark:bg-neutral-800 - 登出按钮加 border-t 与上面其它选项分隔 阴影从 shadow-lg 升到 shadow-xl 让浮层从下方内容里跳出来。 * fix(login): SignInButton 走同源 rewrite 避免 hardcode 后端端口 SignInButton 之前 fallback 到 http://localhost:8080,在后端跑 8081 的本地 环境就直接打不通。参考 /analytics、/auth、/api/user-center 的 rewrite pattern, 改用同源 /oauth/render/github,next.config.mjs 补一条 /oauth/:path* → BACKEND_URL。 前端不再关心后端端口,换端口时只改 .env 里 BACKEND_URL 即可。 仍然存在的限制(不在本 PR 范围):GitHub OAuth app 注册的 callback URL 是 localhost:3000/api/auth/callback/github,前端换端口跑(如本机 3010)时需要 到 GitHub OAuth app 里补一条 callback URL,否则授权回来依旧 404。
1 parent 62746a7 commit ed1817a

5 files changed

Lines changed: 354 additions & 11 deletions

File tree

app/components/SignInButton.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@ interface SignInButtonProps {
77
}
88

99
export function SignInButton({ className }: SignInButtonProps) {
10-
// 直接跳转到后端 GitHub OAuth 授权入口(NEXT_PUBLIC_BACKEND_URL)
11-
// 后端完成授权后带着 token 重定向回前端首页 /#token=xxx(fragment,不会出现在服务器日志中)
10+
// 同源跳到 /oauth/render/github,经 next.config.mjs 的 rewrite 代理到后端。
11+
// 好处:开发环境后端端口改来改去(8080 / 8081)都不用改前端;302 由 Next.js 透传给浏览器,
12+
// 最终由浏览器跳到 GitHub 授权页。
13+
// 注意:GitHub OAuth app 注册的 callback URL 决定最终返回的前端端口
14+
// (当前注册为 localhost:3000/api/auth/callback/github),换端口跑本地时需在 GitHub OAuth app 里补一个。
1215
const handleSignIn = () => {
13-
const backendUrl =
14-
process.env.NEXT_PUBLIC_BACKEND_URL ?? "http://localhost:8080";
15-
window.location.href = `${backendUrl}/oauth/render/github`;
16+
window.location.href = "/oauth/render/github";
1617
};
1718

1819
return (

app/components/UserMenu.tsx

Lines changed: 25 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
"use client";
22

3+
import Link from "next/link";
34
import {
45
Avatar,
56
AvatarFallback,
@@ -36,32 +37,50 @@ export function UserMenu({ user, provider, logout }: UserMenuProps) {
3637
</Avatar>
3738
</summary>
3839

39-
<div className="absolute right-0 mt-2 w-60 overflow-hidden rounded-md border border-border bg-popover shadow-lg z-50">
40-
<div className="border-b border-border bg-muted/40 px-4 py-3">
41-
<p className="text-sm font-medium text-foreground">
40+
{/*
41+
下拉面板用显式的 bg-white / dark:bg-neutral-900 避免依赖 bg-popover
42+
CSS 变量(原色值在某些主题下与 background 几乎同色导致看不清)。
43+
每一项都显式 text-neutral-900 / dark:text-neutral-100 确保文字可读。
44+
*/}
45+
<div className="absolute right-0 mt-2 w-60 overflow-hidden rounded-md border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-900 shadow-xl z-50">
46+
{/* 账号信息区 */}
47+
<div className="border-b border-neutral-200 dark:border-neutral-700 bg-neutral-50 dark:bg-neutral-800 px-4 py-3">
48+
<p className="text-sm font-medium text-neutral-900 dark:text-neutral-100">
4249
{user.name ?? "Signed in"}
4350
</p>
4451
{user.email ? (
45-
<p className="text-xs text-muted-foreground" title={user.email}>
52+
<p
53+
className="text-xs text-neutral-500 dark:text-neutral-400"
54+
title={user.email}
55+
>
4656
{user.email}
4757
</p>
4858
) : null}
4959
</div>
5060

61+
{/* 设置入口:登录用户均可见,指向 /settings 偏好页 */}
62+
<Link
63+
href="/settings"
64+
className="block px-4 py-2 text-sm text-neutral-900 dark:text-neutral-100 transition hover:bg-neutral-100 dark:hover:bg-neutral-800"
65+
data-umami-event="user_menu_settings_click"
66+
>
67+
设置
68+
</Link>
69+
5170
{provider === "github" ? (
5271
<a
5372
href="https://github.com/logout"
5473
target="_blank"
5574
rel="noreferrer"
56-
className="block px-4 py-2 text-sm text-foreground transition hover:bg-muted"
75+
className="block px-4 py-2 text-sm text-neutral-900 dark:text-neutral-100 transition hover:bg-neutral-100 dark:hover:bg-neutral-800"
5776
>
5877
切换 GitHub 账号(将在新标签页登出 GitHub)
5978
</a>
6079
) : null}
6180

6281
<button
6382
onClick={() => void logout()}
64-
className="w-full px-4 py-2 text-left text-sm text-foreground transition hover:bg-muted"
83+
className="w-full px-4 py-2 text-left text-sm text-neutral-900 dark:text-neutral-100 transition hover:bg-neutral-100 dark:hover:bg-neutral-800 border-t border-neutral-200 dark:border-neutral-700"
6584
>
6685
Sign out
6786
</button>

app/settings/SettingsForm.tsx

Lines changed: 284 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,284 @@
1+
"use client";
2+
3+
// 用户偏好设置表单(Client Component)
4+
// 负责:拉取偏好数据、渲染编辑 UI、提交保存、同步 ThemeProvider
5+
6+
import { useEffect, useRef, useState } from "react";
7+
import { useRouter } from "next/navigation";
8+
import { useAuth } from "@/lib/use-auth";
9+
import { useTheme } from "@/app/components/ThemeProvider";
10+
11+
// 与后端 preferences 字段一一对应
12+
interface UserPreferences {
13+
theme: "light" | "dark" | "system";
14+
language: "zh" | "en";
15+
aiDefaultProvider: "intern" | "openai" | "gemini";
16+
}
17+
18+
const DEFAULT_PREFS: UserPreferences = {
19+
theme: "system",
20+
language: "zh",
21+
aiDefaultProvider: "intern",
22+
};
23+
24+
// 从 localStorage 读取 satoken
25+
function getToken(): string | null {
26+
if (typeof window === "undefined") return null;
27+
return localStorage.getItem("satoken");
28+
}
29+
30+
// 骨架屏占位
31+
function SkeletonRow() {
32+
return (
33+
<div className="animate-pulse flex flex-col gap-2">
34+
<div className="h-4 bg-neutral-200 dark:bg-neutral-700 rounded w-24" />
35+
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded w-full" />
36+
</div>
37+
);
38+
}
39+
40+
export function SettingsForm() {
41+
const { status } = useAuth();
42+
const { setTheme } = useTheme();
43+
const router = useRouter();
44+
45+
const [prefs, setPrefs] = useState<UserPreferences>(DEFAULT_PREFS);
46+
const [loading, setLoading] = useState(true);
47+
const [saving, setSaving] = useState(false);
48+
const [toast, setToast] = useState<{
49+
type: "success" | "error";
50+
msg: string;
51+
} | null>(null);
52+
// toast 定时器 ref:新 toast / 卸载时清掉旧 timer,避免 setState on unmounted
53+
const toastTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
54+
55+
// 未登录时重定向
56+
useEffect(() => {
57+
if (status === "unauthenticated") {
58+
router.replace("/login?redirect=/settings");
59+
}
60+
}, [status, router]);
61+
62+
// 拉取偏好数据
63+
useEffect(() => {
64+
if (status !== "authenticated") return;
65+
const token = getToken();
66+
// token 缺失时立刻结束 loading 并提示 + 跳转,否则页面会卡在骨架屏
67+
if (!token) {
68+
setLoading(false);
69+
showToast("error", "登录态丢失,请重新登录");
70+
router.replace("/login?redirect=/settings");
71+
return;
72+
}
73+
74+
fetch("/api/user-center/preferences", {
75+
headers: { satoken: token },
76+
})
77+
.then((res) => {
78+
if (!res.ok) throw new Error("获取偏好失败");
79+
return res.json();
80+
})
81+
.then((body) => {
82+
if (body?.success && body?.data) {
83+
const merged = { ...DEFAULT_PREFS, ...body.data };
84+
setPrefs(merged);
85+
// 加载出来的 theme 立即同步到 ThemeProvider,避免"已保存设置与当前主题不一致"
86+
setTheme(merged.theme);
87+
}
88+
})
89+
.catch(() => {
90+
showToast("error", "无法加载偏好设置,已显示默认值");
91+
})
92+
.finally(() => setLoading(false));
93+
// setTheme 是 ThemeProvider 提供的稳定引用,router 同理;这里依赖 status 变化触发
94+
// eslint-disable-next-line react-hooks/exhaustive-deps
95+
}, [status]);
96+
97+
// 组件卸载时清掉残留 toast timer
98+
useEffect(() => {
99+
return () => {
100+
if (toastTimerRef.current) {
101+
clearTimeout(toastTimerRef.current);
102+
}
103+
};
104+
}, []);
105+
106+
function showToast(type: "success" | "error", msg: string) {
107+
if (toastTimerRef.current) {
108+
clearTimeout(toastTimerRef.current);
109+
}
110+
setToast({ type, msg });
111+
toastTimerRef.current = setTimeout(() => setToast(null), 3000);
112+
}
113+
114+
async function handleSave() {
115+
const token = getToken();
116+
// token 缺失时给明确反馈并跳转登录,而不是静默返回让用户摸不着头脑
117+
if (!token) {
118+
showToast("error", "登录态丢失,请重新登录后再保存");
119+
router.replace("/login?redirect=/settings");
120+
return;
121+
}
122+
setSaving(true);
123+
try {
124+
const res = await fetch("/api/user-center/preferences", {
125+
method: "PATCH",
126+
headers: {
127+
"Content-Type": "application/json",
128+
satoken: token,
129+
},
130+
body: JSON.stringify(prefs),
131+
});
132+
if (!res.ok) throw new Error("保存失败");
133+
const body = await res.json();
134+
if (body?.data) {
135+
const merged: UserPreferences = { ...DEFAULT_PREFS, ...body.data };
136+
setPrefs(merged);
137+
// 主题变化立即同步到 ThemeProvider(同步写 localStorage)
138+
setTheme(merged.theme);
139+
}
140+
showToast("success", "偏好设置已保存");
141+
} catch {
142+
showToast("error", "保存失败,请稍后重试");
143+
} finally {
144+
setSaving(false);
145+
}
146+
}
147+
148+
// 加载中或未登录均显示骨架屏,避免闪烁
149+
if (status === "loading" || loading) {
150+
return (
151+
<div className="flex flex-col gap-8">
152+
<SkeletonRow />
153+
<SkeletonRow />
154+
<SkeletonRow />
155+
</div>
156+
);
157+
}
158+
159+
// 未认证时页面已重定向,此处不需要渲染
160+
if (status === "unauthenticated") return null;
161+
162+
const themeOptions: { value: UserPreferences["theme"]; label: string }[] = [
163+
{ value: "light", label: "浅色" },
164+
{ value: "dark", label: "深色" },
165+
{ value: "system", label: "跟随系统" },
166+
];
167+
168+
const langOptions: { value: UserPreferences["language"]; label: string }[] = [
169+
{ value: "zh", label: "中文" },
170+
{ value: "en", label: "English" },
171+
];
172+
173+
const aiOptions: {
174+
value: UserPreferences["aiDefaultProvider"];
175+
label: string;
176+
}[] = [
177+
{ value: "intern", label: "书生(InternLM)" },
178+
{ value: "openai", label: "OpenAI" },
179+
{ value: "gemini", label: "Gemini" },
180+
];
181+
182+
return (
183+
<div className="flex flex-col gap-10">
184+
{/* Toast 提示 */}
185+
{toast && (
186+
<div
187+
className={`border px-4 py-3 font-mono text-sm ${
188+
toast.type === "success"
189+
? "border-[var(--foreground)] bg-[var(--foreground)] text-[var(--background)]"
190+
: "border-red-500 text-red-600 dark:text-red-400"
191+
}`}
192+
>
193+
{toast.msg}
194+
</div>
195+
)}
196+
197+
{/* 主题设置 */}
198+
<section>
199+
<label className="block font-serif font-bold text-lg mb-3 uppercase tracking-wide">
200+
主题
201+
</label>
202+
<div className="flex gap-0 border border-[var(--foreground)]">
203+
{themeOptions.map(({ value, label }) => (
204+
<button
205+
key={value}
206+
type="button"
207+
onClick={() => setPrefs((p) => ({ ...p, theme: value }))}
208+
className={`flex-1 py-2 px-4 font-mono text-sm uppercase transition-colors ${
209+
prefs.theme === value
210+
? "bg-[var(--foreground)] text-[var(--background)]"
211+
: "bg-transparent text-[var(--foreground)] hover:bg-neutral-100 dark:hover:bg-neutral-800"
212+
}`}
213+
>
214+
{label}
215+
</button>
216+
))}
217+
</div>
218+
</section>
219+
220+
{/* 语言设置 */}
221+
<section>
222+
<label className="block font-serif font-bold text-lg mb-3 uppercase tracking-wide">
223+
语言
224+
</label>
225+
<div className="flex gap-0 border border-[var(--foreground)]">
226+
{langOptions.map(({ value, label }) => (
227+
<button
228+
key={value}
229+
type="button"
230+
onClick={() => setPrefs((p) => ({ ...p, language: value }))}
231+
className={`flex-1 py-2 px-4 font-mono text-sm uppercase transition-colors ${
232+
prefs.language === value
233+
? "bg-[var(--foreground)] text-[var(--background)]"
234+
: "bg-transparent text-[var(--foreground)] hover:bg-neutral-100 dark:hover:bg-neutral-800"
235+
}`}
236+
>
237+
{label}
238+
</button>
239+
))}
240+
</div>
241+
</section>
242+
243+
{/* AI 默认提供商 */}
244+
<section>
245+
<label
246+
htmlFor="ai-provider"
247+
className="block font-serif font-bold text-lg mb-3 uppercase tracking-wide"
248+
>
249+
AI 默认提供商
250+
</label>
251+
<select
252+
id="ai-provider"
253+
value={prefs.aiDefaultProvider}
254+
onChange={(e) =>
255+
setPrefs((p) => ({
256+
...p,
257+
aiDefaultProvider: e.target
258+
.value as UserPreferences["aiDefaultProvider"],
259+
}))
260+
}
261+
className="w-full border border-[var(--foreground)] bg-[var(--background)] text-[var(--foreground)] font-mono text-sm px-4 py-2 appearance-none focus:outline-none focus:ring-2 focus:ring-[var(--foreground)]"
262+
>
263+
{aiOptions.map(({ value, label }) => (
264+
<option key={value} value={value}>
265+
{label}
266+
</option>
267+
))}
268+
</select>
269+
</section>
270+
271+
{/* 提交按钮 */}
272+
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-6">
273+
<button
274+
type="button"
275+
onClick={handleSave}
276+
disabled={saving}
277+
className="font-mono text-sm uppercase tracking-widest px-8 py-3 border-2 border-[var(--foreground)] bg-[var(--foreground)] text-[var(--background)] hover:bg-transparent hover:text-[var(--foreground)] transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
278+
>
279+
{saving ? "保存中..." : "保存设置"}
280+
</button>
281+
</div>
282+
</div>
283+
);
284+
}

app/settings/page.tsx

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
// 用户偏好设置页(Server Component)
2+
// 登录态由客户端 SettingsForm 内部的 useAuth 处理:token 存在 localStorage,服务端无法读取,
3+
// 所以这里不做服务端鉴权,仅负责渲染页面壳。未登录 → 客户端 router.replace 到 /login?redirect=/settings。
4+
import { Header } from "@/app/components/Header";
5+
import { Footer } from "@/app/components/Footer";
6+
import { SettingsForm } from "./SettingsForm";
7+
8+
export default function SettingsPage() {
9+
return (
10+
<>
11+
<Header />
12+
<main className="min-h-screen pt-32 pb-16 newsprint-texture">
13+
<div className="container mx-auto px-6 max-w-2xl">
14+
<div className="mb-10 border-b-4 border-[var(--foreground)] pb-4">
15+
<h1 className="text-5xl font-serif font-black uppercase text-[var(--foreground)]">
16+
Settings
17+
</h1>
18+
<p className="font-mono text-sm uppercase tracking-widest mt-3 text-neutral-500">
19+
User Preferences — Customize your experience
20+
</p>
21+
</div>
22+
<SettingsForm />
23+
</div>
24+
</main>
25+
<Footer />
26+
</>
27+
);
28+
}

0 commit comments

Comments
 (0)