新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。

作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-31 16:58:22 +08:00
parent 9487c8ab02
commit 822eef96be
83 changed files with 8578 additions and 1222 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useMemo } from 'react';
import { useState, useEffect, useMemo, useRef } from 'react';
import { useNavigate, useSearchParams, useParams, useOutletContext } from 'react-router-dom';
import { ArrowLeft, Send, Pencil } from 'lucide-react';
import { notify } from '@/lib/notify';
@@ -14,6 +14,13 @@ import TagInput, { serializeTags, parseTags } from '../components/TagInput';
import { Spinner } from '@/components/ui/spinner';
import { getCachedBoards } from '../utils/layoutCache';
import type { LayoutCtx } from '../layouts/MainLayout';
import { loginPath } from '../utils/authRedirect';
import {
loadComposeDraft,
saveComposeDraft,
clearComposeDraft,
draftHasContent,
} from '../utils/composeDraft';
interface ComposeBaseline {
title: string;
@@ -27,6 +34,22 @@ function resolveBoards(ctxBoards?: Board[]): Board[] {
return getCachedBoards();
}
/** 格式化剩余可编辑时间 */
function formatEditRemaining(createdAt: string, windowHours: number): string {
if (windowHours <= 0) return '';
const deadline = new Date(createdAt).getTime() + windowHours * 3600_000;
const ms = deadline - Date.now();
if (ms <= 0) return '可编辑时限已到';
const hours = Math.floor(ms / 3600_000);
const mins = Math.floor((ms % 3600_000) / 60_000);
if (hours >= 24) {
const days = Math.floor(hours / 24);
return `还可编辑约 ${days}`;
}
if (hours > 0) return `还可编辑约 ${hours} 小时 ${mins}`;
return `还可编辑约 ${mins} 分钟`;
}
export default function ComposePage() {
const nav = useNavigate();
const { id: editIdParam } = useParams();
@@ -50,13 +73,21 @@ export default function ComposePage() {
() => isEdit || resolveBoards(layoutCtx?.boards).length > 0,
);
const [baseline, setBaseline] = useState<ComposeBaseline | null>(null);
const [editWindowHint, setEditWindowHint] = useState('');
const [draftHint, setDraftHint] = useState('');
const draftReadyRef = useRef(false);
const draftTimerRef = useRef<ReturnType<typeof setTimeout>>();
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
if (!user) {
nav(loginPath(isEdit ? `/post/${editId}/edit` : '/compose'));
return;
}
if (isEdit) {
setLoading(true);
draftReadyRef.current = false;
const cached = resolveBoards(layoutCtx?.boards);
const boardsPromise = cached.length > 0
? Promise.resolve({ boards: cached })
@@ -78,16 +109,43 @@ export default function ComposePage() {
return;
}
const loadedBoardId = String(post.board_id);
setBoardId(loadedBoardId);
setTitle(post.title);
setTags(post.tags ?? '');
setContent(post.content ?? '');
setBaseline({
const serverBaseline: ComposeBaseline = {
title: post.title,
tags: post.tags ?? '',
content: post.content ?? '',
boardId: loadedBoardId,
});
};
setBoardId(loadedBoardId);
setBaseline(serverBaseline);
const windowHours = postData.post_edit_window_hours ?? 0;
if (user.role !== 'admin' && windowHours > 0) {
setEditWindowHint(formatEditRemaining(post.created_at, windowHours));
} else {
setEditWindowHint('');
}
const draft = loadComposeDraft(editId);
const useDraft = draft
&& draftHasContent(draft)
&& (
draft.title !== serverBaseline.title
|| draft.tags !== serverBaseline.tags
|| draft.content !== serverBaseline.content
);
if (useDraft && draft) {
setTitle(draft.title);
setTags(draft.tags);
setContent(draft.content);
setDraftHint('已恢复未保存的编辑草稿');
notify.success('已恢复未保存的编辑草稿');
} else {
setTitle(serverBaseline.title);
setTags(serverBaseline.tags);
setContent(serverBaseline.content);
setDraftHint('');
}
draftReadyRef.current = true;
})
.catch((e: unknown) => {
notify.error(e instanceof Error ? e.message : '加载帖子失败');
@@ -97,40 +155,77 @@ export default function ComposePage() {
return;
}
draftReadyRef.current = false;
const applyNewBaseline = (list: Board[], initialBoardId: string) => {
setBoards(list);
if (!defaultBoard) setBoardId(initialBoardId);
const boardForBaseline = defaultBoard || initialBoardId;
setBoardId(prev => prev || boardForBaseline);
const draft = loadComposeDraft(null);
if (draft && draftHasContent(draft)) {
setTitle(draft.title);
setTags(draft.tags);
setContent(draft.content);
if (draft.boardId && list.some(b => String(b.id) === draft.boardId)) {
setBoardId(draft.boardId);
}
setBaseline({
title: '',
tags: '',
content: '',
boardId: draft.boardId || boardForBaseline,
});
setDraftHint('已恢复本地草稿');
notify.success('已恢复本地草稿');
} else {
setBaseline({
title: '',
tags: '',
content: '',
boardId: boardForBaseline,
});
setDraftHint('');
}
draftReadyRef.current = true;
};
const list = resolveBoards(layoutCtx?.boards);
if (list.length > 0) {
setBoards(list);
setBoardsReady(true);
const initialBoardId = defaultBoard || String(list[0].id);
if (!defaultBoard) setBoardId(initialBoardId);
setBaseline({
title: '',
tags: '',
content: '',
boardId: initialBoardId,
});
applyNewBaseline(list, initialBoardId);
setBoardsReady(true);
return;
}
setBoardsReady(false);
api.boards().then(d => {
const next = d.boards ?? [];
setBoards(next);
const initialBoardId = defaultBoard || (next.length > 0 ? String(next[0].id) : '');
if (!defaultBoard && next.length > 0) {
setBoardId(initialBoardId);
}
setBaseline({
title: '',
tags: '',
content: '',
boardId: initialBoardId,
});
applyNewBaseline(next, initialBoardId);
}).catch(() => {
setBoards([]);
}).finally(() => setBoardsReady(true));
}, [user, authLoading, nav, defaultBoard, isEdit, editId, layoutCtx?.boards]);
// 防抖自动保存草稿
useEffect(() => {
if (!draftReadyRef.current || !user) return;
clearTimeout(draftTimerRef.current);
draftTimerRef.current = setTimeout(() => {
saveComposeDraft(isEdit ? editId : null, {
title,
tags,
content,
boardId,
});
if (title.trim() || tags.trim() || content.trim()) {
setDraftHint('草稿已自动保存');
}
}, 800);
return () => clearTimeout(draftTimerRef.current);
}, [title, tags, content, boardId, isEdit, editId, user]);
const isDirty = useMemo(() => {
if (!baseline) return false;
return (
@@ -206,11 +301,13 @@ export default function ComposePage() {
if (isEdit) {
await api.updatePost(editId!, payload);
notify.success('帖子已更新');
clearComposeDraft(editId);
markSaved();
nav(`/post/${editId}`);
} else {
const res = await api.createPost({ board_id: boardId, ...payload });
notify.success('发帖成功');
clearComposeDraft(null);
markSaved();
nav(`/post/${res.post_id}`);
}
@@ -230,12 +327,20 @@ export default function ComposePage() {
<button
type="button"
className="compose-back"
onClick={() => requestLeave(() => nav(isEdit ? `/post/${editId}` : -1))}
onClick={() => requestLeave(() => {
if (isEdit) nav(`/post/${editId}`);
else nav(-1);
})}
>
<ArrowLeft size={16} />
<span></span>
</button>
<div className="compose-header-actions">
{(draftHint || editWindowHint) && (
<span className="compose-draft-hint" title={editWindowHint || draftHint}>
{editWindowHint || draftHint}
</span>
)}
<button
type="button"
className="compose-publish-btn"
@@ -287,6 +392,9 @@ export default function ComposePage() {
{currentBoard && (
<div className="compose-subtitle">
{isEdit ? '编辑于' : '发布至'} <strong>{currentBoard.name}</strong>
{editWindowHint && (
<span className="compose-edit-window"> · {editWindowHint}</span>
)}
</div>
)}
<ArticleEditor

View File

@@ -8,6 +8,9 @@ import { api } from '../api/client';
import type { PostItem } from '../api/types';
import { useAuth } from '../hooks/useAuth';
import PostListItem from '../components/PostListItem';
import { loginPath } from '../utils/authRedirect';
import { useForumLimits } from '../hooks/useForumLimits';
import { openForumPost } from '../utils/openPost';
interface FavItem {
id: number;
@@ -19,12 +22,13 @@ interface FavItem {
export default function FavoritesPage() {
const nav = useNavigate();
const { user, loading: authLoading } = useAuth();
const { limits } = useForumLimits();
const [list, setList] = useState<FavItem[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (authLoading) return;
if (!user) { nav('/login'); return; }
if (!user) { nav(loginPath('/favorites')); return; }
api.favorites()
.then(d => setList(Array.isArray(d.favorites) ? d.favorites as FavItem[] : []))
.catch(e => notify.error(e.message))
@@ -58,14 +62,14 @@ export default function FavoritesPage() {
<PostListItem
key={fav.id}
post={fav.post}
onClick={() => nav(`/post/${fav.post_id}`)}
onSelect={(id) => openForumPost(nav, id, limits.open_posts_in_new_tab)}
/>
) : (
<button
key={fav.id}
type="button"
className="post-row"
onClick={() => nav(`/post/${fav.post_id}`)}
onClick={() => openForumPost(nav, fav.post_id, limits.open_posts_in_new_tab)}
>
<div className="post-body">
<div className="post-title"></div>

View File

@@ -1,4 +1,4 @@
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
import { useState, useEffect, useCallback, useRef } from 'react';
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
@@ -6,6 +6,7 @@ import type { PostItem } from '../api/types';
import type { LayoutCtx } from '../layouts/MainLayout';
import VirtualPostList from '../components/VirtualPostList';
import FeedHeader from '../components/FeedHeader';
import FeedPageSkeleton from '../components/FeedPageSkeleton';
import FeedSortBar, { parseFeedSort, buildHomeUrl, type FeedSort } from '../components/FeedSortBar';
import { useForumLimits } from '../hooks/useForumLimits';
import {
@@ -16,44 +17,41 @@ import {
FEED_RESET_EVENT,
type FeedNavState,
} from '../utils/feedCache';
import { openForumPost } from '../utils/openPost';
export default function HomePage() {
const nav = useNavigate();
const location = useLocation();
const [params] = useSearchParams();
const ctx = useOutletContext<LayoutCtx>();
const { limits } = useForumLimits();
const pageSize = limits.page_size_default;
const feedMaxPages = limits.feed_max_pages;
const feedMaxItems = limits.feed_max_items;
const { limits, loading: limitsLoading } = useForumLimits();
const pageSize = Math.max(1, limits.page_size_default);
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
const keyword = params.get('keyword') || '';
const sort = parseFeedSort(params.get('sort'));
const initialCache = getFeedCache(boardId, keyword, sort);
const [posts, setPosts] = useState<PostItem[]>(() => initialCache?.posts ?? []);
const [postTotal, setPostTotal] = useState(() => initialCache?.postTotal ?? 0);
const [page, setPage] = useState(() => initialCache?.page ?? 1);
const [hasMore, setHasMore] = useState(() => initialCache?.hasMore ?? true);
const [loading, setLoading] = useState(() => !initialCache);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(() => initialCache?.scrollTop ?? null);
const [posts, setPosts] = useState<PostItem[]>([]);
const [postTotal, setPostTotal] = useState(0);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(true);
const [restoreScrollTop, setRestoreScrollTop] = useState<number | null>(null);
const [listResetKey, setListResetKey] = useState(0);
const scrollTopRef = useRef(initialCache?.scrollTop ?? 0);
const pageWrapRef = useRef<HTMLDivElement>(null);
/** 主动刷新时不把旧列表/滚动位置写回 cache */
const skipCacheSaveRef = useRef(false);
const canAutoLoad = useMemo(
() => hasMore && page < feedMaxPages && posts.length < feedMaxItems,
[hasMore, page, feedMaxPages, posts.length, feedMaxItems],
);
const scrollTopRef = useRef(0);
const skipCacheSaveRef = useRef(false);
const loadingRef = useRef(false);
const pageRef = useRef(1);
pageRef.current = page;
const totalPages = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
const showPagination = totalPages > 1 && posts.length > 0;
const hasMore = page < totalPages;
const resetFeedView = useCallback(() => {
setRestoreScrollTop(null);
scrollTopRef.current = 0;
setListResetKey(k => k + 1);
pageWrapRef.current?.scrollTo(0);
}, []);
const beginFeedRefresh = useCallback(() => {
@@ -62,7 +60,9 @@ export default function HomePage() {
resetFeedView();
}, [resetFeedView]);
const load = useCallback(async (p: number, reset = false) => {
const fetchPage = useCallback(async (p: number) => {
if (loadingRef.current) return;
loadingRef.current = true;
setLoading(true);
try {
const data = await api.posts({
@@ -73,67 +73,77 @@ export default function HomePage() {
sort: sort === 'latest' ? '' : sort,
});
const batch = Array.isArray(data.posts) ? data.posts : [];
setPosts(prev => (reset ? batch : [...prev, ...batch]));
setPostTotal(data.total ?? 0);
setHasMore(!!data.has_more);
const total = data.total ?? 0;
setPosts(batch);
setPostTotal(total);
setPage(p);
pageRef.current = p;
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '加载失败');
if (reset) setPosts([]);
setPosts([]);
setPostTotal(0);
setPage(1);
pageRef.current = 1;
} finally {
loadingRef.current = false;
setLoading(false);
}
}, [boardId, keyword, sort, pageSize]);
/** 有缓存时静默刷新第 1 页,合并置顶等变化同时保留已加载的历史 */
const revalidate = useCallback(async () => {
try {
const data = await api.posts({
page: 1,
size: pageSize,
board_id: boardId || '',
keyword,
sort: sort === 'latest' ? '' : sort,
});
const fresh = Array.isArray(data.posts) ? data.posts : [];
const freshIds = new Set(fresh.map(p => p.id));
setPosts(prev => [...fresh, ...prev.filter(p => !freshIds.has(p.id))]);
setPostTotal(data.total ?? 0);
setHasMore(!!data.has_more);
} catch {
// 静默失败,保留缓存数据
}
}, [boardId, keyword, sort, pageSize]);
const loadFirst = useCallback(() => fetchPage(1), [fetchPage]);
const loadNextPage = useCallback(() => {
if (loading || !hasMore) return;
load(page + 1);
}, [loading, hasMore, page, load]);
const goToPage = useCallback((p: number) => {
if (loadingRef.current) return;
const maxPage = Math.max(1, Math.ceil(Math.max(postTotal, 0) / pageSize));
if (p < 1 || p > maxPage) return;
if (p === pageRef.current) return;
resetFeedView();
fetchPage(p);
}, [fetchPage, postTotal, pageSize, resetFeedView]);
const handleSelectPost = useCallback((id: number) => {
openForumPost(nav, id, limits.open_posts_in_new_tab);
}, [nav, limits.open_posts_in_new_tab]);
// 等限制就绪后再拉列表;筛选变化时重载
useEffect(() => {
if (limitsLoading) return;
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
if (forceRefresh) {
beginFeedRefresh();
load(1, true);
loadFirst();
return;
}
const cached = getFeedCache(boardId, keyword, sort);
if (cached) {
if (cached && cached.posts.length > 0) {
setPosts(cached.posts);
setPostTotal(cached.postTotal);
setPage(cached.page);
setHasMore(cached.hasMore);
pageRef.current = cached.page;
setRestoreScrollTop(cached.scrollTop);
scrollTopRef.current = cached.scrollTop;
setLoading(false);
revalidate();
return;
}
setRestoreScrollTop(null);
scrollTopRef.current = 0;
load(1, true);
}, [boardId, keyword, sort, location.key, location.state, load, revalidate, beginFeedRefresh]);
loadFirst();
}, [
limitsLoading,
pageSize,
boardId,
keyword,
sort,
location.key,
location.state,
loadFirst,
beginFeedRefresh,
]);
// 离开当前筛选条件时写入内存缓存
useEffect(() => {
return () => {
if (skipCacheSaveRef.current || posts.length === 0) return;
@@ -141,22 +151,17 @@ export default function HomePage() {
posts,
postTotal,
page,
hasMore,
scrollTop: scrollTopRef.current,
});
};
}, [boardId, keyword, sort, posts, postTotal, page, hasMore]);
}, [boardId, keyword, sort, posts, postTotal, page]);
useEffect(() => {
if (!loading && posts.length > 0) {
skipCacheSaveRef.current = false;
}
if (!loading && posts.length > 0) skipCacheSaveRef.current = false;
}, [loading, posts.length]);
useEffect(() => {
const onFeedReset = () => {
beginFeedRefresh();
};
const onFeedReset = () => beginFeedRefresh();
window.addEventListener(FEED_RESET_EVENT, onFeedReset);
return () => window.removeEventListener(FEED_RESET_EVENT, onFeedReset);
}, [beginFeedRefresh]);
@@ -164,16 +169,16 @@ export default function HomePage() {
useEffect(() => {
const fn = () => {
beginFeedRefresh();
load(1, true);
loadFirst();
};
window.addEventListener('posts-refresh', fn);
return () => window.removeEventListener('posts-refresh', fn);
}, [beginFeedRefresh, load]);
}, [beginFeedRefresh, loadFirst]);
const handleSortChange = (next: FeedSort) => {
if (next === sort) {
beginFeedRefresh();
load(1, true);
loadFirst();
return;
}
navigateFeed(nav, buildHomeUrl(boardId, next));
@@ -181,8 +186,13 @@ export default function HomePage() {
const showSortBar = !keyword;
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
if ((loading || limitsLoading) && posts.length === 0) {
return <FeedPageSkeleton />;
}
return (
<div className="page-wrap" ref={pageWrapRef}>
<div className="page-wrap page-wrap--feed">
<div className="feed-panel">
<div className="feed-top">
<FeedHeader
@@ -197,19 +207,21 @@ export default function HomePage() {
)}
</div>
<VirtualPostList
posts={posts}
sort={sort}
loading={loading}
hasMore={hasMore}
canAutoLoad={canAutoLoad}
postTotal={postTotal}
onLoadMore={loadNextPage}
onSelect={(id) => nav(`/post/${id}`)}
restoreScrollTop={restoreScrollTop}
resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
/>
posts={posts}
sort={sort}
loading={loading || limitsLoading}
hasMore={hasMore}
showPagination={showPagination}
page={page}
totalPages={totalPages}
postTotal={postTotal}
onPageChange={goToPage}
onSelect={handleSelectPost}
restoreScrollTop={restoreScrollTop}
resetScrollKey={listResetKey}
onScrollTopChange={(top) => { scrollTopRef.current = top; }}
onScrollRestored={() => setRestoreScrollTop(null)}
/>
</div>
</div>
);

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -9,6 +9,9 @@ import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from '
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useAuth } from '../hooks/useAuth';
import { resolveAuthRedirect, registerPath, navigateAfterAuth } from '../utils/authRedirect';
import { useSiteBranding } from '../hooks/useSiteBranding';
import SiteBrandMark from '../components/SiteBrandMark';
const schema = z.object({
username: z.string().min(1, '请输入用户名'),
@@ -19,8 +22,11 @@ type FormValues = z.infer<typeof schema>;
export default function LoginPage() {
const nav = useNavigate();
const [searchParams] = useSearchParams();
const { refresh } = useAuth();
const { branding } = useSiteBranding();
const [loading, setLoading] = useState(false);
const redirectTo = resolveAuthRedirect(searchParams);
const form = useForm<FormValues>({
resolver: zodResolver(schema),
defaultValues: { username: '', password: '' },
@@ -32,7 +38,7 @@ export default function LoginPage() {
await api.login(values.username, values.password);
await refresh();
notify.success('登录成功');
nav('/', { replace: true });
navigateAfterAuth(nav, redirectTo);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '登录失败');
} finally {
@@ -43,9 +49,9 @@ export default function LoginPage() {
return (
<div className="auth-page">
<div className="auth-box">
<div className="logo-mark"></div>
<h1></h1>
<p className="subtitle"></p>
<SiteBrandMark branding={branding} className="logo-mark" />
<h1>{branding.name}</h1>
<p className="subtitle">{branding.slogan || '欢迎回来'}</p>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
@@ -80,7 +86,7 @@ export default function LoginPage() {
</form>
</Form>
<p className="auth-footer">
<Link to="/register"></Link>
<Link to={registerPath(redirectTo === '/' ? undefined : redirectTo)}></Link>
</p>
</div>
</div>

View File

@@ -1,11 +1,22 @@
import { useState, useEffect, useLayoutEffect, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion } from 'lucide-react';
import { useParams, useNavigate, useOutletContext } from 'react-router-dom';
import { ArrowLeft, ThumbsUp, Star, Pencil, Pin, History, Lock, MessageSquare, FileQuestion, Trash2 } from 'lucide-react';
import PinnedIcon from '@/components/PinnedIcon';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import BoardBadge from '@/components/BoardBadge';
import { Spinner } from '@/components/ui/spinner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { notify } from '@/lib/notify';
import { api } from '../api/client';
import type { PostItem, Comment } from '../api/types';
@@ -13,23 +24,42 @@ import CommentThreadList from '../components/CommentThreadList';
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
import PostContent from '../components/PostContent';
import PostRevisionPanel from '../components/PostRevisionPanel';
import ArticleOutline from '../components/ArticleOutline';
import { useAuth } from '../hooks/useAuth';
import { formatDateTime, isTimeDiffSignificant } from '../utils/content';
import { loadMyCommentIds, addMyCommentId } from '../utils/guest';
import { clearAllFeedCache } from '../utils/feedCache';
import { useGlobalWheelScroll } from '../hooks/useGlobalWheelScroll';
import { loginPath } from '../utils/authRedirect';
import type { LayoutCtx } from '../layouts/MainLayout';
import type { PostHeading } from '../utils/postHeadings';
/** 格式化剩余可编辑时间 */
function formatEditRemaining(createdAt: string, windowHours: number): string {
if (windowHours <= 0) return '';
const deadline = new Date(createdAt).getTime() + windowHours * 3600_000;
const ms = deadline - Date.now();
if (ms <= 0) return '';
const hours = Math.floor(ms / 3600_000);
const mins = Math.floor((ms % 3600_000) / 60_000);
if (hours >= 24) return `还可编辑约 ${Math.floor(hours / 24)}`;
if (hours > 0) return `还可编辑约 ${hours} 小时`;
return `还可编辑约 ${mins} 分钟`;
}
export default function PostDetailPage() {
const { id } = useParams();
const postId = Number(id);
const nav = useNavigate();
const { user, refresh } = useAuth();
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
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 [editingCommentId, setEditingCommentId] = useState<number | null>(null);
const [submitting, setSubmitting] = useState(false);
const [loading, setLoading] = useState(true);
const [highlightFloor, setHighlightFloor] = useState<number | null>(null);
@@ -37,7 +67,10 @@ export default function PostDetailPage() {
const [canEdit, setCanEdit] = useState(false);
const [isEdited, setIsEdited] = useState(false);
const [editBlockReason, setEditBlockReason] = useState('');
const [editWindowHours, setEditWindowHours] = useState(0);
const [showRevisions, setShowRevisions] = useState(false);
const [deletingPost, setDeletingPost] = useState(false);
const [headings, setHeadings] = useState<PostHeading[]>([]);
const pageRef = useRef<HTMLDivElement>(null);
const commentSectionRef = useRef<HTMLDivElement>(null);
@@ -46,18 +79,37 @@ export default function PostDetailPage() {
useGlobalWheelScroll(pageRef, !loading && !!post);
const handleHeadingsChange = useCallback((next: PostHeading[]) => {
setHeadings(next);
}, []);
useEffect(() => {
if (loading || !post) {
setPostOutline({ headings: [], scrollRoot: null, title: '文章目录' });
return () => setPostOutline(null);
}
setPostOutline({
headings,
scrollRoot: pageRef.current,
title: '文章目录',
});
return () => setPostOutline(null);
}, [headings, loading, post, setPostOutline]);
const loadSeq = useRef(0);
const postPath = `/post/${postId}`;
useEffect(() => {
if (!postId) return;
setReplyTo(null);
setEditingCommentId(null);
setHeadings([]);
const seq = ++loadSeq.current;
setLoading(true);
setPost(null);
(async () => {
try {
// 游客评论归属:仅在进入该帖时读取,不把 user 放进依赖以免 refresh 触发重载循环
const myIds = user ? [] : loadMyCommentIds();
const [detail, comm] = await Promise.all([
api.post(postId),
@@ -70,8 +122,8 @@ export default function PostDetailPage() {
setCanEdit(detail.can_edit ?? false);
setIsEdited(detail.is_edited ?? isTimeDiffSignificant(detail.post.created_at, detail.post.updated_at ?? detail.post.created_at));
setEditBlockReason(detail.edit_block_reason ?? '');
setEditWindowHours(detail.post_edit_window_hours ?? 0);
setComments(Array.isArray(comm.comments) ? comm.comments : []);
// 会话刷新与正文展示解耦;勿作为 effect 依赖
void refresh();
} catch (e: unknown) {
if (seq !== loadSeq.current) return;
@@ -81,16 +133,15 @@ export default function PostDetailPage() {
if (seq === loadSeq.current) setLoading(false);
}
})();
// 仅 postId 变化时加载user/refresh 变化不得重跑
// eslint-disable-next-line react-hooks/exhaustive-deps -- 见上
// eslint-disable-next-line react-hooks/exhaustive-deps -- 仅 postId 变化时加载
}, [postId]);
// 发评后局部刷新评论列表(不整页重载)
const reloadComments = useCallback(async () => {
const myIds = user ? [] : loadMyCommentIds();
const comm = await api.comments(postId, myIds);
setComments(Array.isArray(comm.comments) ? comm.comments : []);
}, [postId, user]);
const jumpToFloor = useCallback((floor: number) => {
const el = document.getElementById(`floor-${floor}`);
if (!el) return;
@@ -100,7 +151,13 @@ export default function PostDetailPage() {
highlightTimer.current = setTimeout(() => setHighlightFloor(null), 2000);
}, []);
const requireLogin = (actionLabel: string) => {
notify.warning(`登录后即可${actionLabel}`);
nav(loginPath(postPath));
};
const handleReplyTo = (comment: Comment) => {
setEditingCommentId(null);
if (replyTo?.id === comment.id) {
setReplyTo(null);
return;
@@ -108,7 +165,6 @@ export default function PostDetailPage() {
setReplyTo(comment);
};
// DOM 提交后再滚动,避免 setTimeout 与 focus 抢滚动导致概率性错位
useLayoutEffect(() => {
if (!replyTo) return;
const el = document.getElementById(`reply-box-${replyTo.id}`);
@@ -120,7 +176,7 @@ export default function PostDetailPage() {
}, []);
const handleLike = async () => {
if (!user) { nav('/login'); return; }
if (!user) { requireLogin('点赞'); return; }
try {
const r = await api.like(postId);
setLiked(r.liked);
@@ -131,7 +187,7 @@ export default function PostDetailPage() {
};
const handleFavorite = async () => {
if (!user) { nav('/login'); return; }
if (!user) { requireLogin('收藏'); return; }
try {
const r = await api.favorite(postId);
setFavorited(r.favorited);
@@ -165,6 +221,50 @@ export default function PostDetailPage() {
}
};
const handleSaveComment = async (comment: Comment, content: string) => {
try {
const r = await api.updateComment(comment.id, content);
setComments(list => list.map(c => (
c.id === comment.id
? { ...c, content: r.content || content, updated_at: new Date().toISOString() }
: c
)));
setEditingCommentId(null);
notify.success('评论已更新');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '保存失败');
throw e;
}
};
const handleDeleteComment = async (comment: Comment) => {
try {
await api.deleteComment(comment.id);
setComments(list => list.filter(c => c.id !== comment.id));
if (replyTo?.id === comment.id) setReplyTo(null);
if (editingCommentId === comment.id) setEditingCommentId(null);
notify.success('评论已删除');
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
throw e;
}
};
const handleDeletePost = async () => {
setDeletingPost(true);
try {
await api.deletePost(postId);
clearAllFeedCache();
window.dispatchEvent(new Event('posts-refresh'));
notify.success('帖子已删除');
nav('/', { replace: true });
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '删除失败');
} finally {
setDeletingPost(false);
}
};
const commentBoxProps = {
user,
submitting,
@@ -184,9 +284,12 @@ export default function PostDetailPage() {
const authorInitial = post.user?.nickname?.[0] || '?';
const tags = post.tags?.split(/[,]/).map(t => t.trim()).filter(Boolean) ?? [];
const isOwnerOrAdmin = user && (user.role === 'admin' || user.id === post.user_id);
const isOwnerOrAdmin = !!(user && (user.role === 'admin' || user.id === post.user_id));
const isAdmin = user?.role === 'admin';
const showEdited = isEdited && post.updated_at;
const editRemaining = canEdit && user?.role !== 'admin'
? formatEditRemaining(post.created_at, editWindowHours)
: '';
const handlePin = async () => {
if (!post) return;
@@ -264,14 +367,41 @@ export default function PostDetailPage() {
</div>
)}
<PostContent html={post.content || ''} isLoggedIn={!!user} />
{isMobile && headings.length > 0 && (
<details className="post-detail-toc-mobile">
<summary>{headings.length}</summary>
<ArticleOutline
headings={headings}
scrollRoot={pageRef.current}
title="目录"
/>
</details>
)}
<PostContent
html={post.content || ''}
isLoggedIn={!!user}
onHeadingsChange={handleHeadingsChange}
/>
<div className="post-detail-actions">
<Button variant={liked ? 'default' : 'outline'} size="sm" onClick={handleLike}>
<Button
variant={liked ? 'default' : 'outline'}
size="sm"
onClick={handleLike}
title={!user ? '登录后可点赞' : undefined}
className={!user ? 'post-action-guest' : undefined}
>
<ThumbsUp />
{post.like_count}
</Button>
<Button variant={favorited ? 'default' : 'outline'} size="sm" onClick={handleFavorite}>
<Button
variant={favorited ? 'default' : 'outline'}
size="sm"
onClick={handleFavorite}
title={!user ? '登录后可收藏' : undefined}
className={!user ? 'post-action-guest' : undefined}
>
<Star />
{favorited ? '已收藏' : '收藏'}
</Button>
@@ -287,6 +417,29 @@ export default function PostDetailPage() {
</Button>
)}
{isOwnerOrAdmin && (
<AlertDialog>
<AlertDialogTrigger asChild>
<Button variant="outline" size="sm" disabled={deletingPost}>
<Trash2 />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle></AlertDialogTitle>
<AlertDialogDescription></AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeletePost}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
)}
{editRemaining && (
<span className="post-detail-edit-hint">{editRemaining}</span>
)}
{isOwnerOrAdmin && !canEdit && editBlockReason && (
<span className="post-detail-edit-hint" title={editBlockReason}>
{editBlockReason}
@@ -338,8 +491,17 @@ export default function PostDetailPage() {
comments={comments}
highlightFloor={highlightFloor}
replyToId={replyTo?.id ?? null}
editingId={editingCommentId}
currentUser={user}
onReply={handleReplyTo}
onCancelReply={() => setReplyTo(null)}
onStartEdit={(c) => {
setReplyTo(null);
setEditingCommentId(c.id);
}}
onCancelEdit={() => setEditingCommentId(null)}
onSaveEdit={handleSaveComment}
onDelete={handleDeleteComment}
renderReplyBox={(c) => (
<CommentBox
key={c.id}

View File

@@ -15,6 +15,7 @@ import { api } from '../api/client';
import { useForumLimits } from '../hooks/useForumLimits';
import AvatarCropDialog from '../components/AvatarCropDialog';
import { AVATAR_ACCEPT, validateAvatarFile } from '../utils/avatarCrop';
import { loginPath } from '../utils/authRedirect';
const nickSchema = z.object({
nickname: z.string().min(1, '请输入昵称').max(64),
@@ -61,7 +62,7 @@ export default function ProfilePage() {
useEffect(() => {
if (!authLoading && !user) {
nav('/login');
nav(loginPath('/profile'));
}
}, [authLoading, user, nav]);
@@ -317,6 +318,12 @@ export default function ProfilePage() {
<Input value={user.username} disabled />
</FormControl>
</FormItem>
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input value={user.email || '未设置'} disabled />
</FormControl>
</FormItem>
<FormField
control={nickForm.control}
name="nickname"

View File

@@ -0,0 +1,119 @@
import { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { ArrowLeft, ExternalLink, FolderGit2 } 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 type { GiteaProject } from '../api/types';
function formatRemoteTime(raw?: string | null): string {
if (!raw) return '';
const d = new Date(raw);
if (Number.isNaN(d.getTime())) return '';
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
export default function ProjectsPage() {
const nav = useNavigate();
const [list, setList] = useState<GiteaProject[]>([]);
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [totalPages, setTotalPages] = useState(0);
const [loading, setLoading] = useState(true);
useEffect(() => {
setLoading(true);
api.projects({ page, limit: 30 })
.then(d => {
setList(Array.isArray(d.projects) ? d.projects : []);
setTotal(d.total ?? 0);
setTotalPages(d.total_pages ?? 0);
})
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'))
.finally(() => setLoading(false));
}, [page]);
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">
Gitea
{total > 0 ? ` · 共 ${total}` : ''}
</p>
{loading ? (
<div className="flex justify-center py-16"><Spinner size="lg" /></div>
) : list.length === 0 ? (
<div className="empty-state">
<FolderGit2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
<p></p>
<p className="page-desc" style={{ marginTop: 8 }}>
Gitea
</p>
</div>
) : (
<>
<div className="content-surface projects-list">
{list.map(p => (
<article key={p.id} className="project-row">
<div className="project-row-body">
<h2 className="project-row-title">{p.full_name || p.name}</h2>
{p.description ? (
<p className="project-row-desc">{p.description}</p>
) : null}
<div className="project-row-meta">
<span>{p.owner_login}</span>
{p.updated_at_remote && (
<span> {formatRemoteTime(p.updated_at_remote)}</span>
)}
</div>
</div>
<a
className="project-row-link"
href={p.html_url}
target="_blank"
rel="noopener noreferrer"
>
Gitea
<ExternalLink size={14} aria-hidden />
</a>
</article>
))}
</div>
{totalPages > 1 && (
<div className="projects-pager">
<Button
variant="outline"
disabled={page <= 1}
onClick={() => setPage(p => Math.max(1, p - 1))}
>
</Button>
<span className="projects-pager-info">{page} / {totalPages}</span>
<Button
variant="outline"
disabled={page >= totalPages}
onClick={() => setPage(p => p + 1)}
>
</Button>
</div>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -1,5 +1,5 @@
import { useState } from 'react';
import { useNavigate, Link } from 'react-router-dom';
import { useEffect, useState } from 'react';
import { useNavigate, Link, useSearchParams } from 'react-router-dom';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
@@ -10,32 +10,96 @@ import { notify } from '@/lib/notify';
import { api } from '../api/client';
import { useForumLimits } from '../hooks/useForumLimits';
import { useAuth } from '../hooks/useAuth';
import { resolveAuthRedirect, loginPath, navigateAfterAuth } from '../utils/authRedirect';
import type { RegisterConfig } from '../api/types';
import { useSiteBranding } from '../hooks/useSiteBranding';
import SiteBrandMark from '../components/SiteBrandMark';
const schema = (minLen: number) => z.object({
username: z.string().min(1, '请输入用户名'),
username: z.string().min(2, '用户名至少 2 位').max(32, '用户名最多 32 位'),
nickname: z.string().optional(),
email: z.string().min(1, '请输入邮箱').email('请输入有效邮箱'),
password: z.string().min(minLen, `密码至少 ${minLen}`),
email_code: z.string().optional(),
});
type FormValues = z.infer<ReturnType<typeof schema>>;
export default function RegisterPage() {
const { limits } = useForumLimits();
const { branding } = useSiteBranding();
const nav = useNavigate();
const [searchParams] = useSearchParams();
const { refresh } = useAuth();
const [loading, setLoading] = useState(false);
const [sendingCode, setSendingCode] = useState(false);
const [countdown, setCountdown] = useState(0);
const [regConfig, setRegConfig] = useState<RegisterConfig | null>(null);
const redirectTo = resolveAuthRedirect(searchParams);
const requireCode = !!regConfig?.require_email_code;
const form = useForm<FormValues>({
resolver: zodResolver(schema(limits.password_min_len)),
defaultValues: { username: '', nickname: '', password: '' },
defaultValues: { username: '', nickname: '', email: '', password: '', email_code: '' },
});
useEffect(() => {
api.registerConfig()
.then(setRegConfig)
.catch(() => setRegConfig({
is_first_user: false,
mail_ready: false,
require_email_code: false,
register_open: false,
}));
}, []);
useEffect(() => {
if (countdown <= 0) return;
const t = window.setTimeout(() => setCountdown(c => c - 1), 1000);
return () => window.clearTimeout(t);
}, [countdown]);
const sendCode = async () => {
const email = form.getValues('email');
const parsed = z.string().email().safeParse(email);
if (!parsed.success) {
form.setError('email', { message: '请先填写有效邮箱' });
return;
}
setSendingCode(true);
try {
const r = await api.sendRegisterEmailCode(email);
notify.success(r.message);
setCountdown(60);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '发送失败');
} finally {
setSendingCode(false);
}
};
const onSubmit = async (values: FormValues) => {
if (regConfig && !regConfig.register_open) {
notify.error('论坛暂未开放注册,请联系管理员配置邮件服务');
return;
}
if (requireCode && !values.email_code?.trim()) {
form.setError('email_code', { message: '请输入邮箱验证码' });
return;
}
setLoading(true);
try {
await api.register(values.username, values.password, values.nickname || values.username);
await api.register({
username: values.username,
password: values.password,
nickname: values.nickname || values.username,
email: values.email,
emailCode: values.email_code,
});
await refresh();
notify.success('注册成功');
nav('/', { replace: true });
navigateAfterAuth(nav, redirectTo);
} catch (e: unknown) {
notify.error(e instanceof Error ? e.message : '注册失败');
} finally {
@@ -43,61 +107,124 @@ export default function RegisterPage() {
}
};
const subtitle = (() => {
if (!regConfig) return branding.slogan || '欢迎加入';
if (regConfig.is_first_user) return '首个注册用户自动成为管理员';
if (!regConfig.register_open) return '注册暂未开放,请等待管理员配置邮件服务';
return branding.slogan || '欢迎加入';
})();
return (
<div className="auth-page">
<div className="auth-box">
<div className="logo-mark"></div>
<SiteBrandMark branding={branding} className="logo-mark" />
<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={`至少 ${limits.password_min_len}`} autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<Button type="submit" className="w-full" loading={loading}>
</Button>
</form>
</Form>
<p className="auth-footer">
<Link to="/login"></Link>
</p>
<p className="subtitle">{subtitle}</p>
{regConfig && !regConfig.register_open ? (
<p className="auth-footer">
<Link to={loginPath(redirectTo === '/' ? undefined : redirectTo)}></Link>
</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="2-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="email"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="email" placeholder="用于接收验证码" autoComplete="email" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={form.control}
name="password"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Input type="password" placeholder={`至少 ${limits.password_min_len}`} autoComplete="new-password" {...field} />
</FormControl>
<FormMessage />
</FormItem>
)}
/>
{requireCode && (
<FormField
control={form.control}
name="email_code"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<div className="auth-captcha-row">
<FormControl>
<Input
placeholder="6 位数字验证码"
autoComplete="one-time-code"
inputMode="numeric"
{...field}
/>
</FormControl>
<Button
type="button"
variant="outline"
className="auth-code-btn"
loading={sendingCode}
disabled={countdown > 0}
onClick={() => void sendCode()}
>
{countdown > 0 ? `${countdown}s` : '发送验证码'}
</Button>
</div>
<FormMessage />
</FormItem>
)}
/>
)}
{regConfig?.is_first_user && !regConfig.mail_ready && (
<p className="auth-hint"> SMTP</p>
)}
<Button type="submit" className="w-full" loading={loading}>
</Button>
</form>
</Form>
<p className="auth-footer">
<Link to={loginPath(redirectTo === '/' ? undefined : redirectTo)}></Link>
</p>
</>
)}
</div>
</div>
);

View File

@@ -29,7 +29,6 @@ export default function AdminDashboardPage() {
{ label: '帖子总数', value: data.posts, cls: 'admin-stat-posts' },
{ label: '板块数量', value: data.boards, cls: 'admin-stat-boards' },
{ label: '评论总数', value: data.comments, cls: 'admin-stat-comments' },
{ label: '当前在线', value: data.online, cls: 'admin-stat-online' },
];
return (

File diff suppressed because it is too large Load Diff

View File

@@ -58,14 +58,18 @@ export default function AdminUsersPage() {
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
) : (
<>
<div className="admin-table-scroll">
<table className="admin-table">
<thead>
<tr>
<th>ID</th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th></th>
<th> IP</th>
<th></th>
<th></th>
</tr>
@@ -76,12 +80,15 @@ export default function AdminUsersPage() {
<td>{u.id}</td>
<td>{u.username}</td>
<td>{u.nickname}</td>
<td className="admin-table-email">{u.email || '—'}</td>
<td>
{u.role === 'admin'
? <Badge variant="orange"></Badge>
: <Badge variant="secondary"></Badge>}
</td>
<td>{u.banned ? <Badge variant="destructive"></Badge> : '正常'}</td>
<td>{u.last_login_at ? new Date(u.last_login_at).toLocaleString('zh-CN') : '—'}</td>
<td className="admin-table-mono">{u.last_login_ip || '—'}</td>
<td>{u.created_at ? new Date(u.created_at).toLocaleString('zh-CN') : '—'}</td>
<td>
{u.role !== 'admin' && (
@@ -94,6 +101,7 @@ export default function AdminUsersPage() {
))}
</tbody>
</table>
</div>
{users.length === 0 && <div className="admin-empty"></div>}
{totalPages > 1 && (
<div className="admin-pagination">