refactor: improve forum, profile and settings page UI

Streamline profile and settings pages, refine forum listing and post pages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-27 16:45:40 +08:00
parent 38865b0f7d
commit 5d42a0573a
5 changed files with 293 additions and 553 deletions
+17 -37
View File
@@ -33,40 +33,22 @@ function getCategoryIcon(name: string) {
return CATEGORY_ICONS.default; return CATEGORY_ICONS.default;
} }
function getCategoryGradient(name: string) {
if (name.includes("公告") || name.includes("通知"))
return "from-rose-500/10 to-rose-50";
if (name.includes("学习") || name.includes("讨论") || name.includes("课程"))
return "from-blue-500/10 to-blue-50";
if (name.includes("反馈") || name.includes("使用"))
return "from-amber-500/10 to-amber-50";
return "from-slate-500/10 to-slate-50";
}
function getCategoryAccent(name: string) { function getCategoryAccent(name: string) {
if (name.includes("公告") || name.includes("通知")) return "text-rose-600"; if (name.includes("公告") || name.includes("通知")) return "text-rose-600 dark:text-rose-400";
if (name.includes("学习") || name.includes("讨论") || name.includes("课程")) return "text-blue-600"; if (name.includes("学习") || name.includes("讨论") || name.includes("课程")) return "text-blue-600 dark:text-blue-400";
if (name.includes("反馈") || name.includes("使用")) return "text-amber-600"; if (name.includes("反馈") || name.includes("使用")) return "text-amber-600 dark:text-amber-400";
return "text-slate-600"; return "text-slate-600 dark:text-slate-400";
} }
function formatRelativeTime(dateStr: string) { function getCategoryBorder(name: string) {
const date = new Date( if (name.includes("公告") || name.includes("通知")) return "border-l-rose-500";
new Date(dateStr).getTime() + 8 * 60 * 60 * 1000 if (name.includes("学习") || name.includes("讨论") || name.includes("课程")) return "border-l-blue-500";
); if (name.includes("反馈") || name.includes("使用")) return "border-l-amber-500";
const now = new Date(); return "border-l-slate-500";
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 1) return "刚刚";
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
if (diffHours < 24) return `${diffHours} 小时前`;
if (diffDays < 7) return `${diffDays} 天前`;
return date.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
} }
import { format } from "date-fns";
export default function ForumHomePage() { export default function ForumHomePage() {
const [categories, setCategories] = useState<ForumCategory[]>([]); const [categories, setCategories] = useState<ForumCategory[]>([]);
const [categoryPosts, setCategoryPosts] = useState< const [categoryPosts, setCategoryPosts] = useState<
@@ -134,12 +116,10 @@ export default function ForumHomePage() {
return ( return (
<div <div
key={category.id} key={category.id}
className={`bg-gradient-to-r ${getCategoryGradient( className={`rounded-xl border border-border overflow-hidden border-l-4 ${getCategoryBorder(category.name)}`}
category.name
)} rounded-xl border border-border/40 overflow-hidden`}
> >
{/* Category header */} {/* Category header */}
<div className="px-5 py-3.5 flex items-center justify-between border-b border-border/20"> <div className="px-5 py-3.5 flex items-center justify-between border-b border-border">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className={getCategoryAccent(category.name)}> <span className={getCategoryAccent(category.name)}>
{getCategoryIcon(category.name)} {getCategoryIcon(category.name)}
@@ -170,15 +150,15 @@ export default function ForumHomePage() {
</div> </div>
{/* Posts list */} {/* Posts list */}
<div className="divide-y divide-border/20"> <div className="divide-y divide-border">
{posts.length > 0 ? ( {posts.length > 0 ? (
posts.map((post) => ( posts.map((post) => (
<Link <Link
key={post.id} key={post.id}
href={`/forum/post/${post.id}`} href={`/forum/post/${post.id}`}
className="flex items-start gap-3 px-5 py-3.5 hover:bg-white/40 transition-colors group" className="flex items-start gap-3 px-5 py-3.5 hover:bg-accent transition-colors group"
> >
<div className="mt-0.5 w-8 h-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0"> <div className="mt-0.5 w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4 text-muted-foreground" /> <User className="w-4 h-4 text-muted-foreground" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -189,7 +169,7 @@ export default function ForumHomePage() {
<span>{post.author_name}</span> <span>{post.author_name}</span>
<span className="inline-flex items-center gap-0.5"> <span className="inline-flex items-center gap-0.5">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{formatRelativeTime(post.created_at)} {format(new Date(post.created_at), "yyyy/M/d HH:mm")}
</span> </span>
<span className="inline-flex items-center gap-0.5"> <span className="inline-flex items-center gap-0.5">
<MessageCircle className="w-3 h-3" /> <MessageCircle className="w-3 h-3" />
+124 -230
View File
@@ -7,16 +7,10 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
import MobileNav from "@/components/layout/mobile-nav"; import MobileNav from "@/components/layout/mobile-nav";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert"; import {
import {
Loader2, Loader2,
User as UserIcon,
Mail,
Calendar,
CheckCircle, CheckCircle,
Edit2, Edit2,
Save, Save,
@@ -69,10 +63,7 @@ export default function ProfilePage() {
router.push("/login"); router.push("/login");
return; return;
} }
if (isAuthenticated) loadUserData();
if (isAuthenticated) {
loadUserData();
}
}, [isAuthenticated, authLoading, router]); }, [isAuthenticated, authLoading, router]);
useEffect(() => { useEffect(() => {
@@ -86,12 +77,8 @@ export default function ProfilePage() {
try { try {
setIsLoading(true); setIsLoading(true);
setError(null); setError(null);
// 加载用户信息
const userData = await authAPI.getCurrentUser(); const userData = await authAPI.getCurrentUser();
setUser(userData); setUser(userData);
// 加载统计数据
try { try {
const stats = await analyticsAPI.getStatistics(); const stats = await analyticsAPI.getStatistics();
setStatistics({ setStatistics({
@@ -102,7 +89,6 @@ export default function ProfilePage() {
}); });
} catch (err) { } catch (err) {
console.warn("加载统计数据失败:", err); console.warn("加载统计数据失败:", err);
// 统计数据加载失败不影响页面显示
} }
} catch (err) { } catch (err) {
console.error("加载用户信息失败:", err); console.error("加载用户信息失败:", err);
@@ -117,17 +103,13 @@ export default function ProfilePage() {
setIsSaving(true); setIsSaving(true);
setError(null); setError(null);
setSuccess(null); setSuccess(null);
const updatedUser = await authAPI.updateUserInfo({ const updatedUser = await authAPI.updateUserInfo({
email: data.email, email: data.email,
full_name: data.full_name || undefined, full_name: data.full_name || undefined,
}); });
setUser(updatedUser); setUser(updatedUser);
setIsEditing(false); setIsEditing(false);
setSuccess("个人信息更新成功"); setSuccess("个人信息更新成功");
// 3秒后清除成功消息
setTimeout(() => setSuccess(null), 3000); setTimeout(() => setSuccess(null), 3000);
} catch (err: any) { } catch (err: any) {
console.error("更新用户信息失败:", err); console.error("更新用户信息失败:", err);
@@ -155,235 +137,147 @@ export default function ProfilePage() {
); );
} }
if (!isAuthenticated || !user) { if (!isAuthenticated || !user) return null;
return null;
}
const initials = (user.full_name || user.username || "U").charAt(0).toUpperCase(); const initials = (user.full_name || user.username || "U").charAt(0).toUpperCase();
return ( return (
<div className="min-h-screen bg-app pb-16 lg:pb-0"> <div className="min-h-screen bg-app pb-16 lg:pb-0">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 错误提示 */}
{/* 状态提示 */}
{error && ( {error && (
<Alert variant="destructive" className="mb-6"> <div className="mb-6 p-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm">{error}</div>
<AlertDescription>{error}</AlertDescription>
</Alert>
)} )}
{/* 成功提示 */}
{success && ( {success && (
<Alert className="mb-6 border-green-500 bg-green-50 dark:bg-green-950"> <div className="mb-6 p-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" /> <CheckCircle className="w-4 h-4" />{success}
<AlertDescription className="text-green-800 dark:text-green-200"> </div>
{success}
</AlertDescription>
</Alert>
)} )}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="max-w-2xl space-y-8">
{/* 左侧:用户头像基本信息 */} {/* 头像基本信息 */}
<div className="lg:col-span-2 space-y-6"> <section>
{/* 用户头像卡片 */} <div className="flex items-center justify-between mb-4 pb-3 border-b">
<Card> <h2 className="text-base font-semibold"></h2>
<CardHeader> {!isEditing && (
<CardTitle></CardTitle> <button
<CardDescription></CardDescription> onClick={() => setIsEditing(true)}
</CardHeader> className="inline-flex items-center gap-1.5 text-sm text-muted-foreground hover:text-foreground"
<CardContent> >
<div className="flex items-center space-x-6"> <Edit2 className="w-3.5 h-3.5" />
<div className="w-24 h-24 bg-gradient-to-r from-blue-600 to-purple-600 rounded-full flex items-center justify-center text-white text-3xl font-bold shadow-lg"> </button>
{initials} )}
</div> </div>
<div className="flex-1">
<p className="text-sm text-muted-foreground mb-2"> {/* 头像 */}
使 <div className="flex items-center gap-5 mb-6">
</p> <div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-xl font-bold">
<p className="text-xs text-muted-foreground"> {initials}
</div>
</p> <div>
</div> <p className="text-sm font-medium">{user.full_name || user.username}</p>
<p className="text-xs text-muted-foreground">{user.email}</p>
</div>
</div>
{/* 表单 */}
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div>
<Label className="text-sm text-muted-foreground"></Label>
<Input value={user.username} disabled className="mt-1 bg-muted" />
<p className="text-[11px] text-muted-foreground mt-1"></p>
</div> </div>
</CardContent> <div>
</Card> <Label className="text-sm text-muted-foreground"></Label>
<Input value={formatDate(user.created_at)} disabled className="mt-1 bg-muted" />
{/* 基本信息卡片 */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</div>
{!isEditing && (
<Button
variant="outline"
size="sm"
onClick={() => setIsEditing(true)}
>
<Edit2 className="w-4 h-4 mr-2" />
</Button>
)}
</div> </div>
</CardHeader> </div>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* 用户名(只读) */}
<div>
<Label htmlFor="username"></Label>
<Input
id="username"
value={user.username}
disabled
className="bg-muted"
/>
<p className="text-xs text-muted-foreground mt-1">
</p>
</div>
{/* 邮箱(可编辑) */} <div>
<div> <Label htmlFor="email" className="text-sm text-muted-foreground"></Label>
<Label htmlFor="email"></Label> <Input
<Input id="email"
id="email" type="email"
type="email" {...register("email")}
{...register("email")} disabled={!isEditing}
disabled={!isEditing} className={`mt-1 ${errors.email ? "border-red-500" : ""}`}
className={errors.email ? "border-red-500" : ""} />
/> {errors.email && <p className="text-xs text-red-500 mt-1">{errors.email.message}</p>}
{errors.email && ( </div>
<p className="text-xs text-red-500 mt-1">
{errors.email.message}
</p>
)}
</div>
{/* 真实姓名(可编辑) */} <div>
<div> <Label htmlFor="full_name" className="text-sm text-muted-foreground"></Label>
<Label htmlFor="full_name"></Label> <Input
<Input id="full_name"
id="full_name" {...register("full_name")}
{...register("full_name")} disabled={!isEditing}
disabled={!isEditing} placeholder="请输入您的真实姓名(可选)"
placeholder="请输入您的真实姓名(可选)" className="mt-1"
/> />
</div> </div>
{/* 注册时间(只读) */} {isEditing && (
<div> <div className="flex items-center gap-3 pt-2">
<Label htmlFor="created_at"></Label> <button
<Input type="submit"
id="created_at" disabled={isSaving}
value={formatDate(user.created_at)} className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
disabled >
className="bg-muted" {isSaving ? <><Loader2 className="w-4 h-4 animate-spin" />...</> : <><Save className="w-4 h-4" /></>}
/> </button>
</div> <button
type="button"
onClick={handleCancel}
disabled={isSaving}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg border text-sm hover:bg-muted disabled:opacity-50"
>
<X className="w-4 h-4" />
</button>
</div>
)}
</form>
</section>
{/* 账户状态(只读) */} {/* 学习统计 */}
<div> <section>
<Label htmlFor="is_active"></Label> <div className="flex items-center gap-2 mb-4 pb-3 border-b">
<div className="flex items-center space-x-2 mt-2"> <h2 className="text-base font-semibold"></h2>
<Input </div>
id="is_active" {statistics ? (
value={user.is_active ? "已激活" : "未激活"} <div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
disabled <div className="p-3 rounded-lg border text-center">
className="bg-muted" <MessageSquare className="w-4 h-4 mx-auto mb-1.5 text-primary" />
/> <p className="text-lg font-semibold">{statistics.total_sessions}</p>
{user.is_active && ( <p className="text-[11px] text-muted-foreground"></p>
<CheckCircle className="w-5 h-5 text-green-600" /> </div>
)} <div className="p-3 rounded-lg border text-center">
</div> <MessageSquare className="w-4 h-4 mx-auto mb-1.5 text-green-600" />
</div> <p className="text-lg font-semibold">{statistics.total_messages}</p>
<p className="text-[11px] text-muted-foreground"></p>
{/* 编辑模式下的按钮 */} </div>
{isEditing && ( <div className="p-3 rounded-lg border text-center">
<div className="flex items-center space-x-3 pt-4"> <Database className="w-4 h-4 mx-auto mb-1.5 text-purple-600" />
<Button type="submit" disabled={isSaving}> <p className="text-lg font-semibold">{statistics.total_documents}</p>
{isSaving ? ( <p className="text-[11px] text-muted-foreground"></p>
<> </div>
<Loader2 className="w-4 h-4 mr-2 animate-spin" /> <div className="p-3 rounded-lg border text-center">
... <FileText className="w-4 h-4 mx-auto mb-1.5 text-orange-600" />
</> <p className="text-lg font-semibold">{statistics.active_days}</p>
) : ( <p className="text-[11px] text-muted-foreground"></p>
<> </div>
<Save className="w-4 h-4 mr-2" /> </div>
) : (
</> <div className="text-center py-6">
)} <Loader2 className="w-5 h-5 animate-spin mx-auto mb-2 text-muted-foreground" />
</Button> <p className="text-sm text-muted-foreground">...</p>
<Button </div>
type="button" )}
variant="outline" </section>
onClick={handleCancel}
disabled={isSaving}
>
<X className="w-4 h-4 mr-2" />
</Button>
</div>
)}
</form>
</CardContent>
</Card>
</div>
{/* 右侧:学习统计 */}
<div className="lg:col-span-1">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{statistics ? (
<div className="space-y-4">
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex items-center space-x-3">
<MessageSquare className="w-5 h-5 text-blue-600" />
<span className="text-sm font-medium"></span>
</div>
<span className="text-lg font-bold">{statistics.total_sessions}</span>
</div>
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex items-center space-x-3">
<MessageSquare className="w-5 h-5 text-green-600" />
<span className="text-sm font-medium"></span>
</div>
<span className="text-lg font-bold">{statistics.total_messages}</span>
</div>
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex items-center space-x-3">
<Database className="w-5 h-5 text-purple-600" />
<span className="text-sm font-medium"></span>
</div>
<span className="text-lg font-bold">-</span>
</div>
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
<div className="flex items-center space-x-3">
<FileText className="w-5 h-5 text-orange-600" />
<span className="text-sm font-medium"></span>
</div>
<span className="text-lg font-bold">{statistics.total_documents}</span>
</div>
</div>
) : (
<div className="text-center py-8">
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-muted-foreground" />
<p className="text-sm text-muted-foreground">...</p>
</div>
)}
</CardContent>
</Card>
</div>
</div> </div>
</div> </div>
{/* 移动端导航 */}
<MobileNav /> <MobileNav />
</div> </div>
); );
} }
+125 -228
View File
@@ -2,7 +2,6 @@
import { useState } from "react"; import { useState } from "react";
// 禁用静态生成
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { useForm } from "react-hook-form"; import { useForm } from "react-hook-form";
@@ -10,15 +9,11 @@ import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod"; import { z } from "zod";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
import MobileNav from "@/components/layout/mobile-nav"; import MobileNav from "@/components/layout/mobile-nav";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { Alert, AlertDescription } from "@/components/ui/alert";
import { ThemeToggle } from "@/components/ui/theme-toggle"; import { ThemeToggle } from "@/components/ui/theme-toggle";
import { import {
Loader2, Loader2,
Lock,
Eye, Eye,
EyeOff, EyeOff,
CheckCircle, CheckCircle,
@@ -39,17 +34,8 @@ const passwordSchema = z.object({
type PasswordForm = z.infer<typeof passwordSchema>; type PasswordForm = z.infer<typeof passwordSchema>;
// 密码强度检查函数 function getPasswordStrength(password: string) {
function getPasswordStrength(password: string): { if (!password) return { label: "", color: "", score: 0 };
strength: "weak" | "medium" | "strong";
label: string;
color: string;
score: number;
} {
if (!password) {
return { strength: "weak", label: "", color: "", score: 0 };
}
let score = 0; let score = 0;
if (password.length >= 6) score++; if (password.length >= 6) score++;
if (password.length >= 8) score++; if (password.length >= 8) score++;
@@ -58,13 +44,9 @@ function getPasswordStrength(password: string): {
if (/[0-9]/.test(password)) score++; if (/[0-9]/.test(password)) score++;
if (/[^a-zA-Z0-9]/.test(password)) score++; if (/[^a-zA-Z0-9]/.test(password)) score++;
if (score <= 2) { if (score <= 2) return { label: "弱", color: "text-red-600", score };
return { strength: "weak", label: "", color: "text-red-600", score }; if (score <= 4) return { label: "", color: "text-yellow-600", score };
} else if (score <= 4) { return { label: "强", color: "text-green-600", score };
return { strength: "medium", label: "中", color: "text-yellow-600", score };
} else {
return { strength: "strong", label: "强", color: "text-green-600", score };
}
} }
export default function SettingsPage() { export default function SettingsPage() {
@@ -95,16 +77,12 @@ export default function SettingsPage() {
setIsSubmitting(true); setIsSubmitting(true);
setError(null); setError(null);
setSuccess(null); setSuccess(null);
await authAPI.changePassword({ await authAPI.changePassword({
old_password: data.old_password, old_password: data.old_password,
new_password: data.new_password, new_password: data.new_password,
}); });
setSuccess("密码修改成功"); setSuccess("密码修改成功");
reset(); reset();
// 3秒后清除成功消息
setTimeout(() => setSuccess(null), 3000); setTimeout(() => setSuccess(null), 3000);
} catch (err: any) { } catch (err: any) {
console.error("修改密码失败:", err); console.error("修改密码失败:", err);
@@ -129,223 +107,142 @@ export default function SettingsPage() {
return ( return (
<div className="min-h-screen bg-app pb-16 lg:pb-0"> <div className="min-h-screen bg-app pb-16 lg:pb-0">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* 错误提示 */}
{/* 状态提示 */}
{error && ( {error && (
<Alert variant="destructive" className="mb-6"> <div className="mb-6 p-3 rounded-lg bg-red-50 border border-red-200 text-red-700 text-sm">
<AlertDescription>{error}</AlertDescription> {error}
</Alert> </div>
)} )}
{/* 成功提示 */}
{success && ( {success && (
<Alert className="mb-6 border-green-500 bg-green-50 dark:bg-green-950"> <div className="mb-6 p-3 rounded-lg bg-green-50 border border-green-200 text-green-700 text-sm flex items-center gap-2">
<CheckCircle className="w-4 h-4 text-green-600" /> <CheckCircle className="w-4 h-4" />
<AlertDescription className="text-green-800 dark:text-green-200"> {success}
{success} </div>
</AlertDescription>
</Alert>
)} )}
<div className="space-y-6"> <div className="max-w-2xl space-y-8">
{/* 账户安全 */} {/* 账户安全 */}
<Card> <section>
<CardHeader> <div className="flex items-center gap-2 mb-4 pb-3 border-b">
<div className="flex items-center space-x-2"> <Shield className="w-4 h-4 text-primary" />
<Shield className="w-5 h-5 text-blue-600" /> <h2 className="text-base font-semibold"></h2>
<CardTitle></CardTitle> </div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
<div>
<Label htmlFor="old_password" className="text-sm text-muted-foreground"></Label>
<div className="relative mt-1">
<Input
id="old_password"
type={showOldPassword ? "text" : "password"}
{...register("old_password")}
className={errors.old_password ? "border-red-500" : ""}
placeholder="请输入当前密码"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2"
onClick={() => setShowOldPassword(!showOldPassword)}
>
{showOldPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
</button>
</div>
{errors.old_password && <p className="text-xs text-red-500 mt-1">{errors.old_password.message}</p>}
</div> </div>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
{/* 当前密码 */}
<div>
<Label htmlFor="old_password"></Label>
<div className="relative">
<Input
id="old_password"
type={showOldPassword ? "text" : "password"}
{...register("old_password")}
className={errors.old_password ? "border-red-500" : ""}
placeholder="请输入当前密码"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowOldPassword(!showOldPassword)}
>
{showOldPassword ? (
<EyeOff className="w-4 h-4 text-muted-foreground" />
) : (
<Eye className="w-4 h-4 text-muted-foreground" />
)}
</Button>
</div>
{errors.old_password && (
<p className="text-xs text-red-500 mt-1">
{errors.old_password.message}
</p>
)}
</div>
{/* 新密码 */} <div>
<div> <Label htmlFor="new_password" className="text-sm text-muted-foreground"></Label>
<Label htmlFor="new_password"></Label> <div className="relative mt-1">
<div className="relative"> <Input
<Input id="new_password"
id="new_password" type={showNewPassword ? "text" : "password"}
type={showNewPassword ? "text" : "password"} {...register("new_password")}
{...register("new_password")} className={errors.new_password ? "border-red-500" : ""}
className={errors.new_password ? "border-red-500" : ""} placeholder="请输入新密码(至少6个字符)"
placeholder="请输入新密码(至少6个字符)" />
/> <button
<Button type="button"
type="button" className="absolute right-3 top-1/2 -translate-y-1/2"
variant="ghost" onClick={() => setShowNewPassword(!showNewPassword)}
size="sm" >
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent" {showNewPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
onClick={() => setShowNewPassword(!showNewPassword)} </button>
> </div>
{showNewPassword ? ( {newPassword && (
<EyeOff className="w-4 h-4 text-muted-foreground" /> <div className="mt-2">
) : ( <div className="flex items-center gap-2 mb-1">
<Eye className="w-4 h-4 text-muted-foreground" /> <span className="text-xs text-muted-foreground"></span>
)} <span className={`text-xs font-medium ${passwordStrength.color}`}>{passwordStrength.label}</span>
</Button> </div>
</div> <div className="w-full bg-muted rounded-full h-1.5">
{newPassword && ( <div
<div className="mt-2"> className={`h-1.5 rounded-full transition-all ${
<div className="flex items-center space-x-2 mb-1"> passwordStrength.score <= 2 ? "bg-red-500 w-1/3"
<span className="text-xs text-muted-foreground"></span> : passwordStrength.score <= 4 ? "bg-yellow-500 w-2/3"
<span className={`text-xs font-medium ${passwordStrength.color}`}> : "bg-green-500 w-full"
{passwordStrength.label} }`}
</span> />
</div>
<div className="w-full bg-muted rounded-full h-2">
<div
className={`h-2 rounded-full transition-all ${
passwordStrength.strength === "weak"
? "bg-red-500 w-1/3"
: passwordStrength.strength === "medium"
? "bg-yellow-500 w-2/3"
: "bg-green-500 w-full"
}`}
/>
</div>
</div> </div>
)}
{errors.new_password && (
<p className="text-xs text-red-500 mt-1">
{errors.new_password.message}
</p>
)}
</div>
{/* 确认新密码 */}
<div>
<Label htmlFor="confirm_password"></Label>
<div className="relative">
<Input
id="confirm_password"
type={showConfirmPassword ? "text" : "password"}
{...register("confirm_password")}
className={errors.confirm_password ? "border-red-500" : ""}
placeholder="请再次输入新密码"
/>
<Button
type="button"
variant="ghost"
size="sm"
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? (
<EyeOff className="w-4 h-4 text-muted-foreground" />
) : (
<Eye className="w-4 h-4 text-muted-foreground" />
)}
</Button>
</div> </div>
{errors.confirm_password && ( )}
<p className="text-xs text-red-500 mt-1"> {errors.new_password && <p className="text-xs text-red-500 mt-1">{errors.new_password.message}</p>}
{errors.confirm_password.message} </div>
</p>
<div>
<Label htmlFor="confirm_password" className="text-sm text-muted-foreground"></Label>
<div className="relative mt-1">
<Input
id="confirm_password"
type={showConfirmPassword ? "text" : "password"}
{...register("confirm_password")}
className={errors.confirm_password ? "border-red-500" : ""}
placeholder="请再次输入新密码"
/>
<button
type="button"
className="absolute right-3 top-1/2 -translate-y-1/2"
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
>
{showConfirmPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
</button>
</div>
{errors.confirm_password && <p className="text-xs text-red-500 mt-1">{errors.confirm_password.message}</p>}
</div>
<div className="pt-2">
<button
type="submit"
disabled={isSubmitting}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-primary text-primary-foreground text-sm font-medium hover:bg-primary/90 disabled:opacity-50"
>
{isSubmitting ? (
<><Loader2 className="w-4 h-4 animate-spin" />...</>
) : (
<><Key className="w-4 h-4" /></>
)} )}
</div> </button>
</div>
{/* 密码要求提示 */} </form>
<div className="p-4 bg-muted/50 rounded-lg"> </section>
<p className="text-sm font-medium mb-2"></p>
<ul className="text-xs text-muted-foreground space-y-1">
<li> 6</li>
<li> </li>
<li> 使</li>
</ul>
</div>
{/* 提交按钮 */}
<div className="pt-4">
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
...
</>
) : (
<>
<Key className="w-4 h-4 mr-2" />
</>
)}
</Button>
</div>
</form>
</CardContent>
</Card>
{/* 偏好设置 */} {/* 偏好设置 */}
<Card> <section>
<CardHeader> <div className="flex items-center gap-2 mb-4 pb-3 border-b">
<div className="flex items-center space-x-2"> <Palette className="w-4 h-4 text-primary" />
<Palette className="w-5 h-5 text-purple-600" /> <h2 className="text-base font-semibold"></h2>
<CardTitle></CardTitle> </div>
<div className="flex items-center justify-between py-3 border-b last:border-0">
<div>
<p className="text-sm font-medium"></p>
<p className="text-xs text-muted-foreground mt-0.5"></p>
</div> </div>
<CardDescription>使</CardDescription> <ThemeToggle />
</CardHeader> </div>
<CardContent> </section>
<div className="space-y-4">
{/* 主题设置 */}
<div className="flex items-center justify-between p-4 border rounded-lg">
<div className="flex-1">
<div className="flex items-center space-x-2 mb-1">
<Palette className="w-4 h-4 text-muted-foreground" />
<Label className="text-base font-medium"></Label>
</div>
<p className="text-sm text-muted-foreground">
/
</p>
</div>
<ThemeToggle />
</div>
{/* 未来可添加其他偏好设置 */}
<div className="p-4 bg-muted/50 rounded-lg">
<p className="text-sm text-muted-foreground">
</p>
</div>
</div>
</CardContent>
</Card>
</div> </div>
</div> </div>
{/* 移动端导航 */}
<MobileNav /> <MobileNav />
</div> </div>
); );
} }
+14 -32
View File
@@ -5,7 +5,6 @@ import Link from "next/link";
import { useParams, useRouter } from "next/navigation"; import { useParams, useRouter } from "next/navigation";
import { forumAPI } from "@/lib/api"; import { forumAPI } from "@/lib/api";
import type { ForumCategory, ForumPostSummary } from "@/types"; import type { ForumCategory, ForumPostSummary } from "@/types";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
@@ -19,22 +18,7 @@ import {
} from "lucide-react"; } from "lucide-react";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
function formatRelativeTime(dateStr: string) { import { format } from "date-fns";
const date = new Date(
new Date(dateStr).getTime() + 8 * 60 * 60 * 1000
);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 1) return "刚刚";
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
if (diffHours < 24) return `${diffHours} 小时前`;
if (diffDays < 7) return `${diffDays} 天前`;
return date.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
}
export default function ForumCategoryPage() { export default function ForumCategoryPage() {
const params = useParams(); const params = useParams();
@@ -118,7 +102,7 @@ export default function ForumCategoryPage() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{/* Back nav */} {/* Back nav */}
<button <button
onClick={() => router.push("/forum")} onClick={() => router.push("/forum")}
@@ -144,20 +128,19 @@ export default function ForumCategoryPage() {
)} )}
</div> </div>
{isAuthenticated && ( {isAuthenticated && (
<Button <button
onClick={() => setShowForm(!showForm)} onClick={() => setShowForm(!showForm)}
size="sm" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-[#2563eb] text-white hover:bg-[#2563eb]/90 transition-colors"
className="gap-1.5"
> >
<PenLine className="w-3.5 h-3.5" /> <PenLine className="w-3.5 h-3.5" />
{showForm ? "收起" : "发帖"} {showForm ? "收起" : "发帖"}
</Button> </button>
)} )}
</div> </div>
{/* New post form (collapsible) */} {/* New post form (collapsible) */}
{showForm && ( {showForm && (
<div className="bg-muted/30 rounded-xl border border-border/40 p-5 mb-6 space-y-4"> <div className="bg-muted rounded-xl border border-border p-5 mb-6 space-y-4">
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
{submitError && ( {submitError && (
<p className="text-sm text-destructive">{submitError}</p> <p className="text-sm text-destructive">{submitError}</p>
@@ -179,11 +162,10 @@ export default function ForumCategoryPage() {
disabled={isSubmitting} disabled={isSubmitting}
/> />
<div className="flex justify-end"> <div className="flex justify-end">
<Button <button
type="submit" type="submit"
size="sm"
disabled={isSubmitting} disabled={isSubmitting}
className="gap-1.5" className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm font-medium rounded-lg bg-[#2563eb] text-white hover:bg-[#2563eb]/90 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
> >
{isSubmitting ? ( {isSubmitting ? (
<> <>
@@ -196,7 +178,7 @@ export default function ForumCategoryPage() {
</> </>
)} )}
</Button> </button>
</div> </div>
</form> </form>
</div> </div>
@@ -204,7 +186,7 @@ export default function ForumCategoryPage() {
{/* Login prompt */} {/* Login prompt */}
{!isAuthenticated && ( {!isAuthenticated && (
<div className="text-center py-4 mb-6 bg-muted/20 rounded-lg text-sm text-muted-foreground"> <div className="text-center py-4 mb-6 bg-muted rounded-lg text-sm text-muted-foreground">
<Link href="/login" className="text-primary hover:underline mx-1"> <Link href="/login" className="text-primary hover:underline mx-1">
@@ -235,14 +217,14 @@ export default function ForumCategoryPage() {
<p className="text-muted-foreground"></p> <p className="text-muted-foreground"></p>
</div> </div>
) : ( ) : (
<div className="divide-y divide-border/40 rounded-xl border border-border/40 overflow-hidden"> <div className="divide-y divide-border/40 rounded-xl border border-border overflow-hidden">
{posts.map((post) => ( {posts.map((post) => (
<Link <Link
key={post.id} key={post.id}
href={`/forum/post/${post.id}`} href={`/forum/post/${post.id}`}
className="flex items-start gap-3 px-5 py-4 hover:bg-muted/30 transition-colors group" className="flex items-start gap-3 px-5 py-4 hover:bg-muted transition-colors group"
> >
<div className="mt-0.5 w-9 h-9 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0"> <div className="mt-0.5 w-9 h-9 rounded-full bg-muted flex items-center justify-center flex-shrink-0">
<User className="w-4 h-4 text-muted-foreground" /> <User className="w-4 h-4 text-muted-foreground" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -253,7 +235,7 @@ export default function ForumCategoryPage() {
<span>{post.author_name}</span> <span>{post.author_name}</span>
<span className="inline-flex items-center gap-0.5"> <span className="inline-flex items-center gap-0.5">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{formatRelativeTime(post.created_at)} {format(new Date(post.created_at), "yyyy/M/d HH:mm")}
</span> </span>
<span className="inline-flex items-center gap-0.5"> <span className="inline-flex items-center gap-0.5">
<MessageCircle className="w-3 h-3" /> <MessageCircle className="w-3 h-3" />
+13 -26
View File
@@ -9,22 +9,7 @@ import { Textarea } from "@/components/ui/textarea";
import { Loader2, MessageCircle, Clock, User, Send, ArrowLeft } from "lucide-react"; import { Loader2, MessageCircle, Clock, User, Send, ArrowLeft } from "lucide-react";
import { useAuthStore } from "@/store/auth"; import { useAuthStore } from "@/store/auth";
function formatRelativeTime(dateStr: string) { import { format } from "date-fns";
const date = new Date(
new Date(dateStr).getTime() + 8 * 60 * 60 * 1000
);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMinutes = Math.floor(diffMs / (1000 * 60));
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
if (diffMinutes < 1) return "刚刚";
if (diffMinutes < 60) return `${diffMinutes} 分钟前`;
if (diffHours < 24) return `${diffHours} 小时前`;
if (diffDays < 7) return `${diffDays} 天前`;
return date.toLocaleDateString("zh-CN", { month: "short", day: "numeric" });
}
export default function ForumPostPage() { export default function ForumPostPage() {
const params = useParams(); const params = useParams();
@@ -90,7 +75,8 @@ export default function ForumPostPage() {
return ( return (
<div className="min-h-screen bg-background"> <div className="min-h-screen bg-background">
<div className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 py-8"> <div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
<div className="max-w-3xl mx-auto">
{/* Back nav */} {/* Back nav */}
<button <button
onClick={() => router.back()} onClick={() => router.back()}
@@ -112,8 +98,8 @@ export default function ForumPostPage() {
) : post ? ( ) : post ? (
<div className="space-y-6"> <div className="space-y-6">
{/* Post content */} {/* Post content */}
<article className="rounded-xl border border-border/40 overflow-hidden"> <article className="rounded-xl border border-border overflow-hidden">
<div className="px-6 py-5 border-b border-border/20"> <div className="px-6 py-5 border-b border-border">
<h1 <h1
className="text-xl font-bold tracking-tight leading-snug" className="text-xl font-bold tracking-tight leading-snug"
style={{ fontFamily: "var(--font-serif), Georgia, serif" }} style={{ fontFamily: "var(--font-serif), Georgia, serif" }}
@@ -122,14 +108,14 @@ export default function ForumPostPage() {
</h1> </h1>
<div className="flex items-center gap-3 mt-3 text-sm text-muted-foreground"> <div className="flex items-center gap-3 mt-3 text-sm text-muted-foreground">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<div className="w-6 h-6 rounded-full bg-muted/60 flex items-center justify-center"> <div className="w-6 h-6 rounded-full bg-muted flex items-center justify-center">
<User className="w-3 h-3 text-muted-foreground" /> <User className="w-3 h-3 text-muted-foreground" />
</div> </div>
<span>{post.author_name}</span> <span>{post.author_name}</span>
</div> </div>
<span className="inline-flex items-center gap-0.5"> <span className="inline-flex items-center gap-0.5">
<Clock className="w-3 h-3" /> <Clock className="w-3 h-3" />
{formatRelativeTime(post.created_at)} {format(new Date(post.created_at), "yyyy/M/d HH:mm")}
</span> </span>
</div> </div>
</div> </div>
@@ -148,7 +134,7 @@ export default function ForumPostPage() {
</div> </div>
{post.replies.length === 0 ? ( {post.replies.length === 0 ? (
<div className="text-center py-8 text-sm text-muted-foreground bg-muted/20 rounded-xl"> <div className="text-center py-8 text-sm text-muted-foreground bg-muted rounded-xl">
</div> </div>
) : ( ) : (
@@ -156,9 +142,9 @@ export default function ForumPostPage() {
{post.replies.map((reply) => ( {post.replies.map((reply) => (
<div <div
key={reply.id} key={reply.id}
className="flex gap-3 px-5 py-4 rounded-xl border border-border/30 hover:border-border/60 transition-colors" className="flex gap-3 px-5 py-4 rounded-xl border border-border hover:bg-accent transition-colors"
> >
<div className="w-8 h-8 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0 mt-0.5"> <div className="w-8 h-8 rounded-full bg-muted flex items-center justify-center flex-shrink-0 mt-0.5">
<User className="w-3.5 h-3.5 text-muted-foreground" /> <User className="w-3.5 h-3.5 text-muted-foreground" />
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
@@ -167,7 +153,7 @@ export default function ForumPostPage() {
{reply.author_name} {reply.author_name}
</span> </span>
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{formatRelativeTime(reply.created_at)} {format(new Date(reply.created_at), "yyyy/M/d HH:mm")}
</span> </span>
</div> </div>
<p className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90"> <p className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
@@ -181,7 +167,7 @@ export default function ForumPostPage() {
</section> </section>
{/* Reply form */} {/* Reply form */}
<div className="rounded-xl border border-border/40 p-5"> <div className="rounded-xl border border-border p-5">
{isAuthenticated ? ( {isAuthenticated ? (
<form onSubmit={handleReply} className="space-y-3"> <form onSubmit={handleReply} className="space-y-3">
{submitError && ( {submitError && (
@@ -236,6 +222,7 @@ export default function ForumPostPage() {
</div> </div>
</div> </div>
) : null} ) : null}
</div>
</div> </div>
</div> </div>
); );