feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -30,6 +30,8 @@ import type { Board } from '../api/types';
|
||||
import { BoardColorPicker, BoardIconPicker } from '../components/BoardAppearancePicker';
|
||||
import BoardIconDisplay from '../components/BoardIconDisplay';
|
||||
import { getBoardThemeIndex } from '../utils/boardTheme';
|
||||
import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../components/admin/AdminSortableList';
|
||||
import { persistSortOrderChanges, shouldShowSortableMoveButtons } from '../utils/sortOrder';
|
||||
|
||||
const boardSchema = z.object({
|
||||
name: z.string().min(1, '请输入名称').max(64),
|
||||
@@ -48,6 +50,7 @@ export default function BoardsManagePage() {
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<Board | null>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [reordering, setReordering] = useState(false);
|
||||
|
||||
const form = useForm<BoardFormValues>({
|
||||
resolver: zodResolver(boardSchema),
|
||||
@@ -125,6 +128,32 @@ export default function BoardsManagePage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBoardReorder = async (reordered: Board[]) => {
|
||||
const before = [...boards];
|
||||
setReordering(true);
|
||||
try {
|
||||
const after = await persistSortOrderChanges(before, reordered, board =>
|
||||
api.updateBoard(board.id, {
|
||||
name: board.name,
|
||||
description: board.description ?? '',
|
||||
sort_order: board.sort_order ?? 0,
|
||||
icon: board.icon ?? '',
|
||||
color_index: board.color_index ?? -1,
|
||||
}).then(() => undefined),
|
||||
);
|
||||
setBoards(after);
|
||||
notify.success('板块排序已更新');
|
||||
window.dispatchEvent(new Event('boards-refresh'));
|
||||
} catch (e: unknown) {
|
||||
setBoards(before);
|
||||
notify.error(e instanceof Error ? e.message : '排序保存失败');
|
||||
} finally {
|
||||
setReordering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showMoveButtons = shouldShowSortableMoveButtons(boards.length);
|
||||
|
||||
if (!ready) {
|
||||
return <div className="flex justify-center py-16"><Spinner size="lg" /></div>;
|
||||
}
|
||||
@@ -152,60 +181,76 @@ export default function BoardsManagePage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[72px]">排序</TableHead>
|
||||
<TableHead className="w-[60px]">ID</TableHead>
|
||||
<TableHead className="w-[52px]">图标</TableHead>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>简介</TableHead>
|
||||
<TableHead className="w-[70px]">排序</TableHead>
|
||||
<TableHead className="w-[70px]">权重</TableHead>
|
||||
<TableHead className="w-[80px]">帖子数</TableHead>
|
||||
<TableHead className="w-[160px]">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{boards.map(board => {
|
||||
<AdminSortableList
|
||||
as={TableBody}
|
||||
items={boards}
|
||||
getId={board => board.id}
|
||||
onReorder={handleBoardReorder}
|
||||
showMoveButtons="auto"
|
||||
renderItem={(board, _index, controls) => {
|
||||
const themeIdx = getBoardThemeIndex(board);
|
||||
return (
|
||||
<TableRow key={board.id}>
|
||||
<TableCell>{board.id}</TableCell>
|
||||
<TableCell>
|
||||
<span className={cn('board-table-icon', `sidebar-board-icon--${themeIdx}`)}>
|
||||
<BoardIconDisplay board={board} />
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell><strong>{board.name}</strong></TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{board.description}</TableCell>
|
||||
<TableCell>{board.sort_order}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{board.post_count ?? 0}</Badge></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(board)}>编辑</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive">
|
||||
删除
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该板块?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除后该板块下的帖子将无法通过板块筛选,此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDelete(board.id)}>
|
||||
<TableRow
|
||||
ref={controls.setNodeRef}
|
||||
style={controls.style}
|
||||
className={cn('admin-sortable-table-row', controls.isDragging && 'is-dragging')}
|
||||
>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-0">
|
||||
<SortableDragHandle label={`拖拽调整「${board.name}」顺序`} {...controls.dragHandleProps} />
|
||||
{showMoveButtons && <SortableMoveButtons controls={controls} />}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{board.id}</TableCell>
|
||||
<TableCell>
|
||||
<span className={cn('board-table-icon', `sidebar-board-icon--${themeIdx}`)}>
|
||||
<BoardIconDisplay board={board} />
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell><strong>{board.name}</strong></TableCell>
|
||||
<TableCell className="max-w-[200px] truncate">{board.description}</TableCell>
|
||||
<TableCell>{board.sort_order}</TableCell>
|
||||
<TableCell><Badge variant="secondary">{board.post_count ?? 0}</Badge></TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(board)} disabled={reordering}>编辑</Button>
|
||||
<AlertDialog>
|
||||
<AlertDialogTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="text-destructive hover:text-destructive" disabled={reordering}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</Button>
|
||||
</AlertDialogTrigger>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确定删除该板块?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
删除后该板块下的帖子将无法通过板块筛选,此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={() => handleDelete(board.id)}>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
}}
|
||||
/>
|
||||
</Table>
|
||||
{boards.length === 0 && (
|
||||
<div className="empty-state">
|
||||
|
||||
@@ -13,7 +13,16 @@ import { serializeTags, parseTags } from '../components/TagInput';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import ComposeHeader from '../components/compose/ComposeHeader';
|
||||
import ComposeContextBar, { type PostType } from '../components/compose/ComposeContextBar';
|
||||
import ComposeSpecialFields, {
|
||||
buildPollOptionsPayload,
|
||||
countValidPollOptions,
|
||||
defaultPollEndsAtLocal,
|
||||
pollEndsAtISOToLocal,
|
||||
pollEndsAtLocalToISO,
|
||||
validatePollEndsAtLocal,
|
||||
} from '../components/compose/ComposeSpecialFields';
|
||||
import ComposeDocument from '../components/compose/ComposeDocument';
|
||||
import { sortBoardsForCompose } from '../utils/board';
|
||||
import { getCachedBoards } from '../utils/layoutCache';
|
||||
import type { LayoutCtx } from '../layouts/MainLayout';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
@@ -36,8 +45,8 @@ interface ComposeBaseline {
|
||||
}
|
||||
|
||||
function resolveBoards(ctxBoards?: Board[]): Board[] {
|
||||
if (ctxBoards && ctxBoards.length > 0) return ctxBoards;
|
||||
return getCachedBoards();
|
||||
const raw = ctxBoards && ctxBoards.length > 0 ? ctxBoards : getCachedBoards();
|
||||
return sortBoardsForCompose(raw);
|
||||
}
|
||||
|
||||
/** 格式化剩余可编辑时间 */
|
||||
@@ -74,6 +83,13 @@ export default function ComposePage() {
|
||||
const [tags, setTags] = useState('');
|
||||
const [content, setContent] = useState('');
|
||||
const [postType, setPostType] = useState<PostType>('normal');
|
||||
const [pollOptions, setPollOptions] = useState(['', '']);
|
||||
const [pollMulti, setPollMulti] = useState(false);
|
||||
const [pollMaxChoices, setPollMaxChoices] = useState(2);
|
||||
const [pollEndsAt, setPollEndsAt] = useState(defaultPollEndsAtLocal);
|
||||
const [pollNoEndTime, setPollNoEndTime] = useState(false);
|
||||
const [bountyPoints, setBountyPoints] = useState(0);
|
||||
const [lotteryWinners, setLotteryWinners] = useState(1);
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [loading, setLoading] = useState(isEdit);
|
||||
/** 新建帖:板块列表是否已就绪(避免请求中误显空态) */
|
||||
@@ -101,7 +117,7 @@ export default function ComposePage() {
|
||||
: api.boards();
|
||||
Promise.all([boardsPromise, api.post(editId!, { skipView: true })])
|
||||
.then(([boardsData, postData]) => {
|
||||
const list = boardsData.boards ?? [];
|
||||
const list = sortBoardsForCompose(boardsData.boards ?? []);
|
||||
setBoards(list);
|
||||
const post = postData.post;
|
||||
const isOwnerOrAdmin = user.role === 'admin' || post.user_id === user.id;
|
||||
@@ -116,7 +132,13 @@ export default function ComposePage() {
|
||||
return;
|
||||
}
|
||||
const loadedBoardId = String(post.board_id);
|
||||
const loadedType = post.post_type === 'question' ? 'question' : 'normal';
|
||||
const loadedType = (
|
||||
post.post_type === 'question' ? 'question'
|
||||
: post.post_type === 'poll' ? 'poll'
|
||||
: post.post_type === 'bounty' ? 'bounty'
|
||||
: post.post_type === 'lottery' ? 'lottery'
|
||||
: 'normal'
|
||||
) as PostType;
|
||||
const serverBaseline: ComposeBaseline = {
|
||||
title: post.title,
|
||||
tags: post.tags ?? '',
|
||||
@@ -130,6 +152,18 @@ export default function ComposePage() {
|
||||
setTags(serverBaseline.tags);
|
||||
setContent(serverBaseline.content);
|
||||
setPostType(loadedType);
|
||||
if (postData.poll) {
|
||||
setPollOptions(postData.poll.options.map(o => o.text));
|
||||
setPollMulti(postData.poll.multi);
|
||||
setPollMaxChoices(postData.poll.max_choices);
|
||||
if (postData.poll.ends_at) {
|
||||
setPollNoEndTime(false);
|
||||
setPollEndsAt(pollEndsAtISOToLocal(postData.poll.ends_at));
|
||||
} else {
|
||||
setPollNoEndTime(true);
|
||||
setPollEndsAt(defaultPollEndsAtLocal());
|
||||
}
|
||||
}
|
||||
|
||||
const windowHours = postData.post_edit_window_hours ?? 0;
|
||||
if (user.role !== 'admin' && windowHours > 0) {
|
||||
@@ -175,6 +209,13 @@ export default function ComposePage() {
|
||||
setTags(draft.tags);
|
||||
setContent(draft.content);
|
||||
setPostType(draft.postType);
|
||||
if (draft.postType === 'poll') {
|
||||
if (draft.pollOptions?.length) setPollOptions(draft.pollOptions);
|
||||
if (typeof draft.pollMulti === 'boolean') setPollMulti(draft.pollMulti);
|
||||
if (typeof draft.pollMaxChoices === 'number') setPollMaxChoices(draft.pollMaxChoices);
|
||||
if (typeof draft.pollNoEndTime === 'boolean') setPollNoEndTime(draft.pollNoEndTime);
|
||||
if (typeof draft.pollEndsAt === 'string' && draft.pollEndsAt) setPollEndsAt(draft.pollEndsAt);
|
||||
}
|
||||
setBaseline(emptyBaseline);
|
||||
setDraftHint('已恢复本地草稿,编辑中将自动保存');
|
||||
return;
|
||||
@@ -229,11 +270,18 @@ export default function ComposePage() {
|
||||
content,
|
||||
boardId,
|
||||
postType,
|
||||
...(postType === 'poll' ? {
|
||||
pollOptions,
|
||||
pollMulti,
|
||||
pollMaxChoices,
|
||||
pollEndsAt,
|
||||
pollNoEndTime,
|
||||
} : {}),
|
||||
});
|
||||
setDraftHint('草稿已自动保存到本机');
|
||||
}, 800);
|
||||
return () => window.clearTimeout(timer);
|
||||
}, [isEdit, baseline, isDirty, title, tags, content, boardId, postType]);
|
||||
}, [isEdit, baseline, isDirty, title, tags, content, boardId, postType, pollOptions, pollMulti, pollMaxChoices, pollEndsAt, pollNoEndTime]);
|
||||
|
||||
const {
|
||||
dialogOpen,
|
||||
@@ -307,15 +355,42 @@ export default function ComposePage() {
|
||||
if (!boardId) { notify.warning('请选择板块'); return; }
|
||||
if (!trimmedTitle) { notify.warning('请输入标题'); return; }
|
||||
if (isHtmlEmpty(content)) { notify.warning('请输入正文内容'); return; }
|
||||
if (postType === 'poll' && !isEdit && countValidPollOptions(pollOptions) < 2) {
|
||||
notify.warning('投票至少需要 2 个非空选项');
|
||||
return;
|
||||
}
|
||||
if (postType === 'poll' && !isEdit && !pollNoEndTime) {
|
||||
const err = validatePollEndsAtLocal(pollEndsAt);
|
||||
if (err) { notify.warning(err); return; }
|
||||
}
|
||||
if (postType === 'bounty' && !isEdit) {
|
||||
const balance = user?.points ?? 0;
|
||||
if (bountyPoints < 1) {
|
||||
notify.warning('请填写悬赏积分');
|
||||
return;
|
||||
}
|
||||
if (bountyPoints > balance) {
|
||||
notify.warning('积分不足');
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
setPublishing(true);
|
||||
try {
|
||||
const pollEndsAtISO = postType === 'poll' && !pollNoEndTime
|
||||
? pollEndsAtLocalToISO(pollEndsAt)
|
||||
: null;
|
||||
const payload = {
|
||||
title: trimmedTitle,
|
||||
content: content.trim(),
|
||||
tags: serializeTags(parseTags(tags)),
|
||||
board_id: boardId,
|
||||
post_type: postType,
|
||||
poll_options: postType === 'poll'
|
||||
? buildPollOptionsPayload(pollOptions, pollMulti, pollMaxChoices, pollEndsAtISO)
|
||||
: undefined,
|
||||
bounty_points: postType === 'bounty' ? bountyPoints : undefined,
|
||||
lottery_winner_count: postType === 'lottery' ? lotteryWinners : undefined,
|
||||
};
|
||||
if (isEdit) {
|
||||
await api.updatePost(editId!, payload);
|
||||
@@ -372,6 +447,25 @@ export default function ComposePage() {
|
||||
onTagsChange={setTags}
|
||||
limits={limits}
|
||||
/>
|
||||
<ComposeSpecialFields
|
||||
postType={postType}
|
||||
pollOptions={pollOptions}
|
||||
onPollOptionsChange={setPollOptions}
|
||||
pollMulti={pollMulti}
|
||||
onPollMultiChange={setPollMulti}
|
||||
pollMaxChoices={pollMaxChoices}
|
||||
onPollMaxChoicesChange={setPollMaxChoices}
|
||||
pollEndsAt={pollEndsAt}
|
||||
onPollEndsAtChange={setPollEndsAt}
|
||||
pollNoEndTime={pollNoEndTime}
|
||||
onPollNoEndTimeChange={setPollNoEndTime}
|
||||
bountyPoints={bountyPoints}
|
||||
onBountyPointsChange={setBountyPoints}
|
||||
userPointsBalance={!isEdit ? user?.points : undefined}
|
||||
lotteryWinners={lotteryWinners}
|
||||
onLotteryWinnersChange={setLotteryWinners}
|
||||
disabled={isEdit && postType !== 'normal' && postType !== 'question'}
|
||||
/>
|
||||
</ComposeDocument>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useNavigate, useOutletContext, useSearchParams, useLocation } from 'react-router-dom';
|
||||
import { useNavigate, useOutletContext, useSearchParams, useLocation, useParams } from 'react-router-dom';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem } from '../api/types';
|
||||
@@ -20,17 +20,35 @@ import {
|
||||
import { openForumPost } from '../utils/openPost';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { siteMetaDescription, useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { boardPath, canonicalRedirectPath, parsePermalinkID } from '../utils/permalink';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
|
||||
/** 仅从 URL 解析板块 ID(不以 layout 状态回退,避免回首页误用上一板块) */
|
||||
function boardIdFromLocation(routeId: string | undefined, searchParams: URLSearchParams): number {
|
||||
if (routeId) {
|
||||
const id = parsePermalinkID(routeId);
|
||||
return Number.isFinite(id) && id > 0 ? id : 0;
|
||||
}
|
||||
const q = Number(searchParams.get('board')) || 0;
|
||||
return q > 0 ? q : 0;
|
||||
}
|
||||
|
||||
export default function HomePage() {
|
||||
const nav = useNavigate();
|
||||
const location = useLocation();
|
||||
const { id: boardRouteId } = useParams();
|
||||
const [params] = useSearchParams();
|
||||
const ctx = useOutletContext<LayoutCtx>();
|
||||
const { branding } = useSiteBranding();
|
||||
const { limits, loading: limitsLoading } = useForumLimits();
|
||||
const pageSize = Math.max(1, limits.page_size_default);
|
||||
|
||||
const boardId = Number(params.get('board')) || ctx?.boardId || 0;
|
||||
const boardId = boardIdFromLocation(boardRouteId, params);
|
||||
const queryBoardId = Number(params.get('board')) || 0;
|
||||
const isBoardRoute = !!boardRouteId;
|
||||
const isInvalidBoardRoute = isBoardRoute && boardId === 0;
|
||||
const boardsLoading = ctx?.boardsLoading ?? true;
|
||||
const isMissingBoard = isBoardRoute && boardId > 0 && !boardsLoading && !(ctx?.boards ?? []).some(b => b.id === boardId);
|
||||
const keyword = params.get('keyword') || '';
|
||||
const tag = params.get('tag') || '';
|
||||
const author = params.get('author') || '';
|
||||
@@ -51,11 +69,30 @@ export default function HomePage() {
|
||||
canonicalPath: tag
|
||||
? `/?tag=${encodeURIComponent(tag)}`
|
||||
: boardId
|
||||
? `/?board=${boardId}`
|
||||
? boardPath(boardId, limits)
|
||||
: '/',
|
||||
ogType: 'website',
|
||||
});
|
||||
|
||||
// 旧版 /?board=id 重定向到规范板块路径
|
||||
useEffect(() => {
|
||||
if (queryBoardId && !boardRouteId) {
|
||||
const p = new URLSearchParams(params);
|
||||
p.delete('board');
|
||||
const qs = p.toString();
|
||||
const target = boardPath(queryBoardId, limits) + (qs ? `?${qs}` : '');
|
||||
nav(target, { replace: true });
|
||||
return;
|
||||
}
|
||||
if (boardRouteId && boardId > 0) {
|
||||
const redirect = canonicalRedirectPath('board', boardId, location.pathname, limits);
|
||||
if (redirect) {
|
||||
const qs = location.search;
|
||||
nav(redirect + qs, { replace: true });
|
||||
}
|
||||
}
|
||||
}, [queryBoardId, boardId, boardRouteId, params, nav, limits, location.pathname, location.search]);
|
||||
|
||||
const [posts, setPosts] = useState<PostItem[]>([]);
|
||||
const [postTotal, setPostTotal] = useState(0);
|
||||
const [page, setPage] = useState(1);
|
||||
@@ -137,7 +174,7 @@ export default function HomePage() {
|
||||
|
||||
// 等限制就绪后再拉列表;筛选变化时重载
|
||||
useEffect(() => {
|
||||
if (limitsLoading) return;
|
||||
if (limitsLoading || isInvalidBoardRoute || isMissingBoard) return;
|
||||
|
||||
const forceRefresh = (location.state as FeedNavState | null)?.refreshFeed;
|
||||
if (forceRefresh) {
|
||||
@@ -174,6 +211,8 @@ export default function HomePage() {
|
||||
location.state,
|
||||
loadFirst,
|
||||
beginFeedRefresh,
|
||||
isInvalidBoardRoute,
|
||||
isMissingBoard,
|
||||
]);
|
||||
|
||||
// 筛选未变时同步列表快照;变筛选的那一帧先保留旧快照供 cleanup 写入
|
||||
@@ -230,13 +269,22 @@ export default function HomePage() {
|
||||
loadFirst();
|
||||
return;
|
||||
}
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly }));
|
||||
navigateFeed(nav, buildHomeUrl(boardId, next, { keyword, tag, author, titleOnly, permalink: limits }));
|
||||
};
|
||||
|
||||
const showSortBar = !keyword && !tag && !author;
|
||||
|
||||
if (isInvalidBoardRoute || isMissingBoard) {
|
||||
return (
|
||||
<NotFoundPage
|
||||
title="板块不存在"
|
||||
description="该板块不存在,或已被删除。"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 首屏用同构骨架,避免标题/列表分区先后出现造成闪动
|
||||
if ((loading || limitsLoading) && posts.length === 0) {
|
||||
if ((loading || limitsLoading || (isBoardRoute && boardsLoading)) && posts.length === 0) {
|
||||
return <FeedPageSkeleton />;
|
||||
}
|
||||
|
||||
|
||||
300
frontend/src/pages/LinksPage.tsx
Normal file
300
frontend/src/pages/LinksPage.tsx
Normal file
@@ -0,0 +1,300 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { ArrowLeft, Link2, Pencil, Plus, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { FriendLink, FriendLinkApply } from '../api/types';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
import { joinSEOKeywords, usePageSEO } from '../hooks/usePageSEO';
|
||||
import { getCachedSiteBranding, invalidateSiteBrandingCache, useSiteBranding } from '../hooks/useSiteBranding';
|
||||
import { formatTime } from '../utils/content';
|
||||
import { loginPath } from '../utils/authRedirect';
|
||||
import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } from '../utils/friendLink';
|
||||
import { InFlowSiteFooter } from '../components/SiteFooter';
|
||||
import FriendLinkApplyDialog from '../components/FriendLinkApplyDialog';
|
||||
|
||||
function applyStatusBadge(status: FriendLinkApply['status']) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="orange">待审核</Badge>;
|
||||
case 'approved':
|
||||
return <Badge variant="green">已通过</Badge>;
|
||||
case 'rejected':
|
||||
return <Badge variant="secondary">已拒绝</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function linkInitial(name: string): string {
|
||||
const t = name.trim();
|
||||
return t ? t.charAt(0).toUpperCase() : '?';
|
||||
}
|
||||
|
||||
/** 友情链接独立页面:展示友链、申请与管理自己的申请 */
|
||||
export default function LinksPage() {
|
||||
const nav = useNavigate();
|
||||
const [params, setParams] = useSearchParams();
|
||||
const { branding } = useSiteBranding();
|
||||
const { user } = useAuth();
|
||||
const [applyOpen, setApplyOpen] = useState(false);
|
||||
const [editApply, setEditApply] = useState<FriendLinkApply | null>(null);
|
||||
const [myApplies, setMyApplies] = useState<FriendLinkApply[]>([]);
|
||||
const [myLoading, setMyLoading] = useState(false);
|
||||
const [cancelingId, setCancelingId] = useState<number | null>(null);
|
||||
|
||||
usePageSEO({
|
||||
title: '友情链接',
|
||||
description: `${branding.name} 的友情链接与申请入口`,
|
||||
keywords: joinSEOKeywords('友情链接', getCachedSiteBranding().keywords),
|
||||
canonicalPath: '/links',
|
||||
});
|
||||
|
||||
const friendLinks = (branding.friend_links ?? []).filter(
|
||||
(l: FriendLink) => l.name?.trim() && l.url?.trim(),
|
||||
);
|
||||
|
||||
const loadMyApplies = useCallback(() => {
|
||||
if (!user) {
|
||||
setMyApplies([]);
|
||||
return;
|
||||
}
|
||||
setMyLoading(true);
|
||||
api.myFriendLinkApplies()
|
||||
.then(r => setMyApplies(r.applies ?? []))
|
||||
.catch(e => notify.error(e instanceof Error ? e.message : '加载失败'))
|
||||
.finally(() => setMyLoading(false));
|
||||
}, [user]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMyApplies();
|
||||
}, [loadMyApplies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user || !myApplies.some(isReciprocalChecking)) return;
|
||||
const timer = window.setInterval(loadMyApplies, 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [user, myApplies, loadMyApplies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (params.get('apply') === '1') {
|
||||
if (!user) {
|
||||
nav(loginPath('/links?apply=1'));
|
||||
return;
|
||||
}
|
||||
setApplyOpen(true);
|
||||
const next = new URLSearchParams(params);
|
||||
next.delete('apply');
|
||||
setParams(next, { replace: true });
|
||||
}
|
||||
}, [params, setParams, user, nav]);
|
||||
|
||||
const openApply = () => {
|
||||
if (!user) {
|
||||
nav(loginPath('/links?apply=1'));
|
||||
return;
|
||||
}
|
||||
setEditApply(null);
|
||||
setApplyOpen(true);
|
||||
};
|
||||
|
||||
const openEditApply = (apply: FriendLinkApply) => {
|
||||
setEditApply(apply);
|
||||
setApplyOpen(true);
|
||||
};
|
||||
|
||||
const handleApplyOpenChange = (open: boolean) => {
|
||||
setApplyOpen(open);
|
||||
if (!open) setEditApply(null);
|
||||
};
|
||||
|
||||
const onApplySubmitted = useCallback(() => {
|
||||
loadMyApplies();
|
||||
invalidateSiteBrandingCache();
|
||||
}, [loadMyApplies]);
|
||||
|
||||
const cancelApply = async (apply: FriendLinkApply) => {
|
||||
if (apply.status !== 'pending') return;
|
||||
setCancelingId(apply.id);
|
||||
try {
|
||||
const r = await api.cancelFriendLinkApply(apply.id);
|
||||
notify.success(r.message);
|
||||
loadMyApplies();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '撤销失败');
|
||||
} finally {
|
||||
setCancelingId(null);
|
||||
}
|
||||
};
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<div className="page-inner-wide">
|
||||
<Button variant="ghost" className="mb-3" onClick={() => nav('/')}>
|
||||
<ArrowLeft />
|
||||
返回
|
||||
</Button>
|
||||
|
||||
<div className="links-page-head">
|
||||
<div>
|
||||
<h1 className="page-title">友情链接</h1>
|
||||
<p className="page-desc">
|
||||
与本站互链的站点列表
|
||||
{friendLinks.length > 0 ? ` · 共 ${friendLinks.length} 个` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" onClick={openApply}>
|
||||
<Plus size={16} aria-hidden />
|
||||
申请友链
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{friendLinks.length === 0 ? (
|
||||
<div className="empty-state links-page-empty">
|
||||
<Link2 className="empty-state-icon" aria-hidden size={36} strokeWidth={1.5} />
|
||||
<p>暂无友情链接</p>
|
||||
<p className="page-desc" style={{ marginTop: 8 }}>
|
||||
注册登录后可提交申请,审核通过后将展示在此页
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="links-page-board">
|
||||
<div className="links-page-grid">
|
||||
{friendLinks.map(link => {
|
||||
const logoURL = resolveFriendLinkLogo(link.logo, branding.site_url);
|
||||
return (
|
||||
<a
|
||||
key={`${link.name}-${link.url}`}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="links-page-card"
|
||||
title={link.name}
|
||||
>
|
||||
<div className="links-page-card__logo">
|
||||
{logoURL ? (
|
||||
<img src={logoURL} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<span>{linkInitial(link.name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<strong className="links-page-card__name">{link.name}</strong>
|
||||
</a>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{user && (
|
||||
<section className="links-page-my" aria-label="我的友链申请">
|
||||
<h2 className="links-page-my__title">我的申请</h2>
|
||||
{myLoading ? (
|
||||
<div className="flex justify-center py-10"><Spinner /></div>
|
||||
) : myApplies.length === 0 ? (
|
||||
<p className="links-page-my__empty">你还没有提交过友链申请</p>
|
||||
) : (
|
||||
<div className="links-page-my-list">
|
||||
{myApplies.map(apply => (
|
||||
<div key={apply.id} className="links-page-my-row">
|
||||
{apply.logo?.trim() && (
|
||||
<div className="links-page-my-row__logo">
|
||||
<img src={resolveFriendLinkLogo(apply.logo, branding.site_url)} alt="" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
)}
|
||||
<div className="links-page-my-row__main">
|
||||
<div className="links-page-my-row__title">
|
||||
<strong>{apply.name}</strong>
|
||||
{applyStatusBadge(apply.status)}
|
||||
</div>
|
||||
<a href={apply.url} target="_blank" rel="noopener noreferrer" className="links-page-my-row__url">
|
||||
{apply.url}
|
||||
</a>
|
||||
{apply.status === 'rejected' && apply.review_note?.trim() && (
|
||||
<p className="links-page-my-row__note">拒绝原因:{apply.review_note}</p>
|
||||
)}
|
||||
{apply.status === 'pending' && (
|
||||
<p className="links-page-my-row__note">
|
||||
回链检测:{reciprocalStatusLabel(apply).text}
|
||||
</p>
|
||||
)}
|
||||
{apply.status === 'approved' && (
|
||||
<p className="links-page-my-row__note">修改后将重新进入审核,友链会暂时从列表移除</p>
|
||||
)}
|
||||
<p className="links-page-my-row__meta">{formatTime(apply.created_at)}</p>
|
||||
</div>
|
||||
{apply.status === 'pending' && (
|
||||
<div className="links-page-my-row__actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditApply(apply)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
修改
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={cancelingId === apply.id}
|
||||
onClick={() => cancelApply(apply)}
|
||||
>
|
||||
<Trash2 size={14} aria-hidden />
|
||||
撤销
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{apply.status === 'rejected' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditApply(apply)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
修改并重新提交
|
||||
</Button>
|
||||
)}
|
||||
{apply.status === 'approved' && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => openEditApply(apply)}
|
||||
>
|
||||
<Pencil size={14} aria-hidden />
|
||||
修改
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!user && (
|
||||
<p className="links-page-login-hint">
|
||||
<Link to={loginPath('/links')}>登录</Link>
|
||||
或
|
||||
<Link to="/register">注册</Link>
|
||||
后可申请友链并管理自己的申请
|
||||
</p>
|
||||
)}
|
||||
|
||||
<InFlowSiteFooter />
|
||||
</div>
|
||||
|
||||
<FriendLinkApplyDialog
|
||||
open={applyOpen}
|
||||
onOpenChange={handleApplyOpenChange}
|
||||
editApply={editApply}
|
||||
onSubmitted={onApplySubmitted}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -34,11 +34,15 @@ import {
|
||||
} from '@/components/ui/dialog';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../api/client';
|
||||
import type { PostItem, Comment, ReportReason } from '../api/types';
|
||||
import type { PostItem, Comment, ReportReason, PollView, PostLotteryView } from '../api/types';
|
||||
import { REPORT_REASON_OPTIONS } from '../utils/report';
|
||||
import CommentThreadList from '../components/CommentThreadList';
|
||||
import CommentBox, { type CommentSubmitData } from '../components/CommentBox';
|
||||
import PostContent from '../components/PostContent';
|
||||
import PostPollCard from '../components/PostPollCard';
|
||||
import PostBountyBanner from '../components/PostBountyBanner';
|
||||
import { findCommentFloor } from '../utils/bounty';
|
||||
import PostLotteryCard from '../components/PostLotteryCard';
|
||||
import PostRevisionPanel from '../components/PostRevisionPanel';
|
||||
import ArticleOutline from '../components/ArticleOutline';
|
||||
import { useAuth } from '../hooks/useAuth';
|
||||
@@ -81,6 +85,8 @@ export default function PostDetailPage() {
|
||||
const { setPostOutline, isMobile } = useOutletContext<LayoutCtx>();
|
||||
|
||||
const [post, setPost] = useState<PostItem | null>(null);
|
||||
const [poll, setPoll] = useState<PollView | null>(null);
|
||||
const [lottery, setLottery] = useState<PostLotteryView | null>(null);
|
||||
const [comments, setComments] = useState<Comment[]>([]);
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [favorited, setFavorited] = useState(false);
|
||||
@@ -104,6 +110,11 @@ export default function PostDetailPage() {
|
||||
const [rejectOpen, setRejectOpen] = useState(false);
|
||||
const [rejectReason, setRejectReason] = useState('');
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [bountyAwardTarget, setBountyAwardTarget] = useState<number | null>(null);
|
||||
const [bountyAwarding, setBountyAwarding] = useState(false);
|
||||
const [bountyCanRefund, setBountyCanRefund] = useState(true);
|
||||
const [bountyRefundBlockReason, setBountyRefundBlockReason] = useState('');
|
||||
const [bountyEligibleReplyCount, setBountyEligibleReplyCount] = useState(0);
|
||||
|
||||
const pageRef = useRef<HTMLDivElement>(null);
|
||||
const commentSectionRef = useRef<HTMLDivElement>(null);
|
||||
@@ -189,12 +200,17 @@ export default function PostDetailPage() {
|
||||
]);
|
||||
if (seq !== loadSeq.current) return;
|
||||
setPost(detail.post);
|
||||
setPoll(detail.poll ?? null);
|
||||
setLottery(detail.lottery ?? null);
|
||||
setLiked(detail.liked);
|
||||
setFavorited(detail.favorited);
|
||||
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);
|
||||
setBountyCanRefund(detail.bounty_can_refund ?? true);
|
||||
setBountyRefundBlockReason(detail.bounty_refund_block_reason ?? '');
|
||||
setBountyEligibleReplyCount(detail.bounty_eligible_reply_count ?? 0);
|
||||
setComments(Array.isArray(comm.comments) ? comm.comments : []);
|
||||
void refresh();
|
||||
} catch {
|
||||
@@ -218,6 +234,11 @@ export default function PostDetailPage() {
|
||||
try {
|
||||
const detail = await api.post(postId, { skipView: true });
|
||||
setPost(detail.post);
|
||||
setPoll(detail.poll ?? null);
|
||||
setLottery(detail.lottery ?? null);
|
||||
setBountyCanRefund(detail.bounty_can_refund ?? true);
|
||||
setBountyRefundBlockReason(detail.bounty_refund_block_reason ?? '');
|
||||
setBountyEligibleReplyCount(detail.bounty_eligible_reply_count ?? 0);
|
||||
} catch {
|
||||
// 评论已成功,正文刷新失败不阻断
|
||||
}
|
||||
@@ -547,6 +568,32 @@ export default function PostDetailPage() {
|
||||
? formatEditRemaining(post.created_at, editWindowHours)
|
||||
: '';
|
||||
|
||||
const handleBountyAward = (commentId: number) => {
|
||||
setBountyAwardTarget(commentId);
|
||||
};
|
||||
|
||||
const confirmBountyAward = async () => {
|
||||
if (bountyAwardTarget == null) return;
|
||||
setBountyAwarding(true);
|
||||
try {
|
||||
await api.bountyAward(post.id, bountyAwardTarget);
|
||||
notify.success('悬赏已发放');
|
||||
setBountyAwardTarget(null);
|
||||
await reloadPostContent();
|
||||
await reloadComments();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setBountyAwarding(false);
|
||||
}
|
||||
};
|
||||
const awardedCommentFloor = findCommentFloor(comments, post.bounty_comment_id);
|
||||
const canJumpToAwarded = awardedCommentFloor != null;
|
||||
|
||||
const handleJumpToAwarded = () => {
|
||||
if (awardedCommentFloor != null) jumpToFloor(awardedCommentFloor);
|
||||
};
|
||||
|
||||
const handlePin = async () => {
|
||||
if (!post) return;
|
||||
try {
|
||||
@@ -729,6 +776,20 @@ export default function PostDetailPage() {
|
||||
{post.question_resolved ? '已解决' : '未解决'}
|
||||
</span>
|
||||
)}
|
||||
{post.post_type === 'poll' && (
|
||||
<span className="post-type-badge post-type-badge--poll">投票</span>
|
||||
)}
|
||||
{post.post_type === 'bounty' && post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0 && (
|
||||
<span className="post-bounty-badge post-bounty-badge--open post-bounty-badge--detail">悬赏 {post.bounty_points}</span>
|
||||
)}
|
||||
{post.post_type === 'bounty' && post.bounty_status === 'awarded' && (
|
||||
<span className="post-bounty-badge post-bounty-badge--awarded post-bounty-badge--detail">已采纳</span>
|
||||
)}
|
||||
{post.post_type === 'lottery' && (
|
||||
<span className="post-type-badge post-type-badge--lottery">
|
||||
{post.lottery_status === 'drawn' ? '已开奖' : '抽奖'}
|
||||
</span>
|
||||
)}
|
||||
{post.title}
|
||||
</h1>
|
||||
<div className="post-detail-author-row">
|
||||
@@ -791,6 +852,34 @@ export default function PostDetailPage() {
|
||||
</details>
|
||||
)}
|
||||
|
||||
{poll && (
|
||||
<PostPollCard
|
||||
postId={post.id}
|
||||
poll={poll}
|
||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||
onUpdate={setPoll}
|
||||
/>
|
||||
)}
|
||||
<PostBountyBanner
|
||||
post={post}
|
||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||
isAdmin={isAdmin}
|
||||
canRefund={bountyCanRefund}
|
||||
refundBlockReason={bountyRefundBlockReason}
|
||||
eligibleReplyCount={bountyEligibleReplyCount}
|
||||
onUpdate={reloadPostContent}
|
||||
onJumpToAwarded={handleJumpToAwarded}
|
||||
canJumpToAwarded={canJumpToAwarded}
|
||||
/>
|
||||
{lottery && (
|
||||
<PostLotteryCard
|
||||
postId={post.id}
|
||||
lottery={lottery}
|
||||
isOwnerOrAdmin={isOwnerOrAdmin}
|
||||
onUpdate={v => { setLottery(v); void reloadPostContent(); }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<PostContent
|
||||
html={post.content || ''}
|
||||
isLoggedIn={!!user}
|
||||
@@ -1078,10 +1167,42 @@ export default function PostDetailPage() {
|
||||
inline
|
||||
/>
|
||||
)}
|
||||
bountyAward={post.post_type === 'bounty' ? {
|
||||
open: post.bounty_status === 'open' && (post.bounty_points ?? 0) > 0,
|
||||
awardedCommentId: post.bounty_comment_id,
|
||||
postAuthorId: post.user_id,
|
||||
canAward: isOwnerOrAdmin,
|
||||
onAward: handleBountyAward,
|
||||
} : undefined}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<AlertDialog
|
||||
open={bountyAwardTarget != null}
|
||||
onOpenChange={(open) => { if (!open && !bountyAwarding) setBountyAwardTarget(null); }}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>采纳该回复?</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定采纳该回复并发放 {post.bounty_points ?? 0} 悬赏积分?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={bountyAwarding}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={bountyAwarding}
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
void confirmBountyAward();
|
||||
}}
|
||||
>
|
||||
{bountyAwarding ? '发放中…' : '确认采纳'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<InFlowSiteFooter />
|
||||
</article>
|
||||
);
|
||||
|
||||
55
frontend/src/pages/SitePageView.tsx
Normal file
55
frontend/src/pages/SitePageView.tsx
Normal file
@@ -0,0 +1,55 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import NotFoundPage from './NotFoundPage';
|
||||
import { api } from '../api/client';
|
||||
import type { SitePage } from '../api/types';
|
||||
import PostContent from '../components/PostContent';
|
||||
import PageLoader from '../components/PageLoader';
|
||||
import { usePageSEO } from '../hooks/usePageSEO';
|
||||
import { parsePermalinkSlug, pagePath } from '../utils/permalink';
|
||||
import { useForumLimits } from '../hooks/useForumLimits';
|
||||
|
||||
/** 自定义单页(关于我们、版规等) */
|
||||
export default function SitePageView() {
|
||||
const { slug: rawSlug } = useParams();
|
||||
const slug = parsePermalinkSlug(rawSlug);
|
||||
const { limits } = useForumLimits();
|
||||
const [page, setPage] = useState<SitePage | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [notFound, setNotFound] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!slug) {
|
||||
setNotFound(true);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
api.page(slug)
|
||||
.then(d => setPage(d.page))
|
||||
.catch(() => setNotFound(true))
|
||||
.finally(() => setLoading(false));
|
||||
}, [slug]);
|
||||
|
||||
usePageSEO({
|
||||
title: page?.title,
|
||||
description: page?.title,
|
||||
canonicalPath: slug ? pagePath(slug, limits) : undefined,
|
||||
ogType: 'article',
|
||||
});
|
||||
|
||||
if (!slug) return <NotFoundPage title="页面不存在" />;
|
||||
if (loading) return <PageLoader />;
|
||||
if (notFound || !page) return <NotFoundPage title="页面不存在" description="该页面不存在或未发布" />;
|
||||
|
||||
return (
|
||||
<div className="page-wrap">
|
||||
<article className="site-page">
|
||||
<header className="site-page__head">
|
||||
<h1>{page.title}</h1>
|
||||
</header>
|
||||
<PostContent html={page.content} className="site-page__body post-body" />
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
badgeIcon,
|
||||
formatBadgeCondition,
|
||||
} from '../../utils/badgeIcons';
|
||||
import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../../components/admin/AdminSortableList';
|
||||
import { mergeReorderedSubset, persistSortOrderChanges, shouldShowSortableMoveButtons } from '../../utils/sortOrder';
|
||||
|
||||
type KindTab = 'all' | 'auto' | 'limited';
|
||||
|
||||
@@ -62,6 +64,7 @@ export default function AdminBadgesPage() {
|
||||
const [form, setForm] = useState<Partial<BadgeDef>>({ ...EMPTY });
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [togglingId, setTogglingId] = useState<number | null>(null);
|
||||
const [reordering, setReordering] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
@@ -161,6 +164,27 @@ export default function AdminBadgesPage() {
|
||||
}
|
||||
};
|
||||
|
||||
const handleBadgeReorder = async (reorderedFiltered: BadgeDef[]) => {
|
||||
const subsetBefore = [...filtered];
|
||||
const before = [...rows];
|
||||
setReordering(true);
|
||||
try {
|
||||
const merged = mergeReorderedSubset(before, subsetBefore, reorderedFiltered);
|
||||
const after = await persistSortOrderChanges(before, merged, badge =>
|
||||
api.adminUpsertBadge({ ...badge, sort_order: badge.sort_order }),
|
||||
);
|
||||
setRows(after);
|
||||
notify.success('徽章排序已更新');
|
||||
} catch (e: unknown) {
|
||||
setRows(before);
|
||||
notify.error(e instanceof Error ? e.message : '排序保存失败');
|
||||
} finally {
|
||||
setReordering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showMoveButtons = shouldShowSortableMoveButtons(filtered.length);
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
const PreviewIcon = badgeIcon(form.icon);
|
||||
@@ -249,14 +273,30 @@ export default function AdminBadgesPage() {
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="admin-badge-grid">
|
||||
{filtered.map(b => {
|
||||
<AdminSortableList
|
||||
strategy="grid"
|
||||
className="admin-badge-grid"
|
||||
items={filtered}
|
||||
getId={b => b.id}
|
||||
onReorder={handleBadgeReorder}
|
||||
showMoveButtons="auto"
|
||||
ariaLabel="徽章列表排序"
|
||||
renderItem={(b, _index, controls) => {
|
||||
const Icon = badgeIcon(b.icon);
|
||||
return (
|
||||
<article
|
||||
key={b.id}
|
||||
className={cn('admin-badge-card', !b.enabled && 'is-disabled')}
|
||||
ref={controls.setNodeRef}
|
||||
style={controls.style}
|
||||
className={cn(
|
||||
'admin-badge-card',
|
||||
'admin-sortable-grid-item',
|
||||
!b.enabled && 'is-disabled',
|
||||
controls.isDragging && 'is-dragging',
|
||||
)}
|
||||
>
|
||||
<div className="admin-sortable-grid-item__handle-wrap">
|
||||
<SortableDragHandle label={`拖拽调整「${b.name}」顺序`} {...controls.dragHandleProps} />
|
||||
</div>
|
||||
<div className="admin-badge-card-top">
|
||||
<div className={cn('admin-badge-preview', b.kind === 'limited' && 'is-limited')}>
|
||||
<Icon size={22} aria-hidden />
|
||||
@@ -283,15 +323,16 @@ export default function AdminBadgesPage() {
|
||||
{formatBadgeCondition(b)}
|
||||
</span>
|
||||
<div className="admin-badge-card-actions">
|
||||
{showMoveButtons && <SortableMoveButtons controls={controls} />}
|
||||
<label className="admin-badge-switch" title={b.enabled ? '点击停用' : '点击启用'}>
|
||||
<span className="sr-only">启用</span>
|
||||
<Switch
|
||||
checked={b.enabled}
|
||||
disabled={togglingId === b.id}
|
||||
disabled={togglingId === b.id || reordering}
|
||||
onCheckedChange={() => toggleEnabled(b)}
|
||||
/>
|
||||
</label>
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(b)}>
|
||||
<Button size="sm" variant="outline" onClick={() => openEdit(b)} disabled={reordering}>
|
||||
<Pencil size={13} />
|
||||
编辑
|
||||
</Button>
|
||||
@@ -299,8 +340,8 @@ export default function AdminBadgesPage() {
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={(open) => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FileText, Flag, MessageSquare } from 'lucide-react';
|
||||
import { FileText, Flag, MessageSquare, Link2 } from 'lucide-react';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { api } from '../../api/client';
|
||||
@@ -29,7 +29,8 @@ export default function AdminDashboardPage() {
|
||||
const pendingPosts = data.pending_posts ?? 0;
|
||||
const pendingComments = data.pending_comments ?? 0;
|
||||
const pendingReports = data.pending_reports ?? 0;
|
||||
const pendingTotal = pendingPosts + pendingComments + pendingReports;
|
||||
const pendingFriendLinks = data.pending_friend_links ?? 0;
|
||||
const pendingTotal = pendingPosts + pendingComments + pendingReports + pendingFriendLinks;
|
||||
|
||||
const stats = [
|
||||
{ label: '注册用户', value: data.users },
|
||||
@@ -63,6 +64,14 @@ export default function AdminDashboardPage() {
|
||||
to: '/admin/reports',
|
||||
icon: Flag,
|
||||
},
|
||||
{
|
||||
key: 'links',
|
||||
label: '待审友链',
|
||||
count: pendingFriendLinks,
|
||||
hint: '用户提交的友情链接申请',
|
||||
to: '/admin/links',
|
||||
icon: Link2,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
|
||||
712
frontend/src/pages/admin/AdminLinksPage.tsx
Normal file
712
frontend/src/pages/admin/AdminLinksPage.tsx
Normal file
@@ -0,0 +1,712 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Link, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Link2, Plus, Trash2, Check, X, ExternalLink, Eye, RotateCcw,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { FriendLink, FriendLinkApply, SiteBranding } from '../../api/types';
|
||||
import { useSiteBranding, seedSiteBrandingCache, invalidateSiteBrandingCache } from '../../hooks/useSiteBranding';
|
||||
import { formatTime } from '../../utils/content';
|
||||
import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } from '../../utils/friendLink';
|
||||
import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../../components/admin/AdminSortableList';
|
||||
import { shouldShowSortableMoveButtons } from '../../utils/sortOrder';
|
||||
|
||||
type ApplyStatusTab = 'pending' | 'approved' | 'rejected' | 'all';
|
||||
|
||||
type LinkRow = FriendLink & { _key: string };
|
||||
|
||||
const APPLY_PAGE_SIZE = 20;
|
||||
|
||||
function applyStatusBadge(status: FriendLinkApply['status']) {
|
||||
switch (status) {
|
||||
case 'pending':
|
||||
return <Badge variant="orange">待审核</Badge>;
|
||||
case 'approved':
|
||||
return <Badge variant="green">已通过</Badge>;
|
||||
case 'rejected':
|
||||
return <Badge variant="secondary">已拒绝</Badge>;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function linkInitial(name: string): string {
|
||||
const t = name.trim();
|
||||
return t ? t.charAt(0).toUpperCase() : '?';
|
||||
}
|
||||
|
||||
function normalizeLinks(list: FriendLink[]): FriendLink[] {
|
||||
return list
|
||||
.map(l => ({
|
||||
name: l.name.trim(),
|
||||
url: l.url.trim(),
|
||||
logo: l.logo?.trim() || '',
|
||||
}))
|
||||
.filter(l => l.name && l.url);
|
||||
}
|
||||
|
||||
function linksEqual(a: FriendLink[], b: FriendLink[]): boolean {
|
||||
return JSON.stringify(normalizeLinks(a)) === JSON.stringify(normalizeLinks(b));
|
||||
}
|
||||
|
||||
function toLinkRows(list: FriendLink[]): LinkRow[] {
|
||||
return list.map((l, i) => ({
|
||||
...l,
|
||||
_key: `${l.url}-${l.name}-${i}`,
|
||||
}));
|
||||
}
|
||||
|
||||
function ApplyLogo({ apply, className, siteURL }: { apply: FriendLinkApply; className?: string; siteURL?: string }) {
|
||||
const logo = resolveFriendLinkLogo(apply.logo, siteURL);
|
||||
if (logo) {
|
||||
return (
|
||||
<div className={cn('admin-links-logo-thumb', className)}>
|
||||
<img src={logo} alt="" loading="lazy" decoding="async" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={cn('admin-links-logo-thumb admin-links-logo-thumb--placeholder', className)}>
|
||||
{linkInitial(apply.name)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReciprocalAddress({ apply }: { apply: FriendLinkApply }) {
|
||||
const href = apply.reciprocal_page_url?.trim();
|
||||
if (!href) {
|
||||
return <span className="admin-links-reciprocal-empty">未填写</span>;
|
||||
}
|
||||
return (
|
||||
<>
|
||||
<a href={href} target="_blank" rel="noopener noreferrer">{href}</a>
|
||||
{apply.link_on_homepage ? '(首页)' : ''}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** 后台:友情链接独立管理 */
|
||||
export default function AdminLinksPage() {
|
||||
const nav = useNavigate();
|
||||
const { ready } = useAdminGuard();
|
||||
const { branding } = useSiteBranding();
|
||||
const rowKeyRef = useRef(0);
|
||||
|
||||
const [links, setLinks] = useState<LinkRow[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const [applyStatus, setApplyStatus] = useState<ApplyStatusTab>('pending');
|
||||
const [applies, setApplies] = useState<FriendLinkApply[]>([]);
|
||||
const [applyPage, setApplyPage] = useState(1);
|
||||
const [applyTotal, setApplyTotal] = useState(0);
|
||||
const [pendingCount, setPendingCount] = useState(0);
|
||||
const [appliesLoading, setAppliesLoading] = useState(true);
|
||||
|
||||
const [detailApply, setDetailApply] = useState<FriendLinkApply | null>(null);
|
||||
const [rejectTarget, setRejectTarget] = useState<FriendLinkApply | null>(null);
|
||||
const [rejectNote, setRejectNote] = useState('');
|
||||
const [rejecting, setRejecting] = useState(false);
|
||||
const [handlingId, setHandlingId] = useState<number | null>(null);
|
||||
const [reciprocalCheckEnabled, setReciprocalCheckEnabled] = useState(false);
|
||||
const [reciprocalCheckSaving, setReciprocalCheckSaving] = useState(false);
|
||||
|
||||
const baselineLinks = branding.friend_links ?? [];
|
||||
const siteURL = branding.site_url;
|
||||
const isDirty = useMemo(
|
||||
() => !linksEqual(links, baselineLinks),
|
||||
[links, baselineLinks],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
setLinks(toLinkRows(baselineLinks));
|
||||
}, [baselineLinks]);
|
||||
|
||||
const loadApplies = useCallback(async (status: ApplyStatusTab = applyStatus, page = 1) => {
|
||||
setAppliesLoading(true);
|
||||
try {
|
||||
const r = await api.adminFriendLinkApplies({ status, page, size: APPLY_PAGE_SIZE });
|
||||
setApplies(r.applies ?? []);
|
||||
setApplyTotal(r.total ?? 0);
|
||||
setApplyPage(r.page ?? page);
|
||||
setPendingCount(r.pending_count ?? 0);
|
||||
setReciprocalCheckEnabled(!!r.reciprocal_check_enabled);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '加载失败');
|
||||
} finally {
|
||||
setAppliesLoading(false);
|
||||
}
|
||||
}, [applyStatus]);
|
||||
|
||||
useEffect(() => {
|
||||
if (ready) loadApplies(applyStatus, 1);
|
||||
}, [ready, applyStatus, loadApplies]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!ready || !reciprocalCheckEnabled || !applies.some(isReciprocalChecking)) return;
|
||||
const timer = window.setInterval(() => loadApplies(applyStatus, applyPage), 3000);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [ready, applies, applyStatus, applyPage, loadApplies, reciprocalCheckEnabled]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!detailApply) return;
|
||||
const updated = applies.find(a => a.id === detailApply.id);
|
||||
if (updated) setDetailApply(updated);
|
||||
}, [applies, detailApply]);
|
||||
|
||||
const applyTabs: { key: ApplyStatusTab; label: string }[] = [
|
||||
{ key: 'pending', label: `待审${pendingCount ? ` (${pendingCount})` : ''}` },
|
||||
{ key: 'approved', label: '已通过' },
|
||||
{ key: 'rejected', label: '已拒绝' },
|
||||
{ key: 'all', label: '全部' },
|
||||
];
|
||||
|
||||
const applyTotalPages = Math.max(1, Math.ceil(applyTotal / APPLY_PAGE_SIZE));
|
||||
const showMoveButtons = shouldShowSortableMoveButtons(links.length);
|
||||
|
||||
const addLinkRow = () => {
|
||||
rowKeyRef.current += 1;
|
||||
setLinks(prev => [...prev, { name: '', url: '', logo: '', _key: `new-${rowKeyRef.current}` }]);
|
||||
};
|
||||
|
||||
const resetLinks = () => {
|
||||
setLinks(toLinkRows(baselineLinks));
|
||||
notify.success('已恢复为已保存的版本');
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
const cleaned = normalizeLinks(links);
|
||||
const urls = new Set<string>();
|
||||
for (const l of cleaned) {
|
||||
if (!/^https?:\/\//i.test(l.url)) {
|
||||
notify.warning('友情链接需完整 http/https URL');
|
||||
return;
|
||||
}
|
||||
const key = l.url.toLowerCase();
|
||||
if (urls.has(key)) {
|
||||
notify.warning(`存在重复 URL:${l.url}`);
|
||||
return;
|
||||
}
|
||||
urls.add(key);
|
||||
}
|
||||
setSaving(true);
|
||||
try {
|
||||
const payload: SiteBranding = { ...branding, friend_links: cleaned };
|
||||
const r = await api.adminUpdateBranding(payload);
|
||||
seedSiteBrandingCache({ ...branding, ...r.branding, friend_links: cleaned });
|
||||
setLinks(toLinkRows(cleaned));
|
||||
notify.success('友情链接已保存');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const approveApply = async (apply: FriendLinkApply) => {
|
||||
setHandlingId(apply.id);
|
||||
try {
|
||||
const r = await api.adminApproveFriendLinkApply(apply.id);
|
||||
invalidateSiteBrandingCache();
|
||||
notify.success(r.message);
|
||||
setDetailApply(null);
|
||||
loadApplies(applyStatus, applyPage);
|
||||
window.dispatchEvent(new Event('admin-pending-refresh'));
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setHandlingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const openReject = (apply: FriendLinkApply) => {
|
||||
setDetailApply(null);
|
||||
setRejectTarget(apply);
|
||||
setRejectNote('');
|
||||
};
|
||||
|
||||
const recheckReciprocal = async (apply: FriendLinkApply) => {
|
||||
setHandlingId(apply.id);
|
||||
try {
|
||||
const r = await api.adminRecheckFriendLinkApply(apply.id);
|
||||
notify.success(r.message);
|
||||
setDetailApply(r.apply);
|
||||
loadApplies(applyStatus, applyPage);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setHandlingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
const toggleReciprocalCheck = async () => {
|
||||
if (reciprocalCheckSaving) return;
|
||||
const next = !reciprocalCheckEnabled;
|
||||
setReciprocalCheckEnabled(next);
|
||||
setReciprocalCheckSaving(true);
|
||||
try {
|
||||
const r = await api.adminUpdateFriendLinkSettings({ reciprocal_check_enabled: next });
|
||||
setReciprocalCheckEnabled(r.reciprocal_check_enabled);
|
||||
notify.success(r.message);
|
||||
} catch (e: unknown) {
|
||||
setReciprocalCheckEnabled(!next);
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setReciprocalCheckSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const submitReject = async () => {
|
||||
if (!rejectTarget) return;
|
||||
setRejecting(true);
|
||||
try {
|
||||
const r = await api.adminRejectFriendLinkApply(rejectTarget.id, { note: rejectNote.trim() });
|
||||
notify.success(r.message);
|
||||
setRejectTarget(null);
|
||||
setRejectNote('');
|
||||
loadApplies(applyStatus, applyPage);
|
||||
window.dispatchEvent(new Event('admin-pending-refresh'));
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '操作失败');
|
||||
} finally {
|
||||
setRejecting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const renderApplicant = (apply: FriendLinkApply) => {
|
||||
const label = apply.user?.nickname || apply.user?.username || `用户 #${apply.user_id}`;
|
||||
if (apply.user_id) {
|
||||
return (
|
||||
<button type="button" className="admin-text-link" onClick={() => nav(`/user/${apply.user_id}`)}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return label;
|
||||
};
|
||||
|
||||
const renderPendingCard = (apply: FriendLinkApply) => {
|
||||
const reciprocal = reciprocalStatusLabel(apply);
|
||||
return (
|
||||
<div
|
||||
key={apply.id}
|
||||
className={cn(
|
||||
'admin-links-pending-row',
|
||||
reciprocalCheckEnabled && !isReciprocalChecking(apply) && !apply.reciprocal_verified && 'admin-links-pending-row--warn',
|
||||
)}
|
||||
>
|
||||
<ApplyLogo apply={apply} className="admin-links-pending-logo" siteURL={siteURL} />
|
||||
<div className="admin-links-pending-main">
|
||||
<div className="admin-links-pending-title">
|
||||
<strong>{apply.name}</strong>
|
||||
<a href={apply.url} target="_blank" rel="noopener noreferrer">{apply.url}</a>
|
||||
</div>
|
||||
<p className="admin-links-pending-reciprocal">
|
||||
回链地址:
|
||||
<ReciprocalAddress apply={apply} />
|
||||
</p>
|
||||
<p className="admin-links-pending-meta">
|
||||
<Badge variant={reciprocal.variant}>{reciprocal.text}</Badge>
|
||||
{' · '}
|
||||
{renderApplicant(apply)}
|
||||
{' · '}
|
||||
{formatTime(apply.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="admin-links-pending-actions">
|
||||
<Button type="button" size="sm" variant="outline" onClick={() => setDetailApply(apply)}>
|
||||
<Eye size={14} aria-hidden /> 查看
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderApplyTable = () => (
|
||||
<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>
|
||||
{applyStatus === 'rejected' || applyStatus === 'all' ? <th>备注</th> : null}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{applies.map(apply => {
|
||||
const reciprocal = reciprocalStatusLabel(apply);
|
||||
return (
|
||||
<tr key={apply.id}>
|
||||
<td>{apply.id}</td>
|
||||
<td className="admin-links-table-site">
|
||||
<div className="admin-links-table-site__head">
|
||||
<ApplyLogo apply={apply} siteURL={siteURL} />
|
||||
<div>
|
||||
<strong>{apply.name}</strong>
|
||||
<a href={apply.url} target="_blank" rel="noopener noreferrer">{apply.url}</a>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td>{renderApplicant(apply)}</td>
|
||||
<td className="admin-links-table-reciprocal"><ReciprocalAddress apply={apply} /></td>
|
||||
<td><Badge variant={reciprocal.variant}>{reciprocal.text}</Badge></td>
|
||||
<td>{applyStatusBadge(apply.status)}</td>
|
||||
<td className="text-sm whitespace-nowrap">{formatTime(apply.created_at)}</td>
|
||||
{(applyStatus === 'rejected' || applyStatus === 'all') && (
|
||||
<td className="text-sm text-muted-foreground max-w-[200px]">
|
||||
{apply.review_note?.trim() || '—'}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-page admin-links-page">
|
||||
<header className="admin-page-head admin-links-page-head">
|
||||
<div>
|
||||
<h1><Link2 size={20} aria-hidden /> 友情链接</h1>
|
||||
<p>管理已发布友链(最多 20 条)并审核用户申请;通过后将展示在友情链接页面</p>
|
||||
</div>
|
||||
<div className="admin-links-page-head__actions">
|
||||
<Button type="button" variant="outline" asChild>
|
||||
<Link to="/links" target="_blank" rel="noopener noreferrer">
|
||||
<ExternalLink size={14} aria-hidden />
|
||||
预览友链页
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={saving || !isDirty}
|
||||
onClick={save}
|
||||
>
|
||||
{saving ? '保存中…' : isDirty ? '保存友链' : '已是最新'}
|
||||
</Button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="admin-links-columns">
|
||||
<div className="admin-card admin-links-apply-card">
|
||||
<div className="admin-card-head">
|
||||
<div className="admin-links-apply-head">
|
||||
<span>申请审核</span>
|
||||
{pendingCount > 0 && <Badge variant="orange">{pendingCount}</Badge>}
|
||||
</div>
|
||||
<div className="admin-links-reciprocal-toggle">
|
||||
<span id="admin-links-reciprocal-label">回链检测</span>
|
||||
<button
|
||||
type="button"
|
||||
id="admin-links-reciprocal-switch"
|
||||
role="switch"
|
||||
aria-checked={reciprocalCheckEnabled}
|
||||
aria-labelledby="admin-links-reciprocal-label"
|
||||
disabled={reciprocalCheckSaving}
|
||||
className={cn('admin-settings-switch', reciprocalCheckEnabled && 'is-on')}
|
||||
onClick={toggleReciprocalCheck}
|
||||
>
|
||||
<span className="admin-settings-switch-ui" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<p className="admin-card-desc">
|
||||
{reciprocalCheckEnabled
|
||||
? '回链检测结果仅供参考,未检测到回链仍可手动通过。'
|
||||
: '回链检测已关闭,申请仍可手动审核通过。'}
|
||||
</p>
|
||||
<div className="admin-card-body admin-links-apply-body">
|
||||
<div className="admin-tabs">
|
||||
{applyTabs.map(tab => (
|
||||
<button
|
||||
key={tab.key}
|
||||
type="button"
|
||||
className={cn('admin-tab', applyStatus === tab.key && 'active')}
|
||||
onClick={() => {
|
||||
setApplyStatus(tab.key);
|
||||
setApplyPage(1);
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="admin-links-apply-scroll">
|
||||
{appliesLoading ? (
|
||||
<div className="flex justify-center py-12"><Spinner size="lg" /></div>
|
||||
) : applies.length === 0 ? (
|
||||
<p className="admin-table-empty">
|
||||
{applyStatus === 'pending' ? '暂无待审申请' : '暂无记录'}
|
||||
</p>
|
||||
) : applyStatus === 'pending' ? (
|
||||
<div className="admin-links-pending-list">
|
||||
{applies.map(renderPendingCard)}
|
||||
</div>
|
||||
) : (
|
||||
renderApplyTable()
|
||||
)}
|
||||
</div>
|
||||
|
||||
{!appliesLoading && applyTotal > APPLY_PAGE_SIZE && (
|
||||
<div className="admin-pagination">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={applyPage <= 1}
|
||||
onClick={() => loadApplies(applyStatus, applyPage - 1)}
|
||||
>
|
||||
上一页
|
||||
</Button>
|
||||
<span>{applyPage} / {applyTotalPages}</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={applyPage >= applyTotalPages}
|
||||
onClick={() => loadApplies(applyStatus, applyPage + 1)}
|
||||
>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-card admin-links-published-card">
|
||||
<div className="admin-card-head">
|
||||
<span>已发布友链</span>
|
||||
<Badge variant="secondary">{links.filter(l => l.name.trim() && l.url.trim()).length}/20</Badge>
|
||||
</div>
|
||||
<p className="admin-card-desc">
|
||||
保存后立即生效;右侧栏展示可在
|
||||
<Link to="/admin/settings" className="admin-inline-link">系统设置 → 右侧栏组件</Link>
|
||||
中配置。
|
||||
</p>
|
||||
<div className="admin-card-body admin-links-editor">
|
||||
<div className="admin-links-published-scroll">
|
||||
{links.length > 0 && (
|
||||
<div className="admin-links-table-head">
|
||||
<span>排序</span>
|
||||
<span>图标</span>
|
||||
<span>名称</span>
|
||||
<span>链接</span>
|
||||
<span>LOGO 地址</span>
|
||||
<span />
|
||||
</div>
|
||||
)}
|
||||
<AdminSortableList
|
||||
items={links}
|
||||
getId={link => link._key}
|
||||
onReorder={setLinks}
|
||||
showMoveButtons="auto"
|
||||
className="admin-sortable-list admin-links-table-body"
|
||||
ariaLabel="已发布友链"
|
||||
renderItem={(link, idx, controls) => (
|
||||
<div
|
||||
ref={controls.setNodeRef}
|
||||
style={controls.style}
|
||||
className={cn('admin-links-row', controls.isDragging && 'is-dragging')}
|
||||
>
|
||||
<div className="admin-links-row__order">
|
||||
<SortableDragHandle label={`拖拽调整「${link.name || '友链'}」顺序`} {...controls.dragHandleProps} />
|
||||
{showMoveButtons && (
|
||||
<SortableMoveButtons controls={controls} className="admin-links-row__move" />
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-links-row__preview">
|
||||
{resolveFriendLinkLogo(link.logo, siteURL) ? (
|
||||
<img src={resolveFriendLinkLogo(link.logo, siteURL)} alt="" loading="lazy" decoding="async" />
|
||||
) : (
|
||||
<span>{linkInitial(link.name)}</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="admin-links-field admin-links-field--name">
|
||||
<Label htmlFor={`link-name-${link._key}`} className="sr-only">名称</Label>
|
||||
<Input
|
||||
id={`link-name-${link._key}`}
|
||||
placeholder="站点名称"
|
||||
value={link.name}
|
||||
onChange={e => {
|
||||
const next = [...links];
|
||||
next[idx] = { ...next[idx], name: e.target.value };
|
||||
setLinks(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-links-field admin-links-field--url">
|
||||
<Label htmlFor={`link-url-${link._key}`} className="sr-only">链接</Label>
|
||||
<Input
|
||||
id={`link-url-${link._key}`}
|
||||
placeholder="https://..."
|
||||
value={link.url}
|
||||
onChange={e => {
|
||||
const next = [...links];
|
||||
next[idx] = { ...next[idx], url: e.target.value };
|
||||
setLinks(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-links-field admin-links-field--logo">
|
||||
<Label htmlFor={`link-logo-${link._key}`} className="sr-only">LOGO 地址</Label>
|
||||
<Input
|
||||
id={`link-logo-${link._key}`}
|
||||
placeholder="LOGO 地址(可选)"
|
||||
value={link.logo ?? ''}
|
||||
onChange={e => {
|
||||
const next = [...links];
|
||||
next[idx] = { ...next[idx], logo: e.target.value };
|
||||
setLinks(next);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-links-row__actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setLinks(links.filter((_, i) => i !== idx))}
|
||||
aria-label={`删除「${link.name || '友链'}」`}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
{links.length === 0 && <p className="admin-table-empty">暂无友情链接,可手动添加或审核通过用户申请</p>}
|
||||
</div>
|
||||
<div className="admin-links-editor__foot">
|
||||
<Button type="button" variant="outline" disabled={links.length >= 20} onClick={addLinkRow}>
|
||||
<Plus size={14} aria-hidden /> 添加链接
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" disabled={!isDirty} onClick={resetLinks}>
|
||||
<RotateCcw size={14} aria-hidden /> 放弃更改
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Dialog open={!!detailApply} onOpenChange={open => { if (!open) setDetailApply(null); }}>
|
||||
<DialogContent className="admin-links-apply-dialog sm:max-w-[520px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>友链申请详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
确认信息无误后再通过;通过后将写入已发布友链列表。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
{detailApply && (
|
||||
<div className="admin-links-apply-detail">
|
||||
<div className="admin-links-apply-detail__preview">
|
||||
<ApplyLogo apply={detailApply} siteURL={siteURL} />
|
||||
<div>
|
||||
<strong>{detailApply.name}</strong>
|
||||
<a href={detailApply.url} target="_blank" rel="noopener noreferrer">{detailApply.url}</a>
|
||||
</div>
|
||||
</div>
|
||||
<dl className="admin-links-apply-detail__grid">
|
||||
<div><dt>申请人</dt><dd>{renderApplicant(detailApply)}</dd></div>
|
||||
<div><dt>提交时间</dt><dd>{formatTime(detailApply.created_at)}</dd></div>
|
||||
<div><dt>回链地址</dt><dd>
|
||||
<ReciprocalAddress apply={detailApply} />
|
||||
</dd></div>
|
||||
<div><dt>回链检测</dt><dd>
|
||||
<Badge variant={reciprocalStatusLabel(detailApply).variant}>
|
||||
{reciprocalStatusLabel(detailApply).text}
|
||||
</Badge>
|
||||
</dd></div>
|
||||
{detailApply.logo?.trim() && (
|
||||
<div><dt>LOGO</dt><dd className="admin-links-apply-detail__logo-url">{detailApply.logo}</dd></div>
|
||||
)}
|
||||
</dl>
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter className="admin-links-apply-dialog__footer">
|
||||
<Button type="button" variant="outline" onClick={() => setDetailApply(null)}>关闭</Button>
|
||||
{detailApply && (
|
||||
<>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={handlingId === detailApply.id || isReciprocalChecking(detailApply) || !reciprocalCheckEnabled}
|
||||
title={!reciprocalCheckEnabled ? '回链检测已关闭' : undefined}
|
||||
onClick={() => recheckReciprocal(detailApply)}
|
||||
>
|
||||
<RotateCcw size={14} aria-hidden />
|
||||
{handlingId === detailApply.id ? '处理中…' : '重新检测'}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={handlingId === detailApply.id}
|
||||
onClick={() => openReject(detailApply)}
|
||||
>
|
||||
<X size={14} aria-hidden /> 拒绝
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={handlingId === detailApply.id}
|
||||
onClick={() => approveApply(detailApply)}
|
||||
>
|
||||
<Check size={14} aria-hidden />
|
||||
{handlingId === detailApply.id ? '处理中…' : '确认通过'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<Dialog open={!!rejectTarget} onOpenChange={open => { if (!open) setRejectTarget(null); }}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>拒绝友链申请</DialogTitle>
|
||||
<DialogDescription>
|
||||
{rejectTarget ? `拒绝「${rejectTarget.name}」的申请,可选填原因通知申请人。` : ''}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Textarea
|
||||
value={rejectNote}
|
||||
onChange={e => setRejectNote(e.target.value)}
|
||||
placeholder="拒绝原因(可选)"
|
||||
maxLength={256}
|
||||
rows={4}
|
||||
/>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => setRejectTarget(null)}>取消</Button>
|
||||
<Button type="button" disabled={rejecting} onClick={submitReject}>
|
||||
{rejecting ? '提交中…' : '确认拒绝'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
251
frontend/src/pages/admin/AdminPagesPage.tsx
Normal file
251
frontend/src/pages/admin/AdminPagesPage.tsx
Normal file
@@ -0,0 +1,251 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { FileText, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import {
|
||||
Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import ArticleEditor from '../../components/ArticleEditor';
|
||||
import { notify } from '@/lib/notify';
|
||||
import { api } from '../../api/client';
|
||||
import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import type { SitePage } from '../../api/types';
|
||||
import { invalidateSitePagesCache } from '../../hooks/useSitePages';
|
||||
import AdminSortableList, { SortableDragHandle, SortableMoveButtons } from '../../components/admin/AdminSortableList';
|
||||
import { persistSortOrderChanges, shouldShowSortableMoveButtons } from '../../utils/sortOrder';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const EMPTY: Partial<SitePage> = {
|
||||
title: '',
|
||||
slug: '',
|
||||
content: '',
|
||||
published: false,
|
||||
sort_order: 0,
|
||||
show_in_footer: true,
|
||||
show_in_nav: false,
|
||||
};
|
||||
|
||||
function slugify(title: string): string {
|
||||
const s = title.trim().toLowerCase()
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^a-z0-9-]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-|-$/g, '');
|
||||
return s.slice(0, 64);
|
||||
}
|
||||
|
||||
/** 后台:自定义单页管理 */
|
||||
export default function AdminPagesPage() {
|
||||
const { ready } = useAdminGuard();
|
||||
const [rows, setRows] = useState<SitePage[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [form, setForm] = useState<Partial<SitePage>>({ ...EMPTY });
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [reordering, setReordering] = useState(false);
|
||||
|
||||
const load = () => {
|
||||
setLoading(true);
|
||||
api.adminPages()
|
||||
.then(d => setRows(d.pages ?? []))
|
||||
.catch(e => notify.error(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
useEffect(() => { if (ready) load(); }, [ready]);
|
||||
|
||||
const openCreate = () => {
|
||||
setEditingId(null);
|
||||
setForm({ ...EMPTY });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const openEdit = (p: SitePage) => {
|
||||
setEditingId(p.id);
|
||||
setForm({ ...p });
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editingId) {
|
||||
await api.adminUpdatePage(editingId, form);
|
||||
notify.success('单页已更新');
|
||||
} else {
|
||||
await api.adminCreatePage(form);
|
||||
notify.success('单页已创建');
|
||||
}
|
||||
invalidateSitePagesCache();
|
||||
setDialogOpen(false);
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const remove = async (id: number) => {
|
||||
if (!window.confirm('确定删除该单页?')) return;
|
||||
try {
|
||||
await api.adminDeletePage(id);
|
||||
invalidateSitePagesCache();
|
||||
notify.success('已删除');
|
||||
load();
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handlePageReorder = async (reordered: SitePage[]) => {
|
||||
const before = [...rows];
|
||||
setReordering(true);
|
||||
try {
|
||||
const after = await persistSortOrderChanges(before, reordered, page =>
|
||||
api.adminUpdatePage(page.id, { sort_order: page.sort_order }),
|
||||
);
|
||||
setRows(after);
|
||||
invalidateSitePagesCache();
|
||||
notify.success('单页排序已更新');
|
||||
} catch (e: unknown) {
|
||||
setRows(before);
|
||||
notify.error(e instanceof Error ? e.message : '排序保存失败');
|
||||
} finally {
|
||||
setReordering(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showMoveButtons = shouldShowSortableMoveButtons(rows.length);
|
||||
|
||||
if (!ready) return null;
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<header className="admin-page-head">
|
||||
<div>
|
||||
<h1><FileText size={20} aria-hidden /> 单页管理</h1>
|
||||
<p>创建「关于我们」「版规」等独立页面</p>
|
||||
</div>
|
||||
<Button onClick={openCreate}><Plus size={16} /> 新建单页</Button>
|
||||
</header>
|
||||
|
||||
{loading ? <Spinner /> : (
|
||||
<div className="admin-table-wrap">
|
||||
<table className="admin-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="w-[72px]">排序</th>
|
||||
<th>标题</th>
|
||||
<th>Slug</th>
|
||||
<th>权重</th>
|
||||
<th>发布</th>
|
||||
<th>展示</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
{rows.length === 0 ? (
|
||||
<tbody>
|
||||
<tr><td colSpan={7} className="admin-table-empty">暂无单页</td></tr>
|
||||
</tbody>
|
||||
) : (
|
||||
<AdminSortableList
|
||||
as="tbody"
|
||||
items={rows}
|
||||
getId={p => p.id}
|
||||
onReorder={handlePageReorder}
|
||||
showMoveButtons="auto"
|
||||
renderItem={(p, _index, controls) => (
|
||||
<tr
|
||||
ref={controls.setNodeRef}
|
||||
style={controls.style}
|
||||
className={cn('admin-sortable-table-row', controls.isDragging && 'is-dragging')}
|
||||
>
|
||||
<td>
|
||||
<div className="flex items-center gap-0">
|
||||
<SortableDragHandle label={`拖拽调整「${p.title}」顺序`} {...controls.dragHandleProps} />
|
||||
{showMoveButtons && <SortableMoveButtons controls={controls} />}
|
||||
</div>
|
||||
</td>
|
||||
<td>{p.title}</td>
|
||||
<td><code>/page/{p.slug}</code></td>
|
||||
<td>{p.sort_order}</td>
|
||||
<td>{p.published ? '是' : '否'}</td>
|
||||
<td>{[p.show_in_footer && '页脚', p.show_in_nav && '导航'].filter(Boolean).join('、') || '—'}</td>
|
||||
<td className="admin-table-actions">
|
||||
<Button variant="ghost" size="sm" onClick={() => openEdit(p)} disabled={reordering}><Pencil size={14} /></Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => remove(p.id)} disabled={reordering}><Trash2 size={14} /></Button>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="admin-page-dialog">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editingId ? '编辑单页' : '新建单页'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="admin-form-grid">
|
||||
<div>
|
||||
<Label htmlFor="page-title">标题</Label>
|
||||
<Input
|
||||
id="page-title"
|
||||
value={form.title ?? ''}
|
||||
onChange={e => {
|
||||
const title = e.target.value;
|
||||
setForm(f => ({
|
||||
...f,
|
||||
title,
|
||||
slug: f.slug || slugify(title),
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Label htmlFor="page-slug">Slug(URL 路径)</Label>
|
||||
<Input
|
||||
id="page-slug"
|
||||
value={form.slug ?? ''}
|
||||
onChange={e => setForm(f => ({ ...f, slug: e.target.value }))}
|
||||
placeholder="about-us"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-row">
|
||||
<Label htmlFor="page-sort">排序</Label>
|
||||
<Input
|
||||
id="page-sort"
|
||||
type="number"
|
||||
value={form.sort_order ?? 0}
|
||||
onChange={e => setForm(f => ({ ...f, sort_order: Number(e.target.value) || 0 }))}
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-form-switches">
|
||||
<label><Switch checked={!!form.published} onCheckedChange={v => setForm(f => ({ ...f, published: v }))} /> 发布</label>
|
||||
<label><Switch checked={!!form.show_in_footer} onCheckedChange={v => setForm(f => ({ ...f, show_in_footer: v }))} /> 页脚展示</label>
|
||||
<label><Switch checked={!!form.show_in_nav} onCheckedChange={v => setForm(f => ({ ...f, show_in_nav: v }))} /> 侧栏导航</label>
|
||||
</div>
|
||||
<div>
|
||||
<Label>正文</Label>
|
||||
<ArticleEditor
|
||||
value={form.content ?? ''}
|
||||
onChange={html => setForm(f => ({ ...f, content: html }))}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button disabled={saving} onClick={save}>{saving ? '保存中…' : '保存'}</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,7 +10,10 @@ import { useAdminGuard } from '../../layouts/AdminLayout';
|
||||
import { invalidateForumLimitsCache } from '../../hooks/useForumLimits';
|
||||
import { DEFAULT_BRANDING, seedSiteBrandingCache } from '../../hooks/useSiteBranding';
|
||||
import { clearAllFeedCache } from '../../utils/feedCache';
|
||||
import type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, StorageConfig, SiteBranding, FriendLink } from '../../api/types';
|
||||
import { normalizeAsideWidgets, resolveAsideWidgets, mergeForumLimitsWithAsideWidgets, resolveSavedAsideWidgets } from '../../utils/asideWidgets';
|
||||
import AsideWidgetList from '../../components/admin/AsideWidgetList';
|
||||
import type { AdminSettings, ForumLimits, MailConfig, OIDCConfig, OAuthClient, GiteaSyncConfig, StorageConfig, SiteBranding, AsideWidget } from '../../api/types';
|
||||
import { DEFAULT_ASIDE_WIDGETS } from '../../api/types';
|
||||
|
||||
type TabId = 'branding' | 'limits' | 'mail' | 'oidc' | 'gitea' | 'storage' | 'filter' | 'system';
|
||||
|
||||
@@ -88,7 +91,9 @@ const SETTING_SECTIONS: SettingSection[] = [
|
||||
},
|
||||
];
|
||||
|
||||
type BoolLimitKey = 'open_posts_in_new_tab' | 'open_content_links_in_new_tab';
|
||||
type BoolLimitKey =
|
||||
| 'open_posts_in_new_tab'
|
||||
| 'open_content_links_in_new_tab';
|
||||
|
||||
const NAV_TOGGLES: { key: BoolLimitKey; label: string; hint: string }[] = [
|
||||
{
|
||||
@@ -241,6 +246,7 @@ export default function AdminSettingsPage() {
|
||||
const { ready } = useAdminGuard();
|
||||
const [settings, setSettings] = useState<AdminSettings | null>(null);
|
||||
const [limits, setLimits] = useState<ForumLimits | null>(null);
|
||||
const [asideWidgets, setAsideWidgets] = useState<AsideWidget[]>(DEFAULT_ASIDE_WIDGETS);
|
||||
const [branding, setBranding] = useState<SiteBranding>(DEFAULT_BRANDING);
|
||||
const [mail, setMail] = useState<MailConfig>(EMPTY_MAIL);
|
||||
const [oidc, setOidc] = useState<OIDCConfig>(EMPTY_OIDC);
|
||||
@@ -277,13 +283,28 @@ export default function AdminSettingsPage() {
|
||||
api.adminSettings()
|
||||
.then(s => {
|
||||
setSettings(s);
|
||||
setLimits({
|
||||
const loadedAsideWidgets = normalizeAsideWidgets(
|
||||
resolveAsideWidgets({
|
||||
aside_widgets: s.limits?.aside_widgets,
|
||||
aside_show_tag_cloud: s.limits?.aside_show_tag_cloud ?? false,
|
||||
aside_show_recent_comments: s.limits?.aside_show_recent_comments ?? false,
|
||||
aside_show_friend_links: s.limits?.aside_show_friend_links ?? true,
|
||||
}),
|
||||
);
|
||||
setAsideWidgets(loadedAsideWidgets);
|
||||
const baseLimits: ForumLimits = {
|
||||
open_posts_in_new_tab: true,
|
||||
open_content_links_in_new_tab: true,
|
||||
aside_show_tag_cloud: false,
|
||||
aside_show_recent_comments: false,
|
||||
aside_show_friend_links: true,
|
||||
aside_widgets: loadedAsideWidgets,
|
||||
feed_list_style: 'title',
|
||||
permalink_enabled: false,
|
||||
permalink_ext: 'html',
|
||||
...s.limits,
|
||||
});
|
||||
};
|
||||
setLimits(mergeForumLimitsWithAsideWidgets(baseLimits, loadedAsideWidgets));
|
||||
setBranding({ ...DEFAULT_BRANDING, ...(s.branding ?? {}) });
|
||||
setMail({ ...EMPTY_MAIL, ...s.mail, password: '' });
|
||||
setOidc({ ...EMPTY_OIDC, ...(s.oidc ?? {}) });
|
||||
@@ -306,6 +327,20 @@ export default function AdminSettingsPage() {
|
||||
setLimits(prev => prev ? { ...prev, [key]: checked } : prev);
|
||||
};
|
||||
|
||||
const handleAsideWidgetsChange = (widgets: AsideWidget[]) => {
|
||||
const normalized = normalizeAsideWidgets(widgets);
|
||||
setAsideWidgets(normalized);
|
||||
setLimits(prev => prev ? mergeForumLimitsWithAsideWidgets(prev, normalized) : prev);
|
||||
};
|
||||
|
||||
const applySavedForumLimits = (savedWidgets: AsideWidget[], response: ForumLimits): ForumLimits => {
|
||||
const nextWidgets = resolveSavedAsideWidgets(savedWidgets, response.aside_widgets);
|
||||
const merged = mergeForumLimitsWithAsideWidgets(response, nextWidgets);
|
||||
setAsideWidgets(nextWidgets);
|
||||
setLimits(merged);
|
||||
return merged;
|
||||
};
|
||||
|
||||
const applyBranding = (next: SiteBranding) => {
|
||||
setBranding({ ...DEFAULT_BRANDING, ...next });
|
||||
setSettings(s => s ? { ...s, branding: next } : s);
|
||||
@@ -314,21 +349,15 @@ export default function AdminSettingsPage() {
|
||||
|
||||
const handleSaveBranding = async () => {
|
||||
if (!limits) return;
|
||||
const links = (branding.friend_links ?? [])
|
||||
.map(l => ({ name: l.name.trim(), url: l.url.trim() }))
|
||||
.filter(l => l.name || l.url);
|
||||
if (links.some(l => !l.name || !l.url)) {
|
||||
notify.warning('友情链接需同时填写名称与完整 URL');
|
||||
return;
|
||||
}
|
||||
setSavingBranding(true);
|
||||
try {
|
||||
const r = await api.adminUpdateBranding({ ...branding, friend_links: links });
|
||||
const r = await api.adminUpdateBranding(branding);
|
||||
applyBranding(r.branding);
|
||||
// 伪静态与品牌同属站点呈现,一并保存
|
||||
const forum = await api.adminUpdateForumSettings(limits);
|
||||
setLimits(forum.limits);
|
||||
invalidateForumLimitsCache();
|
||||
const forumPayload = mergeForumLimitsWithAsideWidgets(limits, asideWidgets);
|
||||
// 伪静态、右侧栏、浏览与链接、列表呈现同属站点呈现,一并保存
|
||||
const forum = await api.adminUpdateForumSettings(forumPayload);
|
||||
applySavedForumLimits(asideWidgets, forum.limits);
|
||||
clearAllFeedCache();
|
||||
notify.success('站点设置已保存');
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
@@ -368,12 +397,13 @@ export default function AdminSettingsPage() {
|
||||
if (!limits) return;
|
||||
setSavingForum(true);
|
||||
try {
|
||||
const r = await api.adminUpdateForumSettings(limits);
|
||||
const forumPayload = mergeForumLimitsWithAsideWidgets(limits, asideWidgets);
|
||||
const r = await api.adminUpdateForumSettings(forumPayload);
|
||||
notify.success(r.message);
|
||||
invalidateForumLimitsCache();
|
||||
clearAllFeedCache();
|
||||
setLimits(r.limits);
|
||||
setSettings(s => s ? { ...s, limits: r.limits } : s);
|
||||
const merged = applySavedForumLimits(asideWidgets, r.limits);
|
||||
setSettings(s => s ? { ...s, limits: merged } : s);
|
||||
} catch (e: unknown) {
|
||||
notify.error(e instanceof Error ? e.message : '保存失败');
|
||||
} finally {
|
||||
@@ -645,7 +675,7 @@ export default function AdminSettingsPage() {
|
||||
<section className="admin-settings-section" id="settings-brand-identity">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>品牌标识</h3>
|
||||
<p>名称、标语、简介与字标;简介用于首页展示与搜索引擎 description</p>
|
||||
<p>名称、标语、简介与字标;简介用于右侧栏顶部展示与搜索引擎 description</p>
|
||||
</div>
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
@@ -691,7 +721,7 @@ export default function AdminSettingsPage() {
|
||||
rows={3}
|
||||
/>
|
||||
<span className="admin-mail-field-hint">
|
||||
用于右侧栏介绍与 SEO description;未填写时回退到标语(建议 80–160 字)
|
||||
用于右侧栏顶部论坛简介与 SEO description;未填写时回退到标语(建议 80–160 字)
|
||||
</span>
|
||||
</div>
|
||||
<div className="admin-mail-field admin-mail-field--span2">
|
||||
@@ -810,7 +840,7 @@ export default function AdminSettingsPage() {
|
||||
<section className="admin-settings-section" id="settings-brand-footer">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>页脚信息</h3>
|
||||
<p>备案号与友情链接,显示在站点底部</p>
|
||||
<p>备案号显示在站点底部;友情链接请在「社区 → 友情链接」管理</p>
|
||||
</div>
|
||||
<div className="admin-mail-grid">
|
||||
<div className="admin-mail-field">
|
||||
@@ -835,69 +865,81 @@ export default function AdminSettingsPage() {
|
||||
<span className="admin-mail-field-hint">留空则用工信部默认页</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-friend-links" style={{ marginTop: 12 }}>
|
||||
<div className="admin-friend-links-list">
|
||||
{(branding.friend_links ?? []).map((link, idx) => (
|
||||
<div key={idx} className="admin-friend-links-row">
|
||||
<Input
|
||||
value={link.name}
|
||||
placeholder="友链名称"
|
||||
maxLength={32}
|
||||
onChange={e => {
|
||||
const name = e.target.value;
|
||||
setBranding(b => {
|
||||
const next = [...(b.friend_links ?? [])];
|
||||
next[idx] = { ...next[idx], name };
|
||||
return { ...b, friend_links: next };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={link.url}
|
||||
placeholder="https://example.com"
|
||||
maxLength={512}
|
||||
onChange={e => {
|
||||
const url = e.target.value;
|
||||
setBranding(b => {
|
||||
const next = [...(b.friend_links ?? [])];
|
||||
next[idx] = { ...next[idx], url };
|
||||
return { ...b, friend_links: next };
|
||||
});
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
</section>
|
||||
|
||||
<section className="admin-settings-section" id="settings-aside">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>右侧栏组件</h3>
|
||||
<p>「站点简介」固定在最上方;以下模块可拖拽排序,保存后同步影响桌面右侧栏与手机「社区动态」抽屉</p>
|
||||
</div>
|
||||
<AsideWidgetList
|
||||
widgets={asideWidgets}
|
||||
onChange={handleAsideWidgetsChange}
|
||||
/>
|
||||
</section>
|
||||
|
||||
<section className="admin-settings-section" id="settings-nav">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>浏览与链接</h3>
|
||||
<p>控制打开帖子与正文链接时是否新开浏览器标签页</p>
|
||||
</div>
|
||||
<div className="admin-settings-table" role="group" aria-label="浏览与链接">
|
||||
{NAV_TOGGLES.map(row => (
|
||||
<div key={row.key} className="admin-settings-row">
|
||||
<span className="admin-settings-row-label" id={`limit-label-${row.key}`}>
|
||||
{row.label}
|
||||
</span>
|
||||
<div className="admin-settings-row-input">
|
||||
<button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setBranding(b => ({
|
||||
...b,
|
||||
friend_links: (b.friend_links ?? []).filter((_, i) => i !== idx),
|
||||
}));
|
||||
}}
|
||||
id={`limit-${row.key}`}
|
||||
role="switch"
|
||||
aria-checked={!!limits[row.key]}
|
||||
aria-labelledby={`limit-label-${row.key}`}
|
||||
className={`admin-settings-switch${limits[row.key] ? ' is-on' : ''}`}
|
||||
onClick={() => handleBoolLimitChange(row.key, !limits[row.key])}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
<span className="admin-settings-switch-ui" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
<span className="admin-settings-row-hint">{row.hint}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="admin-settings-section" id="settings-feed-list">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>列表呈现</h3>
|
||||
<p>首页及帖子列表的信息密度与缩略图展示</p>
|
||||
</div>
|
||||
<div className="admin-settings-table" role="group" aria-label="列表呈现">
|
||||
<div className="admin-settings-row">
|
||||
<span className="admin-settings-row-label" id="limit-label-feed_list_style">
|
||||
帖子列表样式
|
||||
</span>
|
||||
<div className="admin-settings-row-input admin-settings-row-input--stack">
|
||||
<div className="admin-permalink-presets">
|
||||
{([
|
||||
{ value: 'title' as const, label: '仅标题' },
|
||||
{ value: 'excerpt' as const, label: '标题+摘要' },
|
||||
{ value: 'thumbnail' as const, label: '标题+摘要+缩略图' },
|
||||
]).map(opt => (
|
||||
<button
|
||||
key={opt.value}
|
||||
type="button"
|
||||
className={`admin-permalink-chip${limits.feed_list_style === opt.value ? ' is-active' : ''}`}
|
||||
onClick={() => setLimits(prev => prev ? { ...prev, feed_list_style: opt.value } : prev)}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<span className="admin-settings-row-hint">
|
||||
仅标题最紧凑;摘要模式增加一行预览;缩略图模式在有站内配图时右侧显示封面
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={(branding.friend_links?.length ?? 0) >= 20}
|
||||
onClick={() => {
|
||||
setBranding(b => ({
|
||||
...b,
|
||||
friend_links: [...(b.friend_links ?? []), { name: '', url: '' } as FriendLink],
|
||||
}));
|
||||
}}
|
||||
>
|
||||
添加友链
|
||||
</Button>
|
||||
<span className="admin-mail-field-hint" style={{ display: 'block', marginTop: 8 }}>
|
||||
最多 20 条,需填写完整 http(s) 地址
|
||||
</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -968,7 +1010,7 @@ export default function AdminSettingsPage() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-settings-bar">
|
||||
<p>保存后立即影响顶栏、页脚、伪静态链接与浏览器标题</p>
|
||||
<p>保存后立即影响顶栏、页脚、列表样式、链接打开方式、伪静态链接与浏览器标题</p>
|
||||
<Button onClick={handleSaveBranding} loading={savingBranding}>
|
||||
保存站点设置
|
||||
</Button>
|
||||
@@ -981,39 +1023,10 @@ export default function AdminSettingsPage() {
|
||||
<div className="admin-card admin-settings-card">
|
||||
<div className="admin-card-head">
|
||||
<span>论坛限制</span>
|
||||
<span className="admin-settings-card-badge">共 {SETTING_SECTIONS.length + 1} 组</span>
|
||||
<span className="admin-settings-card-badge">共 {SETTING_SECTIONS.length} 组</span>
|
||||
</div>
|
||||
<div className="admin-card-body">
|
||||
<SettingTable sections={SETTING_SECTIONS} limits={limits} onChange={handleLimitChange} />
|
||||
<section className="admin-settings-section" id="settings-nav">
|
||||
<div className="admin-settings-section-head">
|
||||
<h3>浏览与链接</h3>
|
||||
<p>控制打开帖子与正文链接时是否新开浏览器标签页</p>
|
||||
</div>
|
||||
<div className="admin-settings-table" role="group" aria-label="浏览与链接">
|
||||
{NAV_TOGGLES.map(row => (
|
||||
<div key={row.key} className="admin-settings-row">
|
||||
<span className="admin-settings-row-label" id={`limit-label-${row.key}`}>
|
||||
{row.label}
|
||||
</span>
|
||||
<div className="admin-settings-row-input">
|
||||
<button
|
||||
type="button"
|
||||
id={`limit-${row.key}`}
|
||||
role="switch"
|
||||
aria-checked={!!limits[row.key]}
|
||||
aria-labelledby={`limit-label-${row.key}`}
|
||||
className={`admin-settings-switch${limits[row.key] ? ' is-on' : ''}`}
|
||||
onClick={() => handleBoolLimitChange(row.key, !limits[row.key])}
|
||||
>
|
||||
<span className="admin-settings-switch-ui" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<span className="admin-settings-row-hint">{row.hint}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-settings-bar">
|
||||
|
||||
Reference in New Issue
Block a user