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

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