Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions app/components/SignInButton.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down
31 changes: 25 additions & 6 deletions app/components/UserMenu.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
"use client";

import Link from "next/link";
import {
Avatar,
AvatarFallback,
Expand Down Expand Up @@ -36,32 +37,50 @@ export function UserMenu({ user, provider, logout }: UserMenuProps) {
</Avatar>
</summary>

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

{/* 设置入口:登录用户均可见,指向 /settings 偏好页 */}
<Link
href="/settings"
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"
data-umami-event="user_menu_settings_click"
>
设置
</Link>

{provider === "github" ? (
<a
href="https://github.com/logout"
target="_blank"
rel="noreferrer"
className="block px-4 py-2 text-sm text-foreground transition hover:bg-muted"
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"
>
切换 GitHub 账号(将在新标签页登出 GitHub)
</a>
) : null}

<button
onClick={() => void logout()}
className="w-full px-4 py-2 text-left text-sm text-foreground transition hover:bg-muted"
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"
>
Sign out
</button>
Expand Down
284 changes: 284 additions & 0 deletions app/settings/SettingsForm.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="animate-pulse flex flex-col gap-2">
<div className="h-4 bg-neutral-200 dark:bg-neutral-700 rounded w-24" />
<div className="h-10 bg-neutral-100 dark:bg-neutral-800 rounded w-full" />
</div>
);
}

export function SettingsForm() {
const { status } = useAuth();
const { setTheme } = useTheme();
const router = useRouter();

const [prefs, setPrefs] = useState<UserPreferences>(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<ReturnType<typeof setTimeout> | 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;
}

Comment on lines +63 to +73
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);
}
Comment on lines +106 to +112

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 (
<div className="flex flex-col gap-8">
<SkeletonRow />
<SkeletonRow />
<SkeletonRow />
</div>
);
}

// 未认证时页面已重定向,此处不需要渲染
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 (
<div className="flex flex-col gap-10">
{/* Toast 提示 */}
{toast && (
<div
className={`border px-4 py-3 font-mono text-sm ${
toast.type === "success"
? "border-[var(--foreground)] bg-[var(--foreground)] text-[var(--background)]"
: "border-red-500 text-red-600 dark:text-red-400"
}`}
>
{toast.msg}
</div>
)}

{/* 主题设置 */}
<section>
<label className="block font-serif font-bold text-lg mb-3 uppercase tracking-wide">
主题
</label>
<div className="flex gap-0 border border-[var(--foreground)]">
{themeOptions.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => setPrefs((p) => ({ ...p, theme: value }))}
className={`flex-1 py-2 px-4 font-mono text-sm uppercase transition-colors ${
prefs.theme === value
? "bg-[var(--foreground)] text-[var(--background)]"
: "bg-transparent text-[var(--foreground)] hover:bg-neutral-100 dark:hover:bg-neutral-800"
}`}
>
{label}
</button>
))}
</div>
</section>

{/* 语言设置 */}
<section>
<label className="block font-serif font-bold text-lg mb-3 uppercase tracking-wide">
语言
</label>
<div className="flex gap-0 border border-[var(--foreground)]">
{langOptions.map(({ value, label }) => (
<button
key={value}
type="button"
onClick={() => setPrefs((p) => ({ ...p, language: value }))}
className={`flex-1 py-2 px-4 font-mono text-sm uppercase transition-colors ${
prefs.language === value
? "bg-[var(--foreground)] text-[var(--background)]"
: "bg-transparent text-[var(--foreground)] hover:bg-neutral-100 dark:hover:bg-neutral-800"
}`}
>
{label}
</button>
))}
</div>
</section>

{/* AI 默认提供商 */}
<section>
<label
htmlFor="ai-provider"
className="block font-serif font-bold text-lg mb-3 uppercase tracking-wide"
>
AI 默认提供商
</label>
<select
id="ai-provider"
value={prefs.aiDefaultProvider}
onChange={(e) =>
setPrefs((p) => ({
...p,
aiDefaultProvider: e.target
.value as UserPreferences["aiDefaultProvider"],
}))
}
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)]"
>
{aiOptions.map(({ value, label }) => (
<option key={value} value={value}>
{label}
</option>
))}
</select>
</section>

{/* 提交按钮 */}
<div className="border-t border-neutral-200 dark:border-neutral-700 pt-6">
<button
type="button"
onClick={handleSave}
disabled={saving}
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"
>
{saving ? "保存中..." : "保存设置"}
</button>
</div>
</div>
);
}
28 changes: 28 additions & 0 deletions app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<>
<Header />
<main className="min-h-screen pt-32 pb-16 newsprint-texture">
<div className="container mx-auto px-6 max-w-2xl">
<div className="mb-10 border-b-4 border-[var(--foreground)] pb-4">
<h1 className="text-5xl font-serif font-black uppercase text-[var(--foreground)]">
Settings
</h1>
<p className="font-mono text-sm uppercase tracking-widest mt-3 text-neutral-500">
User Preferences — Customize your experience
</p>
</div>
<SettingsForm />
</div>
</main>
<Footer />
</>
);
}
Loading
Loading