feat: 增加友链申请、独立页面、投票/悬赏/抽奖帖与侧栏签到,并统一开发数据目录

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-27 06:23:54 +08:00
parent df19752a1e
commit 2208af7070
80 changed files with 9620 additions and 641 deletions

View File

@@ -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">

View File

@@ -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>

View File

@@ -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 />;
}

View 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>
);
}

View File

@@ -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>
);

View 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>
);
}

View File

@@ -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) => {

View File

@@ -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 (

View 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>
);
}

View 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>
);
}

View File

@@ -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">