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 (
回链地址:
{reciprocal.text}
{' · '}
{renderApplicant(apply)}
{' · '}
{formatTime(apply.created_at)}
);
};
const renderApplyTable = () => (
| ID |
站点 |
申请人 |
回链地址 |
回链检测 |
状态 |
时间 |
{applyStatus === 'rejected' || applyStatus === 'all' ? 备注 | : null}
{applies.map(apply => {
const reciprocal = reciprocalStatusLabel(apply);
return (
| {apply.id} |
|
{renderApplicant(apply)} |
|
{reciprocal.text} |
{applyStatusBadge(apply.status)} |
{formatTime(apply.created_at)} |
{(applyStatus === 'rejected' || applyStatus === 'all') && (
{apply.review_note?.trim() || '—'}
|
)}
);
})}
);
if (!ready) return null;
return (
入口展示
控制友情链接入口出现在何处;关闭后仍可直接访问 /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) => (
)}
/>
{links.length === 0 && 暂无友情链接,可手动添加或审核通过用户申请
}
);
}