Files
course-agent-od/web/src/app/(main)/forum/page.tsx
T
pengxiao 50faf7ad4e feat: add forum seed categories, improve page layouts and navigation
- Seed default forum categories (announcements, learning, feedback) on startup
- Update forum pages with improved category/post/reply UI
- Refine navbar, mobile nav, and home page content layout
- Improve analytics, course-content, knowledge, profile, settings, spatial pages

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 10:02:30 +08:00

216 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useEffect, useState } from "react";
import Link from "next/link";
import {
MessageSquare,
Loader2,
MessageCircle,
BookOpen,
Lightbulb,
Megaphone,
ChevronRight,
Clock,
User,
} from "lucide-react";
import { forumAPI } from "@/lib/api";
import type { ForumCategory, ForumPostSummary } from "@/types";
const CATEGORY_ICONS: Record<string, React.ReactNode> = {
default: <MessageSquare className="w-5 h-5" />,
};
const CATEGORY_ORDER = ["公告", "通知", "学习", "讨论", "课程", "反馈", "使用"];
function getCategoryIcon(name: string) {
if (name.includes("公告") || name.includes("通知"))
return <Megaphone className="w-4 h-4" />;
if (name.includes("学习") || name.includes("讨论") || name.includes("课程"))
return <BookOpen className="w-4 h-4" />;
if (name.includes("反馈") || name.includes("使用"))
return <Lightbulb className="w-4 h-4" />;
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) {
if (name.includes("公告") || name.includes("通知")) return "text-rose-600";
if (name.includes("学习") || name.includes("讨论") || name.includes("课程")) return "text-blue-600";
if (name.includes("反馈") || name.includes("使用")) return "text-amber-600";
return "text-slate-600";
}
function formatRelativeTime(dateStr: string) {
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 ForumHomePage() {
const [categories, setCategories] = useState<ForumCategory[]>([]);
const [categoryPosts, setCategoryPosts] = useState<
Record<number, ForumPostSummary[]>
>({});
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const loadCategories = async () => {
try {
setIsLoading(true);
const data = await forumAPI.getCategories();
setCategories(data);
const postsEntries = await Promise.all(
data.map(async (category) => {
try {
const posts = await forumAPI.getPosts(category.id, 3);
return [category.id, posts] as const;
} catch {
return [category.id, []] as const;
}
})
);
setCategoryPosts(Object.fromEntries(postsEntries));
setError(null);
} catch (err) {
const message =
err instanceof Error ? err.message : "加载论坛数据失败";
setError(message);
} finally {
setIsLoading(false);
}
};
loadCategories();
}, []);
return (
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-6 space-y-5">
{isLoading ? (
<div className="flex items-center justify-center gap-2 text-muted-foreground py-20">
<Loader2 className="h-4 w-4 animate-spin" />
...
</div>
) : error ? (
<div className="text-center py-16">
<p className="text-muted-foreground">{error}</p>
</div>
) : (
<div className="space-y-3">
{[...categories]
.sort((a, b) => {
const getOrder = (name: string) => {
for (let i = 0; i < CATEGORY_ORDER.length; i++) {
if (name.includes(CATEGORY_ORDER[i])) return i;
}
return CATEGORY_ORDER.length;
};
return getOrder(a.name) - getOrder(b.name);
})
.map((category) => {
const posts = categoryPosts[category.id] || [];
return (
<div
key={category.id}
className={`bg-gradient-to-r ${getCategoryGradient(
category.name
)} rounded-xl border border-border/40 overflow-hidden`}
>
{/* Category header */}
<div className="px-4 py-3 flex items-center justify-between border-b border-border/20">
<div className="flex items-center gap-2.5">
<span className={getCategoryAccent(category.name)}>
{getCategoryIcon(category.name)}
</span>
<div>
<h2 className="text-sm font-semibold text-foreground">
{category.name}
</h2>
{category.description && (
<p className="text-[11px] text-muted-foreground mt-0">
{category.description}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2.5">
<span className="text-[11px] text-muted-foreground tabular-nums">
{category.post_count}
</span>
<Link
href={`/forum/${category.id}`}
className="text-[11px] font-medium text-primary hover:underline flex items-center gap-0.5"
>
<ChevronRight className="w-3 h-3" />
</Link>
</div>
</div>
{/* Posts list */}
<div className="divide-y divide-border/20">
{posts.length > 0 ? (
posts.map((post) => (
<Link
key={post.id}
href={`/forum/post/${post.id}`}
className="flex items-start gap-2.5 px-4 py-2.5 hover:bg-white/40 transition-colors group"
>
<div className="mt-0.5 w-6 h-6 rounded-full bg-muted/60 flex items-center justify-center flex-shrink-0">
<User className="w-3 h-3 text-muted-foreground" />
</div>
<div className="flex-1 min-w-0">
<p className="text-[13px] font-medium text-foreground group-hover:text-primary transition-colors line-clamp-1">
{post.title}
</p>
<div className="flex items-center gap-2 mt-0.5 text-[11px] text-muted-foreground">
<span>{post.author_name}</span>
<span className="inline-flex items-center gap-0.5">
<Clock className="w-2.5 h-2.5" />
{formatRelativeTime(post.created_at)}
</span>
<span className="inline-flex items-center gap-0.5">
<MessageCircle className="w-2.5 h-2.5" />
{post.reply_count}
</span>
</div>
</div>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground/0 group-hover:text-muted-foreground transition-colors mt-1 flex-shrink-0" />
</Link>
))
) : (
<div className="px-4 py-5 text-center text-xs text-muted-foreground">
</div>
)}
</div>
</div>
);
})}
</div>
)}
</div>
);
}