初始提交:姜十三论坛 Jiang13 Forum

轻量自用论坛,Go 单二进制 + React SPA 内嵌 + SQLite。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-15 21:08:52 +08:00
commit e1c1708715
140 changed files with 16115 additions and 0 deletions

View File

@@ -0,0 +1,259 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, Plus } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import {
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
} from '@/components/ui/table';
import {
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
} from '@/components/ui/dialog';
import {
AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent,
AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import {
Form, FormControl, FormField, FormItem, FormLabel, FormMessage,
} from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
const boardSchema = z.object({
name: z.string().min(1, '请输入名称').max(64),
description: z.string().max(500).optional(),
sort_order: z.coerce.number().min(0),
});
type BoardFormValues = z.infer<typeof boardSchema>;
export default function BoardsManagePage() {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const [boards, setBoards] = useState<Board[]>([]);
const [loading, setLoading] = useState(true);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<Board | null>(null);
const [submitting, setSubmitting] = useState(false);
const form = useForm<BoardFormValues>({
resolver: zodResolver(boardSchema),
defaultValues: { name: '', description: '', sort_order: 1 },
});
const load = () => {
setLoading(true);
api.boards()
.then(d => setBoards(d.boards ?? []))
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
};
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
if (user.role !== 'admin') { nav('/'); notify.warning('需要管理员权限'); return; }
load();
}, [user, authLoading, nav]);
const openCreate = () => {
setEditing(null);
form.reset({ name: '', description: '', sort_order: boards.length + 1 });
setModalOpen(true);
};
const openEdit = (board: Board) => {
setEditing(board);
form.reset({
name: board.name,
description: board.description ?? '',
sort_order: board.sort_order,
});
setModalOpen(true);
};
const handleSubmit = async (values: BoardFormValues) => {
setSubmitting(true);
try {
if (editing) {
await api.updateBoard(editing.id, values);
notify.success('板块已更新');
} else {
await api.createBoard(values);
notify.success('板块已创建');
}
setModalOpen(false);
load();
window.dispatchEvent(new Event('boards-refresh'));
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
} finally {
setSubmitting(false);
}
};
const handleDelete = async (id: number) => {
try {
await api.deleteBoard(id);
notify.success('板块已删除');
load();
window.dispatchEvent(new Event('boards-refresh'));
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
}
};
if (authLoading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!user || user.role !== 'admin') return null;
return (
<div className="page-wrap">
<div className="page-inner-wide">
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
<ArrowLeft />
</Button>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 20 }}>
<div>
<h1 className="page-title"></h1>
<p className="page-desc"></p>
</div>
<Button onClick={openCreate}>
<Plus />
</Button>
</div>
<div className="section-card" style={{ padding: 0, overflow: 'hidden' }}>
{loading ? (
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[60px]">ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="w-[70px]"></TableHead>
<TableHead className="w-[80px]"></TableHead>
<TableHead className="w-[160px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{boards.map(board => (
<TableRow key={board.id}>
<TableCell>{board.id}</TableCell>
<TableCell><strong>{board.name}</strong></TableCell>
<TableCell className="max-w-[200px] truncate">{board.description}</TableCell>
<TableCell>{board.sort_order}</TableCell>
<TableCell><Badge variant="secondary">{board.post_count ?? 0}</Badge></TableCell>
<TableCell>
<div className="flex items-center gap-1">
<Button variant="ghost" size="sm" onClick={() => openEdit(board)}></Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={() => handleDelete(board.id)}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{boards.length === 0 && (
<div className="empty-state">
<p></p>
</div>
)}
</>
)}
</div>
</div>
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editing ? '编辑板块' : '新建板块'}</DialogTitle>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="如:技术交流" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="description"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea rows={3} maxLength={500} placeholder="板块说明(可选)" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="sort_order"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="number" min={0} {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button type="submit" loading={submitting}>{editing ? '保存' : '创建'}</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,213 @@
import { useState, useEffect } from 'react';
import { useNavigate, useSearchParams, useParams } from 'react-router-dom';
import { ArrowLeft, Send, Tag } from 'lucide-react';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import type { Board } from '../api/types';
import ArticleEditor from '../components/ArticleEditor';
import { Spinner } from '@/components/ui/spinner';
import { markdownToHtml, htmlToMarkdown } from '../utils/markdown';
export default function ComposePage() {
const nav = useNavigate();
const { id: editIdParam } = useParams();
const editId = editIdParam ? Number(editIdParam) : null;
const isEdit = editId !== null && !Number.isNaN(editId);
const [params] = useSearchParams();
const defaultBoard = params.get('board') || '';
const { user, loading: authLoading } = useAuth();
const [boards, setBoards] = useState<Board[]>([]);
const [boardId, setBoardId] = useState(defaultBoard);
const [title, setTitle] = useState('');
const [tags, setTags] = useState('');
const [content, setContent] = useState('');
const [publishing, setPublishing] = useState(false);
const [loading, setLoading] = useState(isEdit);
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
if (isEdit) {
setLoading(true);
Promise.all([api.boards(), api.post(editId!)])
.then(([boardsData, postData]) => {
const list = boardsData.boards ?? [];
setBoards(list);
const post = postData.post;
const canEdit = user.role === 'admin' || post.user_id === user.id;
if (!canEdit) {
notify.error('无权编辑此帖子');
nav(`/post/${editId}`);
return;
}
setBoardId(String(post.board_id));
setTitle(post.title);
setTags(post.tags ?? '');
setContent(htmlToMarkdown(post.content ?? ''));
})
.catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败');
nav('/');
})
.finally(() => setLoading(false));
return;
}
api.boards().then(d => {
const list = d.boards ?? [];
setBoards(list);
if (!defaultBoard && list.length > 0) {
setBoardId(String(list[0].id));
}
}).catch(() => {});
}, [user, authLoading, nav, defaultBoard, isEdit, editId]);
if (authLoading) {
return (
<div className="compose-page compose-page--empty">
<Spinner size="lg" />
</div>
);
}
if (!user) return null;
if (loading) {
return (
<div className="compose-page compose-page--empty">
<Spinner size="lg" />
</div>
);
}
if (!isEdit && boards.length === 0) {
return (
<div className="compose-page compose-page--empty">
<div className="compose-empty-card">
<div className="compose-empty-icon"></div>
<h2></h2>
<p></p>
{user.role === 'admin' ? (
<button type="button" className="compose-primary-btn" onClick={() => nav('/boards')}>
</button>
) : (
<button type="button" className="compose-ghost-btn" onClick={() => nav('/')}>
</button>
)}
</div>
</div>
);
}
const handleSubmit = async () => {
const trimmedTitle = title.trim();
if (!isEdit && !boardId) { notify.warning('请选择板块'); return; }
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
if (!content.trim()) { notify.warning('请输入正文内容'); return; }
setPublishing(true);
try {
const payload = {
title: trimmedTitle,
content: markdownToHtml(content),
tags: tags.trim(),
};
if (isEdit) {
await api.updatePost(editId!, payload);
notify.success('帖子已更新');
nav(`/post/${editId}`);
} else {
const res = await api.createPost({ board_id: boardId, ...payload });
notify.success('发帖成功');
nav(`/post/${res.post_id}`);
}
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : isEdit ? '保存失败' : '发帖失败');
} finally {
setPublishing(false);
}
};
const currentBoard = boards.find(b => String(b.id) === boardId);
return (
<div className="compose-page">
<div className="compose-canvas">
<header className="compose-header">
<button type="button" className="compose-back" onClick={() => nav(isEdit ? `/post/${editId}` : -1)}>
<ArrowLeft size={16} />
<span></span>
</button>
<div className="compose-header-actions">
<button
type="button"
className="compose-publish-btn"
disabled={publishing}
onClick={handleSubmit}
>
<Send size={16} />
{publishing ? (isEdit ? '保存中…' : '发布中…') : (isEdit ? '保存修改' : '发布帖子')}
</button>
</div>
</header>
<div className="compose-meta">
{!isEdit ? (
<div className="compose-board-pills">
{boards.map(b => (
<button
key={b.id}
type="button"
className={`compose-board-pill${String(b.id) === boardId ? ' active' : ''}`}
onClick={() => setBoardId(String(b.id))}
>
{b.name}
</button>
))}
</div>
) : currentBoard && (
<div className="compose-board-pills">
<span className="compose-board-pill active">{currentBoard.name}</span>
</div>
)}
<div className="compose-tags-field">
<Tag className="compose-tags-icon" size={16} />
<input
type="text"
placeholder="添加标签,逗号分隔"
value={tags}
onChange={e => setTags(e.target.value)}
maxLength={128}
/>
</div>
</div>
<div className="compose-writing">
<input
className="compose-title"
type="text"
placeholder="输入文章标题…"
value={title}
onChange={e => setTitle(e.target.value)}
maxLength={256}
/>
{currentBoard && (
<div className="compose-subtitle">
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
</div>
)}
<ArticleEditor
value={content}
onChange={setContent}
placeholder="开始写作。支持 Markdown 语法,右侧可实时预览渲染效果。"
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,80 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import { formatTime } from '../utils/content';
interface FavItem {
id: number;
post_id: number;
created_at: string;
post?: {
id: number;
title: string;
board?: { name: string };
user?: { nickname: string };
};
}
export default function FavoritesPage() {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const [list, setList] = useState<FavItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
api.favorites()
.then(d => setList(Array.isArray(d.favorites) ? d.favorites : []))
.catch(e => notify.error(e.message))
.finally(() => setLoading(false));
}, [user, authLoading, nav]);
if (authLoading || loading) return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
if (!user) return null;
return (
<div className="page-wrap">
<div className="page-inner-wide">
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
<ArrowLeft />
</Button>
<h1 className="page-title"></h1>
<p className="page-desc"> {list.length} </p>
{list.length === 0 ? (
<div className="empty-state">
<p></p>
<Button onClick={() => nav('/')}></Button>
</div>
) : (
<div className="content-surface">
{list.map(fav => (
<div
key={fav.id}
className="post-row"
onClick={() => nav(`/post/${fav.post_id}`)}
>
<div className="post-body">
<div className="post-title">{fav.post?.title || '帖子已删除'}</div>
<div className="post-meta">
{fav.post?.board?.name && <span>{fav.post.board.name}</span>}
{fav.post?.user?.nickname && <span>{fav.post.user.nickname}</span>}
<span> {formatTime(fav.created_at)}</span>
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate, useOutletContext, useSearchParams } from 'react-router-dom';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader';
import BoardGrid from '../components/BoardGrid';
export default function HomePage() {
const nav = useNavigate();
const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>();
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
const keyword = params.get('keyword') || '';
const [posts, setPosts] = useState<PostItem[]>([]);
const [postTotal, setPostTotal] = useState(0);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const [loading, setLoading] = useState(true);
const load = useCallback(async (p: number, reset = false) => {
setLoading(true);
try {
const data = await api.posts({ page: p, size: 30, board_id: boardId || '', keyword });
const batch = Array.isArray(data.posts) ? data.posts : [];
// 切换筛选时保留旧列表,避免中间区域瞬间空白
setPosts(prev => (reset ? batch : [...prev, ...batch]));
setPostTotal(data.total ?? 0);
setHasMore(!!data.has_more);
setPage(p);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载失败');
if (reset) setPosts([]);
} finally {
setLoading(false);
}
}, [boardId, keyword]);
useEffect(() => {
load(1, true);
}, [boardId, keyword, load]);
useEffect(() => {
const fn = () => load(1, true);
window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn);
}, [load]);
const showBoardGrid = !keyword;
return (
<div className="page-wrap">
<FeedHeader
boardId={boardId}
keyword={keyword}
boards={ctx?.boards ?? []}
stats={ctx?.stats ?? null}
postTotal={postTotal}
/>
{showBoardGrid && (
<BoardGrid
boards={ctx?.boards ?? []}
loading={!ctx?.layoutReady}
selectedId={boardId}
onSelect={(id) => {
ctx?.setBoardId(id);
nav(id ? `/?board=${id}` : '/');
}}
/>
)}
<div className="post-list-bar">
<span>{keyword ? '搜索结果' : '帖子列表'}</span>
<span> {postTotal} </span>
</div>
<VirtualPostList
posts={posts}
loading={loading}
hasMore={hasMore}
onLoadMore={() => !loading && hasMore && load(page + 1)}
onSelect={(id) => nav(`/post/${id}`)}
/>
</div>
);
}

View File

@@ -0,0 +1,88 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
const schema = z.object({
username: z.string().min(1, '请输入用户名'),
password: z.string().min(1, '请输入密码'),
});
type FormValues = z.infer<typeof schema>;
export default function LoginPage() {
const nav = useNavigate();
const { refresh } = useAuth();
const [loading, setLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { username: '', password: '' },
});
const onSubmit = async (values: FormValues) => {
setLoading(true);
try {
await api.login(values.username, values.password);
await refresh();
notify.success('登录成功');
nav('/', { replace: true });
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '登录失败');
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-box">
<div className="logo-mark"></div>
<h1></h1>
<p className="subtitle"></p>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="用户名" autoComplete="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="密码" autoComplete="current-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={loading}>
</Button>
</form>
</Form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: 'var(--color-text-3)' }}>
<Link to="/register"></Link>
</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,262 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem, Comment } from '../api/types';
import CommentThreadList from '../components/CommentThreadList';
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
import PostContent from '../components/PostContent';
import { useAuth } from '../hooks/useAuth';
import { formatTime } from '../utils/content';
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
export default function PostDetailPage() {
const { id } = useParams();
const postId = Number(id);
const nav = useNavigate();
const { user, refresh } = useAuth();
const [post, setPost] = useState<PostItem | null>(null);
const [comments, setComments] = useState<Comment[]>([]);
const [liked, setLiked] = useState(false);
const [favorited, setFavorited] = useState(false);
const [replyTo, setReplyTo] = useState<Comment | null>(null);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
const [submitCount, setSubmitCount] = useState(0);
const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
const commentBoxRef = useRef<HTMLDivElement>(null);
const highlightTimer = useRef<ReturnType<typeof setTimeout>>();
useGlobalWheelScroll(pageRef, !loading && !!post);
const fetchComments = useCallback(async () => {
const myIds = user ? [] : loadMyCommentIds();
const comm = await api.comments(postId, myIds);
return Array.isArray(comm.comments) ? comm.comments : [];
}, [postId, user]);
const load = async () => {
if (!postId) return;
setLoading(true);
try {
const [detail, commList] = await Promise.all([
api.post(postId),
fetchComments(),
]);
setPost(detail.post);
setLiked(detail.liked);
setFavorited(detail.favorited);
setComments(commList);
await refresh();
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载失败');
} finally {
setLoading(false);
}
};
useEffect(() => {
setReplyTo(null);
load();
}, [postId]);
const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`);
if (!el) return;
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
setHighlightFloor(floor);
clearTimeout(highlightTimer.current);
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
}, []);
const handleReplyTo = (comment: Comment) => {
if (replyTo?.id === comment.id) {
setReplyTo(null);
return;
}
setReplyTo(comment);
};
// DOM 提交后再滚动,避免 setTimeout 与 focus 抢滚动导致概率性错位
useLayoutEffect(() => {
if (!replyTo) return;
const el = document.getElementById(`reply-box-${replyTo.id}`);
el?.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}, [replyTo?.id]);
useEffect(() => {
return () => clearTimeout(highlightTimer.current);
}, []);
const handleLike = async () => {
if (!user) { nav('/login'); return; }
try {
const r = await api.like(postId);
setLiked(r.liked);
setPost(p => p ? { ...p, like_count: r.like_count } : p);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const handleFavorite = async () => {
if (!user) { nav('/login'); return; }
try {
const r = await api.favorite(postId);
setFavorited(r.favorited);
notify.success(r.favorited ? '已收藏' : '已取消收藏');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '操作失败');
}
};
const handleSubmitComment = async (data: CommentSubmitData) => {
setSubmitting(true);
try {
const r = await api.addComment(postId, {
content: data.content,
replyTo: replyTo?.id,
guestNick: data.guestNick,
guestEmail: data.guestEmail,
guestUrl: data.guestUrl,
isPrivate: data.isPrivate,
});
if (!user) addMyCommentId(r.id);
setReplyTo(null);
setSubmitCount(c => c + 1);
notify.success('评论成功');
setComments(await fetchComments());
setTimeout(() => jumpToFloor(r.floor), 100);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '评论失败');
} finally {
setSubmitting(false);
}
};
const commentBoxProps = {
user,
submitting,
submitCount,
onSubmit: handleSubmitComment,
onCancelReply: () => setReplyTo(null),
};
if (loading) return <div className="post-detail-loading flex justify-center py-16"><Spinner size="lg" /></div>;
if (!post) return (
<div className="empty-state">
<p></p>
<Button variant="outline" onClick={() => nav('/')}></Button>
</div>
);
const authorInitial = post.user?.nickname?.[0] || '?';
const tags = post.tags?.split(/[,]/).map(t => t.trim()).filter(Boolean) ?? [];
const canEdit = user && (user.role === 'admin' || user.id === post.user_id);
return (
<div className="page-wrap post-detail-page" ref={pageRef}>
<div className="post-detail-header">
<div className="post-detail-nav">
<Button variant="ghost" size="sm" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
{post.board && (
<Badge variant="green" className="post-detail-board-tag">{post.board.name}</Badge>
)}
</div>
<div className="post-detail-head">
<h1 className="post-detail-title">
{post.pinned && <Badge variant="orange" className="mr-2 align-middle"></Badge>}
{post.title}
</h1>
<div className="post-detail-author-row">
<div className="post-avatar post-avatar-lg">
{post.user?.avatar ? <img src={post.user.avatar} alt="" /> : authorInitial}
</div>
<div className="post-detail-author-info">
<span className="post-detail-author-name">{post.user?.nickname}</span>
<span className="post-detail-meta-line">
{formatTime(post.created_at)} · {post.view_count}
</span>
</div>
</div>
</div>
{tags.length > 0 && (
<div className="post-detail-tags">
{tags.map(t => <Badge key={t} variant="secondary">{t}</Badge>)}
</div>
)}
<PostContent html={post.content || ''} isLoggedIn={!!user} />
<div className="post-detail-actions">
<Button variant={liked ? 'default' : 'outline'} size="sm" onClick={handleLike}>
<ThumbsUp />
{post.like_count}
</Button>
<Button variant={favorited ? 'default' : 'outline'} size="sm" onClick={handleFavorite}>
<Star />
{favorited ? '已收藏' : '收藏'}
</Button>
{canEdit && (
<Button variant="outline" size="sm" onClick={() => nav(`/post/${postId}/edit`)}>
<Pencil />
</Button>
)}
</div>
</div>
<div className="comment-section" ref={commentSectionRef}>
<div className="comment-section-bar">
<span className="comment-section-title"></span>
<span className="comment-section-count">{comments.length} </span>
</div>
{!replyTo && (
<div className="comment-box-wrap" ref={commentBoxRef}>
<CommentBox {...commentBoxProps} />
</div>
)}
<div className="comment-list-area">
{comments.length === 0 && !replyTo ? (
<div className="comment-empty">
<div className="comment-empty-icon">💬</div>
<p></p>
</div>
) : (
<CommentThreadList
comments={comments}
highlightFloor={highlightFloor}
replyToId={replyTo?.id ?? null}
onReply={handleReplyTo}
onCancelReply={() => setReplyTo(null)}
renderReplyBox={(c) => (
<CommentBox
key={c.id}
{...commentBoxProps}
replyTo={c}
inline
/>
)}
/>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,246 @@
import { useState, useRef, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { ArrowLeft, LayoutDashboard, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Badge } from '@/components/ui/badge';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { Spinner } from '@/components/ui/spinner';
import { notify } from '@/lib/notify';
import { useAuth } from '../hooks/useAuth';
import { api } from '../api/client';
import { openAdminDashboard } from '../utils/admin';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
});
const pwdSchema = z.object({
old_password: z.string().min(1, '请输入当前密码'),
new_password: z.string().min(6, '新密码至少 6 位'),
confirm_password: z.string().min(1, '请确认新密码'),
}).refine(d => d.new_password === d.confirm_password, {
message: '两次输入的新密码不一致',
path: ['confirm_password'],
});
type NickValues = z.infer<typeof nickSchema>;
type PwdValues = z.infer<typeof pwdSchema>;
export default function ProfilePage() {
const nav = useNavigate();
const { user, loading: authLoading, refresh } = useAuth();
const [nickLoading, setNickLoading] = useState(false);
const [pwdLoading, setPwdLoading] = useState(false);
const [avatarLoading, setAvatarLoading] = useState(false);
const fileRef = useRef<HTMLInputElement>(null);
const nickForm = useForm<NickValues>({
resolver: zodResolver(nickSchema),
values: { nickname: user?.nickname ?? '' },
});
const pwdForm = useForm<PwdValues>({
resolver: zodResolver(pwdSchema),
defaultValues: { old_password: '', new_password: '', confirm_password: '' },
});
useEffect(() => {
if (!authLoading && !user) {
nav('/login');
}
}, [authLoading, user, nav]);
if (authLoading) {
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
}
if (!user) return null;
const onUpdateNick = async (values: NickValues) => {
setNickLoading(true);
try {
await api.updateNickname(values.nickname);
await refresh();
notify.success('昵称已更新');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '更新失败');
} finally {
setNickLoading(false);
}
};
const onUpdatePwd = async (values: PwdValues) => {
setPwdLoading(true);
try {
await api.updatePassword(values.old_password, values.new_password);
notify.success('密码已修改,请重新登录');
pwdForm.reset();
await api.logout();
nav('/login');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '修改失败');
} finally {
setPwdLoading(false);
}
};
const onAvatarChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
notify.error('头像不能超过 2MB');
return;
}
setAvatarLoading(true);
try {
await api.uploadAvatar(file);
await refresh();
notify.success('头像已更新');
} catch (err: unknown) {
notify.error(err instanceof Error ? err.message : '上传失败');
} finally {
setAvatarLoading(false);
if (fileRef.current) fileRef.current.value = '';
}
};
return (
<div className="page-wrap">
<div className="page-inner-wide" style={{ maxWidth: 640 }}>
<Button variant="ghost" className="mb-3" onClick={() => nav(-1)}>
<ArrowLeft />
</Button>
<div className="profile-header">
<div className="profile-avatar-lg">
{user.avatar ? <img src={user.avatar} alt="" /> : user.nickname[0]}
</div>
<div>
<h1 className="page-title" style={{ marginBottom: 4 }}>{user.nickname}</h1>
<div style={{ fontSize: 13, color: 'var(--color-text-3)' }}>@{user.username}</div>
{user.role === 'admin' && <Badge variant="green" className="mt-1.5"></Badge>}
</div>
</div>
{user.role === 'admin' && (
<div className="section-card admin-entry-card">
<div className="section-card-title"></div>
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
</p>
<div className="flex flex-wrap gap-2">
<Button variant="outline" onClick={() => nav('/boards')}>
<Settings />
</Button>
<Button onClick={openAdminDashboard}>
<LayoutDashboard />
</Button>
</div>
</div>
)}
<div className="section-card">
<div className="section-card-title"></div>
<Form {...nickForm}>
<form onSubmit={nickForm.handleSubmit(onUpdateNick)} className="space-y-4">
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.username} disabled />
</FormControl>
</FormItem>
<FormField
control={nickForm.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input maxLength={64} placeholder="显示名称" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" loading={nickLoading}></Button>
</form>
</Form>
</div>
<div className="section-card">
<div className="section-card-title"></div>
<p style={{ fontSize: 13, color: 'var(--color-text-3)', margin: '0 0 12px' }}>
JPGPNGGIFWebP 2MB
</p>
<input
ref={fileRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: 'none' }}
onChange={onAvatarChange}
/>
<Button variant="outline" loading={avatarLoading} onClick={() => fileRef.current?.click()}>
</Button>
</div>
<div className="section-card">
<div className="section-card-title"></div>
<Form {...pwdForm}>
<form onSubmit={pwdForm.handleSubmit(onUpdatePwd)} className="space-y-4">
<FormField
control={pwdForm.control}
name="old_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="输入当前密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="new_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="至少 6 位" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={pwdForm.control}
name="confirm_password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="再次输入新密码" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" variant="destructive" loading={pwdLoading}>
</Button>
</form>
</Form>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,102 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '@/components/ui/form';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
const schema = z.object({
username: z.string().min(1, '请输入用户名'),
nickname: z.string().optional(),
password: z.string().min(6, '密码至少 6 位'),
});
type FormValues = z.infer<typeof schema>;
export default function RegisterPage() {
const nav = useNavigate();
const { refresh } = useAuth();
const [loading, setLoading] = useState(false);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { username: '', nickname: '', password: '' },
});
const onSubmit = async (values: FormValues) => {
setLoading(true);
try {
await api.register(values.username, values.password, values.nickname || values.username);
await refresh();
notify.success('注册成功');
nav('/', { replace: true });
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '注册失败');
} finally {
setLoading(false);
}
};
return (
<div className="auth-page">
<div className="auth-box">
<div className="logo-mark"></div>
<h1></h1>
<p className="subtitle"></p>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="username"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="3-32 位字母数字下划线" autoComplete="username" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="nickname"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input placeholder="显示名称(可选)" autoComplete="nickname" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder="至少 6 位" autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={loading}>
</Button>
</form>
</Form>
<p style={{ textAlign: 'center', marginTop: 16, fontSize: 13, color: 'var(--color-text-3)' }}>
<Link to="/login"></Link>
</p>
</div>
</div>
);
}