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:
@@ -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" />
|
||||||
|
|||||||
@@ -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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 头像 */}
|
||||||
|
<div className="flex items-center gap-5 mb-6">
|
||||||
|
<div className="w-16 h-16 bg-primary rounded-full flex items-center justify-center text-primary-foreground text-xl font-bold">
|
||||||
{initials}
|
{initials}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-1">
|
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
|
||||||
当前使用用户名首字母作为头像
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
未来版本将支持上传自定义头像
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/* 基本信息卡片 */}
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<CardTitle>基本信息</CardTitle>
|
<p className="text-sm font-medium">{user.full_name || user.username}</p>
|
||||||
<CardDescription>您的账户基本信息</CardDescription>
|
<p className="text-xs text-muted-foreground">{user.email}</p>
|
||||||
</div>
|
</div>
|
||||||
{!isEditing && (
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => setIsEditing(true)}
|
|
||||||
>
|
|
||||||
<Edit2 className="w-4 h-4 mr-2" />
|
|
||||||
编辑
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
{/* 表单 */}
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
{/* 用户名(只读) */}
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="username">用户名</Label>
|
<Label className="text-sm text-muted-foreground">用户名</Label>
|
||||||
<Input
|
<Input value={user.username} disabled className="mt-1 bg-muted" />
|
||||||
id="username"
|
<p className="text-[11px] text-muted-foreground mt-1">用户名创建后无法修改</p>
|
||||||
value={user.username}
|
</div>
|
||||||
disabled
|
<div>
|
||||||
className="bg-muted"
|
<Label className="text-sm text-muted-foreground">注册时间</Label>
|
||||||
/>
|
<Input value={formatDate(user.created_at)} disabled className="mt-1 bg-muted" />
|
||||||
<p className="text-xs text-muted-foreground mt-1">
|
</div>
|
||||||
用户名创建后无法修改
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 邮箱(可编辑) */}
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="email">邮箱</Label>
|
<Label htmlFor="email" className="text-sm text-muted-foreground">邮箱</Label>
|
||||||
<Input
|
<Input
|
||||||
id="email"
|
id="email"
|
||||||
type="email"
|
type="email"
|
||||||
{...register("email")}
|
{...register("email")}
|
||||||
disabled={!isEditing}
|
disabled={!isEditing}
|
||||||
className={errors.email ? "border-red-500" : ""}
|
className={`mt-1 ${errors.email ? "border-red-500" : ""}`}
|
||||||
/>
|
/>
|
||||||
{errors.email && (
|
{errors.email && <p className="text-xs text-red-500 mt-1">{errors.email.message}</p>}
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
{errors.email.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 真实姓名(可编辑) */}
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="full_name">真实姓名</Label>
|
<Label htmlFor="full_name" className="text-sm text-muted-foreground">真实姓名</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>
|
||||||
|
|
||||||
{/* 注册时间(只读) */}
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="created_at">注册时间</Label>
|
|
||||||
<Input
|
|
||||||
id="created_at"
|
|
||||||
value={formatDate(user.created_at)}
|
|
||||||
disabled
|
|
||||||
className="bg-muted"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 账户状态(只读) */}
|
|
||||||
<div>
|
|
||||||
<Label htmlFor="is_active">账户状态</Label>
|
|
||||||
<div className="flex items-center space-x-2 mt-2">
|
|
||||||
<Input
|
|
||||||
id="is_active"
|
|
||||||
value={user.is_active ? "已激活" : "未激活"}
|
|
||||||
disabled
|
|
||||||
className="bg-muted"
|
|
||||||
/>
|
|
||||||
{user.is_active && (
|
|
||||||
<CheckCircle className="w-5 h-5 text-green-600" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 编辑模式下的按钮 */}
|
|
||||||
{isEditing && (
|
{isEditing && (
|
||||||
<div className="flex items-center space-x-3 pt-4">
|
<div className="flex items-center gap-3 pt-2">
|
||||||
<Button type="submit" disabled={isSaving}>
|
<button
|
||||||
{isSaving ? (
|
type="submit"
|
||||||
<>
|
disabled={isSaving}
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
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"
|
||||||
保存中...
|
>
|
||||||
</>
|
{isSaving ? <><Loader2 className="w-4 h-4 animate-spin" />保存中...</> : <><Save className="w-4 h-4" />保存</>}
|
||||||
) : (
|
</button>
|
||||||
<>
|
<button
|
||||||
<Save className="w-4 h-4 mr-2" />
|
|
||||||
保存
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
type="button"
|
||||||
variant="outline"
|
|
||||||
onClick={handleCancel}
|
onClick={handleCancel}
|
||||||
disabled={isSaving}
|
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 mr-2" />
|
<X className="w-4 h-4" />取消
|
||||||
取消
|
</button>
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</section>
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右侧:学习统计 */}
|
{/* 学习统计 */}
|
||||||
<div className="lg:col-span-1">
|
<section>
|
||||||
<Card>
|
<div className="flex items-center gap-2 mb-4 pb-3 border-b">
|
||||||
<CardHeader>
|
<h2 className="text-base font-semibold">学习统计</h2>
|
||||||
<CardTitle>学习统计</CardTitle>
|
</div>
|
||||||
<CardDescription>您的学习数据概览</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{statistics ? (
|
{statistics ? (
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
<div className="p-3 rounded-lg border text-center">
|
||||||
<div className="flex items-center space-x-3">
|
<MessageSquare className="w-4 h-4 mx-auto mb-1.5 text-primary" />
|
||||||
<MessageSquare className="w-5 h-5 text-blue-600" />
|
<p className="text-lg font-semibold">{statistics.total_sessions}</p>
|
||||||
<span className="text-sm font-medium">总对话数</span>
|
<p className="text-[11px] text-muted-foreground">对话数</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-bold">{statistics.total_sessions}</span>
|
<div className="p-3 rounded-lg border text-center">
|
||||||
|
<MessageSquare className="w-4 h-4 mx-auto mb-1.5 text-green-600" />
|
||||||
|
<p className="text-lg font-semibold">{statistics.total_messages}</p>
|
||||||
|
<p className="text-[11px] text-muted-foreground">消息数</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
<div className="p-3 rounded-lg border text-center">
|
||||||
<div className="flex items-center space-x-3">
|
<Database className="w-4 h-4 mx-auto mb-1.5 text-purple-600" />
|
||||||
<MessageSquare className="w-5 h-5 text-green-600" />
|
<p className="text-lg font-semibold">{statistics.total_documents}</p>
|
||||||
<span className="text-sm font-medium">总消息数</span>
|
<p className="text-[11px] text-muted-foreground">文档数</p>
|
||||||
</div>
|
</div>
|
||||||
<span className="text-lg font-bold">{statistics.total_messages}</span>
|
<div className="p-3 rounded-lg border text-center">
|
||||||
</div>
|
<FileText className="w-4 h-4 mx-auto mb-1.5 text-orange-600" />
|
||||||
<div className="flex items-center justify-between p-3 bg-muted/50 rounded-lg">
|
<p className="text-lg font-semibold">{statistics.active_days}</p>
|
||||||
<div className="flex items-center space-x-3">
|
<p className="text-[11px] text-muted-foreground">活跃天数</p>
|
||||||
<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>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-center py-8">
|
<div className="text-center py-6">
|
||||||
<Loader2 className="w-6 h-6 animate-spin mx-auto mb-2 text-muted-foreground" />
|
<Loader2 className="w-5 h-5 animate-spin mx-auto mb-2 text-muted-foreground" />
|
||||||
<p className="text-sm text-muted-foreground">加载统计数据中...</p>
|
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</section>
|
||||||
</Card>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 移动端导航 */}
|
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,40 +107,32 @@ 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 && (
|
||||||
{/* 成功提示 */}
|
<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">
|
||||||
{success && (
|
<CheckCircle className="w-4 h-4" />
|
||||||
<Alert className="mb-6 border-green-500 bg-green-50 dark:bg-green-950">
|
{success}
|
||||||
<CheckCircle className="w-4 h-4 text-green-600" />
|
</div>
|
||||||
<AlertDescription className="text-green-800 dark:text-green-200">
|
)}
|
||||||
{success}
|
|
||||||
</AlertDescription>
|
<div className="max-w-2xl space-y-8">
|
||||||
</Alert>
|
{/* 账户安全 */}
|
||||||
)}
|
<section>
|
||||||
|
<div className="flex items-center gap-2 mb-4 pb-3 border-b">
|
||||||
<div className="space-y-6">
|
<Shield className="w-4 h-4 text-primary" />
|
||||||
{/* 账户安全 */}
|
<h2 className="text-base font-semibold">账户安全</h2>
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Shield className="w-5 h-5 text-blue-600" />
|
|
||||||
<CardTitle>账户安全</CardTitle>
|
|
||||||
</div>
|
</div>
|
||||||
<CardDescription>修改您的登录密码</CardDescription>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4">
|
||||||
{/* 当前密码 */}
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="old_password">当前密码</Label>
|
<Label htmlFor="old_password" className="text-sm text-muted-foreground">当前密码</Label>
|
||||||
<div className="relative">
|
<div className="relative mt-1">
|
||||||
<Input
|
<Input
|
||||||
id="old_password"
|
id="old_password"
|
||||||
type={showOldPassword ? "text" : "password"}
|
type={showOldPassword ? "text" : "password"}
|
||||||
@@ -170,31 +140,20 @@ export default function SettingsPage() {
|
|||||||
className={errors.old_password ? "border-red-500" : ""}
|
className={errors.old_password ? "border-red-500" : ""}
|
||||||
placeholder="请输入当前密码"
|
placeholder="请输入当前密码"
|
||||||
/>
|
/>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
size="sm"
|
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
||||||
onClick={() => setShowOldPassword(!showOldPassword)}
|
onClick={() => setShowOldPassword(!showOldPassword)}
|
||||||
>
|
>
|
||||||
{showOldPassword ? (
|
{showOldPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
|
||||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
</button>
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{errors.old_password && (
|
{errors.old_password && <p className="text-xs text-red-500 mt-1">{errors.old_password.message}</p>}
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
{errors.old_password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 新密码 */}
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="new_password">新密码</Label>
|
<Label htmlFor="new_password" className="text-sm text-muted-foreground">新密码</Label>
|
||||||
<div className="relative">
|
<div className="relative mt-1">
|
||||||
<Input
|
<Input
|
||||||
id="new_password"
|
id="new_password"
|
||||||
type={showNewPassword ? "text" : "password"}
|
type={showNewPassword ? "text" : "password"}
|
||||||
@@ -202,52 +161,37 @@ export default function SettingsPage() {
|
|||||||
className={errors.new_password ? "border-red-500" : ""}
|
className={errors.new_password ? "border-red-500" : ""}
|
||||||
placeholder="请输入新密码(至少6个字符)"
|
placeholder="请输入新密码(至少6个字符)"
|
||||||
/>
|
/>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
size="sm"
|
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
||||||
onClick={() => setShowNewPassword(!showNewPassword)}
|
onClick={() => setShowNewPassword(!showNewPassword)}
|
||||||
>
|
>
|
||||||
{showNewPassword ? (
|
{showNewPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
|
||||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
</button>
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{newPassword && (
|
{newPassword && (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<div className="flex items-center space-x-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-xs text-muted-foreground">密码强度:</span>
|
<span className="text-xs text-muted-foreground">密码强度:</span>
|
||||||
<span className={`text-xs font-medium ${passwordStrength.color}`}>
|
<span className={`text-xs font-medium ${passwordStrength.color}`}>{passwordStrength.label}</span>
|
||||||
{passwordStrength.label}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="w-full bg-muted rounded-full h-2">
|
<div className="w-full bg-muted rounded-full h-1.5">
|
||||||
<div
|
<div
|
||||||
className={`h-2 rounded-full transition-all ${
|
className={`h-1.5 rounded-full transition-all ${
|
||||||
passwordStrength.strength === "weak"
|
passwordStrength.score <= 2 ? "bg-red-500 w-1/3"
|
||||||
? "bg-red-500 w-1/3"
|
: passwordStrength.score <= 4 ? "bg-yellow-500 w-2/3"
|
||||||
: passwordStrength.strength === "medium"
|
|
||||||
? "bg-yellow-500 w-2/3"
|
|
||||||
: "bg-green-500 w-full"
|
: "bg-green-500 w-full"
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{errors.new_password && (
|
{errors.new_password && <p className="text-xs text-red-500 mt-1">{errors.new_password.message}</p>}
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
{errors.new_password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 确认新密码 */}
|
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="confirm_password">确认新密码</Label>
|
<Label htmlFor="confirm_password" className="text-sm text-muted-foreground">确认新密码</Label>
|
||||||
<div className="relative">
|
<div className="relative mt-1">
|
||||||
<Input
|
<Input
|
||||||
id="confirm_password"
|
id="confirm_password"
|
||||||
type={showConfirmPassword ? "text" : "password"}
|
type={showConfirmPassword ? "text" : "password"}
|
||||||
@@ -255,97 +199,50 @@ export default function SettingsPage() {
|
|||||||
className={errors.confirm_password ? "border-red-500" : ""}
|
className={errors.confirm_password ? "border-red-500" : ""}
|
||||||
placeholder="请再次输入新密码"
|
placeholder="请再次输入新密码"
|
||||||
/>
|
/>
|
||||||
<Button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
variant="ghost"
|
className="absolute right-3 top-1/2 -translate-y-1/2"
|
||||||
size="sm"
|
|
||||||
className="absolute right-0 top-0 h-full px-3 py-2 hover:bg-transparent"
|
|
||||||
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
onClick={() => setShowConfirmPassword(!showConfirmPassword)}
|
||||||
>
|
>
|
||||||
{showConfirmPassword ? (
|
{showConfirmPassword ? <EyeOff className="w-4 h-4 text-muted-foreground" /> : <Eye className="w-4 h-4 text-muted-foreground" />}
|
||||||
<EyeOff className="w-4 h-4 text-muted-foreground" />
|
</button>
|
||||||
) : (
|
|
||||||
<Eye className="w-4 h-4 text-muted-foreground" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
{errors.confirm_password && (
|
{errors.confirm_password && <p className="text-xs text-red-500 mt-1">{errors.confirm_password.message}</p>}
|
||||||
<p className="text-xs text-red-500 mt-1">
|
|
||||||
{errors.confirm_password.message}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 密码要求提示 */}
|
<div className="pt-2">
|
||||||
<div className="p-4 bg-muted/50 rounded-lg">
|
<button
|
||||||
<p className="text-sm font-medium mb-2">密码要求:</p>
|
type="submit"
|
||||||
<ul className="text-xs text-muted-foreground space-y-1">
|
disabled={isSubmitting}
|
||||||
<li>• 至少6个字符</li>
|
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"
|
||||||
<li>• 建议包含大小写字母、数字和特殊字符</li>
|
>
|
||||||
<li>• 避免使用常用密码或个人信息</li>
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 提交按钮 */}
|
|
||||||
<div className="pt-4">
|
|
||||||
<Button type="submit" disabled={isSubmitting}>
|
|
||||||
{isSubmitting ? (
|
{isSubmitting ? (
|
||||||
<>
|
<><Loader2 className="w-4 h-4 animate-spin" />修改中...</>
|
||||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
|
||||||
修改中...
|
|
||||||
</>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<><Key className="w-4 h-4" />修改密码</>
|
||||||
<Key className="w-4 h-4 mr-2" />
|
|
||||||
修改密码
|
|
||||||
</>
|
|
||||||
)}
|
)}
|
||||||
</Button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</CardContent>
|
</section>
|
||||||
</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>
|
||||||
<CardDescription>自定义您的使用偏好</CardDescription>
|
<div className="flex items-center justify-between py-3 border-b last:border-0">
|
||||||
</CardHeader>
|
<div>
|
||||||
<CardContent>
|
<p className="text-sm font-medium">外观主题</p>
|
||||||
<div className="space-y-4">
|
<p className="text-xs text-muted-foreground mt-0.5">切换浅色或深色模式</p>
|
||||||
{/* 主题设置 */}
|
|
||||||
<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>
|
</div>
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
</div>
|
</div>
|
||||||
|
</section>
|
||||||
{/* 未来可添加其他偏好设置 */}
|
|
||||||
<div className="p-4 bg-muted/50 rounded-lg">
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
更多偏好设置功能即将推出
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 移动端导航 */}
|
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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" />
|
||||||
|
|||||||
@@ -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 && (
|
||||||
@@ -238,5 +224,6 @@ export default function ForumPostPage() {
|
|||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user