Files
jiang13-bbs/frontend/app/admin/friend-links/FriendLinksAdmin.tsx
freefire ff2ab286fb feat: 书库导入导出/图片变体/书籍搜索/小组件运行时等
新增:
- 书库导入导出(library_import/library_export)及测试
- 图片变体生成(image_variants)与响应式图片(responsiveImage)
- 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件
- 小组件运行时(widgetRuntime)与静态检查(widgetLint)
- 上传缓存中间件(upload_cache)与字体 CSS 提取脚本

其它:
- 后端 handlers/services 全量调整
- 前端页面、组件、库函数与配置更新
2026-10-01 03:06:05 +08:00

497 lines
17 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.
"use client";
import { useMemo, useState } from "react";
import {
Check,
Link2,
Loader2,
Pencil,
Plus,
RotateCcw,
Trash2,
X,
} from "lucide-react";
import {
apiAdminCreateFriendLink,
apiAdminDeleteFriendLink,
apiAdminListFriendLinks,
apiAdminSetFriendLinkStatus,
apiAdminUpdateFriendLink,
type FriendLink,
type FriendLinkAdminInput,
type FriendLinkStatus,
} from "@/lib/api";
import { AdminPageHeader, AdminSettingsPage, AdminEmpty } from "@/components/admin";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
interface Props {
initialLinks: FriendLink[];
initError: string;
}
const FIELD_CLASS = "w-full rounded-lg px-3 py-2 text-[14px] outline-none transition-colors";
const FIELD_STYLE = {
background: "var(--panel-2)",
color: "var(--ink)",
border: "1px solid var(--line)",
};
const TABS: { key: FriendLinkStatus; label: string }[] = [
{ key: "pending", label: "待审批" },
{ key: "approved", label: "已通过" },
{ key: "rejected", label: "已拒绝" },
];
interface FormState {
name: string;
url: string;
description: string;
logo_url: string;
category: string;
reciprocal_url: string;
sort_order: number;
}
const EMPTY_FORM: FormState = {
name: "",
url: "",
description: "",
logo_url: "",
category: "",
reciprocal_url: "",
sort_order: 0,
};
export default function FriendLinksAdmin({ initialLinks, initError }: Props) {
const [links, setLinks] = useState<FriendLink[]>(initialLinks);
const [tab, setTab] = useState<FriendLinkStatus>("pending");
const [editorOpen, setEditorOpen] = useState(false);
const [editingId, setEditingId] = useState<number | null>(null);
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [saving, setSaving] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState(initError);
const counts = useMemo(() => {
const c = { pending: 0, approved: 0, rejected: 0 } as Record<FriendLinkStatus, number>;
for (const l of links) c[l.status] = (c[l.status] || 0) + 1;
return c;
}, [links]);
const filtered = useMemo(
() => links.filter((l) => l.status === tab).sort((a, b) => a.sort_order - b.sort_order || a.id - b.id),
[links, tab]
);
const refresh = async () => {
try {
const res = await apiAdminListFriendLinks();
setLinks(res.links ?? []);
setError("");
} catch (e) {
setError(e instanceof Error ? e.message : "加载失败");
}
};
const openCreate = () => {
setEditingId(null);
setForm(EMPTY_FORM);
setEditorOpen(true);
};
const openEdit = (l: FriendLink) => {
setEditingId(l.id);
setForm({
name: l.name,
url: l.url,
description: l.description || "",
logo_url: l.logo_url || "",
category: l.category || "",
reciprocal_url: l.reciprocal_url || "",
sort_order: l.sort_order,
});
setEditorOpen(true);
};
const closeEditor = () => {
setEditorOpen(false);
setEditingId(null);
setForm(EMPTY_FORM);
};
const set = (k: keyof FormState, v: string | number) =>
setForm((f) => ({ ...f, [k]: v }));
const submit = async (e: React.FormEvent) => {
e.preventDefault();
if (saving) return;
setSaving(true);
setError("");
try {
const body: FriendLinkAdminInput = {
name: form.name.trim(),
url: form.url.trim(),
description: form.description.trim() || undefined,
logo_url: form.logo_url.trim() || undefined,
category: form.category.trim() || undefined,
reciprocal_url: form.reciprocal_url.trim() || undefined,
sort_order: form.sort_order,
};
if (editingId == null) {
await apiAdminCreateFriendLink(body);
} else {
await apiAdminUpdateFriendLink(editingId, body);
}
closeEditor();
await refresh();
} catch (err) {
setError(err instanceof Error ? err.message : "保存失败");
} finally {
setSaving(false);
}
};
const setStatus = async (id: number, status: FriendLinkStatus) => {
let rejectReason = "";
if (status === "rejected") {
const r = window.prompt("请输入拒绝理由(可选)", "");
if (r === null) return;
rejectReason = r;
}
setBusyId(id);
setError("");
try {
await apiAdminSetFriendLinkStatus(id, status, rejectReason || undefined);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "操作失败");
} finally {
setBusyId(null);
}
};
const remove = async (id: number) => {
if (!window.confirm("确定删除该友情链接?此操作不可撤销。")) return;
setBusyId(id);
setError("");
try {
await apiAdminDeleteFriendLink(id);
await refresh();
} catch (e) {
setError(e instanceof Error ? e.message : "删除失败");
} finally {
setBusyId(null);
}
};
return (
<AdminSettingsPage>
<AdminPageHeader
icon={Link2}
title="友情链接管理"
description="审批用户申请的友情链接,或直接新建/编辑已收录条目"
actions={
<button type="button" className="btn btn-primary btn-sm" onClick={openCreate}>
<Plus size={14} /> 新建
</button>
}
/>
{error ? <p className="alert-error mb-4">{error}</p> : null}
<div className="panel overflow-hidden">
<div className="flex items-center gap-1 px-4 pt-3 border-b" style={{ borderColor: "var(--line)" }}>
{TABS.map((t) => {
const active = tab === t.key;
const n = counts[t.key] || 0;
return (
<button
key={t.key}
type="button"
onClick={() => setTab(t.key)}
className="px-3 py-2 text-[13.5px] font-medium transition-colors"
style={{
color: active ? "var(--accent)" : "var(--ink-3)",
borderBottom: active ? "2px solid var(--accent)" : "2px solid transparent",
marginBottom: "-1px",
}}
>
{t.label}
<span className="ml-1.5 text-[11.5px] opacity-70">{n}</span>
</button>
);
})}
</div>
{filtered.length === 0 ? (
<AdminEmpty
icon={<Link2 size={20} />}
title={tab === "pending" ? "暂无待审批申请" : tab === "approved" ? "暂无已收录友链" : "暂无被拒绝的申请"}
description={tab === "pending" ? "用户提交的申请会出现在这里" : undefined}
/>
) : (
<ul>
{filtered.map((l) => (
<li
key={l.id}
className="px-5 py-4 flex items-start gap-3"
style={{ borderTop: "1px solid var(--line)" }}
>
{l.logo_url ? (
// 36px 位:本站图走 48/96 变体;外链保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(36)) : l.logo_url}
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 36) : undefined}
alt=""
width={36}
height={36}
className="rounded-lg object-contain shrink-0"
style={{ width: 36, height: 36, background: "var(--panel-2)" }}
/>
) : (
<span
className="w-9 h-9 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Link2 size={16} />
</span>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<a
href={l.url}
target="_blank"
rel="noopener noreferrer"
className="font-semibold text-[14.5px] hover:text-[var(--accent)]"
style={{ color: "var(--ink)" }}
>
{l.name}
</a>
{l.category ? (
<span
className="inline-flex items-center rounded-full px-2 py-0.5 text-[11px]"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
>
{l.category}
</span>
) : null}
<span className="meta text-[11.5px]">#{l.id} · 顺序 {l.sort_order}</span>
</div>
{l.description ? (
<p className="meta mt-1 text-[12.5px] line-clamp-2">{l.description}</p>
) : null}
{l.reciprocal_url ? (
<p className="meta mt-1 text-[11.5px]">
回链:
<a href={l.reciprocal_url} target="_blank" rel="noopener noreferrer" className="underline hover:text-[var(--accent)]">
{l.reciprocal_url}
</a>
</p>
) : null}
{l.status === "rejected" && l.reject_reason ? (
<p className="mt-1 text-[12px]" style={{ color: "var(--danger)" }}>拒绝理由:{l.reject_reason}</p>
) : null}
</div>
<div className="flex items-center gap-1.5 shrink-0">
{busyId === l.id ? (
<Loader2 size={15} className="animate-spin" style={{ color: "var(--ink-3)" }} />
) : null}
{l.status === "pending" ? (
<>
<button
type="button"
className="btn btn-primary btn-sm"
onClick={() => setStatus(l.id, "approved")}
disabled={busyId === l.id}
title="通过"
>
<Check size={13} /> 通过
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setStatus(l.id, "rejected")}
disabled={busyId === l.id}
title="拒绝"
>
<X size={13} /> 拒绝
</button>
</>
) : null}
{l.status === "rejected" ? (
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setStatus(l.id, "approved")}
disabled={busyId === l.id}
title="重新通过"
>
<RotateCcw size={13} /> 恢复
</button>
) : null}
{l.status === "approved" ? (
<>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => openEdit(l)}
disabled={busyId === l.id}
title="编辑"
>
<Pencil size={13} /> 编辑
</button>
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => setStatus(l.id, "pending")}
disabled={busyId === l.id}
title="下线"
>
下线
</button>
</>
) : null}
{l.status === "pending" ? (
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => openEdit(l)}
disabled={busyId === l.id}
title="编辑"
>
<Pencil size={13} />
</button>
) : null}
<button
type="button"
className="btn btn-line btn-sm"
onClick={() => remove(l.id)}
disabled={busyId === l.id}
title="删除"
style={{ color: "var(--danger)" }}
>
<Trash2 size={13} />
</button>
</div>
</li>
))}
</ul>
)}
</div>
{editorOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.5)" }}
onClick={closeEditor}
>
<div
className="panel w-full max-w-lg p-5 sm:p-6 max-h-[90vh] overflow-y-auto"
onClick={(e) => e.stopPropagation()}
>
<h2 className="text-[17px] font-bold mb-4" style={{ color: "var(--ink)" }}>
{editingId == null ? "新建友情链接" : "编辑友情链接"}
</h2>
<form onSubmit={submit} className="space-y-3">
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="meta block mb-1">名称 *</span>
<input
required
maxLength={64}
value={form.name}
onChange={(e) => set("name", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
<label className="block">
<span className="meta block mb-1">地址 *</span>
<input
required
type="url"
maxLength={512}
value={form.url}
onChange={(e) => set("url", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
</div>
<label className="block">
<span className="meta block mb-1">简介</span>
<input
maxLength={255}
value={form.description}
onChange={(e) => set("description", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="meta block mb-1">Logo URL</span>
<input
type="url"
maxLength={512}
value={form.logo_url}
onChange={(e) => set("logo_url", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
<label className="block">
<span className="meta block mb-1">分类</span>
<input
maxLength={32}
value={form.category}
onChange={(e) => set("category", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
</div>
<div className="grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="meta block mb-1">回链地址</span>
<input
type="url"
maxLength={512}
value={form.reciprocal_url}
onChange={(e) => set("reciprocal_url", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
<label className="block">
<span className="meta block mb-1">顺序</span>
<input
type="number"
min={0}
value={form.sort_order}
onChange={(e) => set("sort_order", Number(e.target.value))}
className={FIELD_CLASS}
style={FIELD_STYLE}
/>
</label>
</div>
<div className="flex items-center justify-end gap-2 pt-2">
<button type="button" className="btn btn-line btn-sm" onClick={closeEditor} disabled={saving}>
取消
</button>
<button type="submit" className="btn btn-primary btn-sm" disabled={saving}>
{saving ? <Loader2 size={13} className="animate-spin" /> : null}
{saving ? "保存中…" : "保存"}
</button>
</div>
</form>
</div>
</div>
) : null}
</AdminSettingsPage>
);
}