新增 OIDC/SSO、邮件验证码与 Gitea 项目同步,并强化 Feed 与管理后台。
作为 OIDC Provider 对接 Gitea;注册支持邮件验证码/验证码;侧栏同步公开仓库;Feed 分页、文章大纲、标签云与站点品牌设置。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
119
frontend/src/pages/ProjectsPage.tsx
Normal file
119
frontend/src/pages/ProjectsPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user