Files
jiang13-forum/frontend/src/pages/admin/AdminLinksPage.tsx
freefire 9e134916a4 fix: 软刷新齐套前保留旧画面,避免一点击就卸光
Logo/下拉静默预热后一次覆盖;commit 不再先 reset/loading;顺带会话预取与可配置 Feed 排序标签。
2026-09-01 04:56:10 +08:00

915 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 <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 [forumLimits, setForumLimits] = useState<ForumLimits | null>(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<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 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 (
<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-card admin-links-entry-card">
<div className="admin-card-head"></div>
<p className="admin-card-desc">
访 /links
</p>
<div className="admin-card-body admin-links-entry-body">
{(
[
{ 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 => (
<div key={item.key} className="admin-links-entry-row">
<span id={`admin-links-entry-${item.key}`}>{item.label}</span>
<button
type="button"
role="switch"
aria-checked={item.on}
aria-labelledby={`admin-links-entry-${item.key}`}
disabled={entrySaving}
className={cn('admin-settings-switch', item.on && 'is-on')}
onClick={() => patchEntryVisibility({ [item.key]: !item.on })}
>
<span className="admin-settings-switch-ui" aria-hidden />
</button>
</div>
))}
</div>
</div>
<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>
);
}