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 { ForumLimits, FriendLink, FriendLinkApply, SiteBranding } from '../../api/types'; import { DEFAULT_FEED_SORT_TABS } from '../../api/types'; import { useSiteBranding, seedSiteBrandingCache, invalidateSiteBrandingCache } from '../../hooks/useSiteBranding'; import { invalidateForumLimitsCache } from '../../hooks/useForumLimits'; import { formatTime } from '../../utils/content'; import { resolveFriendLinkLogo, isReciprocalChecking, reciprocalStatusLabel } from '../../utils/friendLink'; import { mergeForumLimitsWithAsideWidgets, normalizeAsideWidgets, resolveAsideWidgets, } from '../../utils/asideWidgets'; 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 待审核; case 'approved': return 已通过; case 'rejected': return 已拒绝; 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 (
); } return (
{linkInitial(apply.name)}
); } function ReciprocalAddress({ apply }: { apply: FriendLinkApply }) { const href = apply.reciprocal_page_url?.trim(); if (!href) { return 未填写; } return ( <> {href} {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([]); const [saving, setSaving] = useState(false); const [applyStatus, setApplyStatus] = useState('pending'); const [applies, setApplies] = useState([]); const [applyPage, setApplyPage] = useState(1); const [applyTotal, setApplyTotal] = useState(0); const [pendingCount, setPendingCount] = useState(0); const [appliesLoading, setAppliesLoading] = useState(true); const [detailApply, setDetailApply] = useState(null); const [rejectTarget, setRejectTarget] = useState(null); const [rejectNote, setRejectNote] = useState(''); const [rejecting, setRejecting] = useState(false); const [handlingId, setHandlingId] = useState(null); const [reciprocalCheckEnabled, setReciprocalCheckEnabled] = useState(false); const [reciprocalCheckSaving, setReciprocalCheckSaving] = useState(false); const [forumLimits, setForumLimits] = useState(null); const [entrySaving, setEntrySaving] = useState(false); const baselineLinks = branding.friend_links ?? []; const siteURL = branding.site_url; const isDirty = useMemo( () => !linksEqual(links, baselineLinks), [links, baselineLinks], ); const entryFlags = useMemo(() => { const widgets = resolveAsideWidgets({ aside_widgets: forumLimits?.aside_widgets, aside_show_tag_cloud: forumLimits?.aside_show_tag_cloud ?? false, aside_show_recent_comments: forumLimits?.aside_show_recent_comments ?? false, aside_show_friend_links: forumLimits?.aside_show_friend_links ?? true, }); return { nav: forumLimits?.nav_show_friend_links !== false, footer: forumLimits?.footer_show_friend_links !== false, aside: widgets.find(w => w.id === 'friend_links')?.enabled ?? true, }; }, [forumLimits]); useEffect(() => { setLinks(toLinkRows(baselineLinks)); }, [baselineLinks]); useEffect(() => { if (!ready) return; api.adminSettings() .then(s => { 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, }), ); const base: 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_show_showcase: false, aside_widgets: loadedAsideWidgets, nav_show_friend_links: true, footer_show_friend_links: true, nav_show_showcase: false, footer_show_showcase: false, feed_list_style: 'title', feed_sort_tabs: DEFAULT_FEED_SORT_TABS, permalink_enabled: false, permalink_ext: 'html', ...s.limits, }; setForumLimits(mergeForumLimitsWithAsideWidgets(base, loadedAsideWidgets)); }) .catch(() => { /* 入口开关稍后仍可按默认展示 */ }); }, [ready]); 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(); 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 patchEntryVisibility = async (patch: { nav?: boolean; footer?: boolean; aside?: boolean; }) => { if (entrySaving) return; const prev = { nav: entryFlags.nav, footer: entryFlags.footer, aside: entryFlags.aside, }; const nextFlags = { nav: patch.nav ?? prev.nav, footer: patch.footer ?? prev.footer, aside: patch.aside ?? prev.aside, }; // 乐观更新本地 limits,避免开关回弹 setForumLimits(fl => { if (!fl) { return { 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: nextFlags.aside, aside_show_showcase: false, aside_widgets: normalizeAsideWidgets([ { id: 'tag_cloud', enabled: false }, { id: 'recent_comments', enabled: false }, { id: 'recent_users', enabled: false }, { id: 'friend_links', enabled: nextFlags.aside }, { id: 'showcase', enabled: false }, ]), nav_show_friend_links: nextFlags.nav, footer_show_friend_links: nextFlags.footer, nav_show_showcase: false, footer_show_showcase: false, feed_list_style: 'title', feed_sort_tabs: DEFAULT_FEED_SORT_TABS, permalink_enabled: false, permalink_ext: 'html', post_edit_window_hours: 24, comment_edit_window_minutes: 3, rate_limit_post: 10, rate_limit_comment: 10, rate_limit_register: 10, rate_limit_login: 10, rate_limit_window_sec: 60, post_title_max: 128, post_tags_max: 256, post_content_max: 50000, comment_max: 5000, search_keyword_min: 1, search_keyword_max: 50, page_size_default: 30, password_min_len: 6, avatar_max_mb: 2, signature_max: 200, }; } const widgets = normalizeAsideWidgets(fl.aside_widgets).map(w => ( w.id === 'friend_links' ? { ...w, enabled: nextFlags.aside } : w )); return mergeForumLimitsWithAsideWidgets({ ...fl, nav_show_friend_links: nextFlags.nav, footer_show_friend_links: nextFlags.footer, }, widgets); }); setEntrySaving(true); try { const body: { nav_show_friend_links?: boolean; footer_show_friend_links?: boolean; aside_show_friend_links?: boolean; } = {}; if (patch.nav !== undefined) body.nav_show_friend_links = patch.nav; if (patch.footer !== undefined) body.footer_show_friend_links = patch.footer; if (patch.aside !== undefined) body.aside_show_friend_links = patch.aside; const r = await api.adminUpdateFriendLinkSettings(body); setForumLimits(fl => { const base = fl; if (!base) return fl; const widgets = normalizeAsideWidgets(base.aside_widgets).map(w => ( w.id === 'friend_links' ? { ...w, enabled: r.aside_show_friend_links } : w )); return mergeForumLimitsWithAsideWidgets({ ...base, nav_show_friend_links: r.nav_show_friend_links, footer_show_friend_links: r.footer_show_friend_links, aside_show_friend_links: r.aside_show_friend_links, }, widgets); }); invalidateForumLimitsCache(); notify.success(r.message); } catch (e: unknown) { setForumLimits(fl => { if (!fl) return fl; const widgets = normalizeAsideWidgets(fl.aside_widgets).map(w => ( w.id === 'friend_links' ? { ...w, enabled: prev.aside } : w )); return mergeForumLimitsWithAsideWidgets({ ...fl, nav_show_friend_links: prev.nav, footer_show_friend_links: prev.footer, }, widgets); }); notify.error(e instanceof Error ? e.message : '保存失败'); } finally { setEntrySaving(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 ( ); } return label; }; const renderPendingCard = (apply: FriendLinkApply) => { const reciprocal = reciprocalStatusLabel(apply); return (
{apply.name} {apply.url}

回链地址:

{reciprocal.text} {' · '} {renderApplicant(apply)} {' · '} {formatTime(apply.created_at)}

); }; const renderApplyTable = () => (
{applyStatus === 'rejected' || applyStatus === 'all' ? : null} {applies.map(apply => { const reciprocal = reciprocalStatusLabel(apply); return ( {(applyStatus === 'rejected' || applyStatus === 'all') && ( )} ); })}
ID 站点 申请人 回链地址 回链检测 状态 时间备注
{apply.id}
{apply.name} {apply.url}
{renderApplicant(apply)} {reciprocal.text} {applyStatusBadge(apply.status)} {formatTime(apply.created_at)} {apply.review_note?.trim() || '—'}
); if (!ready) return null; return (

友情链接

管理已发布友链(最多 20 条)并审核用户申请;通过后将展示在友情链接页面

入口展示

控制友情链接入口出现在何处;关闭后仍可直接访问 /links 页面申请与浏览。右侧栏开关与「系统设置 → 右侧栏组件」同源。

{( [ { key: 'nav' as const, label: '左侧栏(站点)', on: entryFlags.nav }, { key: 'footer' as const, label: '页脚', on: entryFlags.footer }, { key: 'aside' as const, label: '右侧栏', on: entryFlags.aside }, ] ).map(item => (
{item.label}
))}
申请审核 {pendingCount > 0 && {pendingCount}}
回链检测

{reciprocalCheckEnabled ? '回链检测结果仅供参考,未检测到回链仍可手动通过。' : '回链检测已关闭,申请仍可手动审核通过。'}

{applyTabs.map(tab => ( ))}
{appliesLoading ? (
) : applies.length === 0 ? (

{applyStatus === 'pending' ? '暂无待审申请' : '暂无记录'}

) : applyStatus === 'pending' ? (
{applies.map(renderPendingCard)}
) : ( renderApplyTable() )}
{!appliesLoading && applyTotal > APPLY_PAGE_SIZE && (
{applyPage} / {applyTotalPages}
)}
已发布友链 {links.filter(l => l.name.trim() && l.url.trim()).length}/20

保存后立即生效;右侧栏展示可在 系统设置 → 右侧栏组件 中配置。

{links.length > 0 && (
排序 图标 名称 链接 LOGO 地址
)} link._key} onReorder={setLinks} showMoveButtons="auto" className="admin-sortable-list admin-links-table-body" ariaLabel="已发布友链" renderItem={(link, idx, controls) => (
{showMoveButtons && ( )}
{resolveFriendLinkLogo(link.logo, siteURL) ? ( ) : ( {linkInitial(link.name)} )}
{ const next = [...links]; next[idx] = { ...next[idx], name: e.target.value }; setLinks(next); }} />
{ const next = [...links]; next[idx] = { ...next[idx], url: e.target.value }; setLinks(next); }} />
{ const next = [...links]; next[idx] = { ...next[idx], logo: e.target.value }; setLinks(next); }} />
)} /> {links.length === 0 &&

暂无友情链接,可手动添加或审核通过用户申请

}
{ if (!open) setDetailApply(null); }}> 友链申请详情 确认信息无误后再通过;通过后将写入已发布友链列表。 {detailApply && (
{detailApply.name} {detailApply.url}
申请人
{renderApplicant(detailApply)}
提交时间
{formatTime(detailApply.created_at)}
回链地址
回链检测
{reciprocalStatusLabel(detailApply).text}
{detailApply.logo?.trim() && (
LOGO
{detailApply.logo}
)}
)} {detailApply && ( <> )}
{ if (!open) setRejectTarget(null); }}> 拒绝友链申请 {rejectTarget ? `拒绝「${rejectTarget.name}」的申请,可选填原因通知申请人。` : ''}