Files
jiang13-bbs/frontend/app/links/LinksBoard.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

512 lines
20 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 { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { Info, Link2, Loader2, X } from "lucide-react";
import {
apiMyFriendLinks,
apiUpdateMyFriendLink,
type FriendLink,
type FriendLinkStatus,
} from "@/lib/api";
import LinksClient from "./LinksClient";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
interface Props {
links: FriendLink[];
isLoggedIn: boolean;
canManage: boolean;
/** 本站信息(申请弹窗展示,供对方添加回链) */
siteName: string;
siteUrl: string;
siteLogo: string;
}
/** 申请须知(站点政策,可按需修改) */
const APPLY_NOTICE = "纯导链、无回链、带有营销类产品的网站申请将不予通过";
/** 弹窗内单条申请的可编辑字段 */
interface MineEdit {
name: string;
url: string;
reciprocal_url: string;
logo_url: string;
}
function toEdit(l: FriendLink): MineEdit {
return {
name: l.name,
url: l.url,
reciprocal_url: l.reciprocal_url || "",
logo_url: l.logo_url || "",
};
}
/** 取 URL 域名用于搜索匹配(解析失败返回原文) */
function domainOf(url: string): string {
try {
return new URL(url).hostname.toLowerCase();
} catch {
return url.toLowerCase();
}
}
const STATUS_META: Record<FriendLinkStatus, { label: string; color: string }> = {
pending: { label: "待审核", color: "var(--accent)" },
approved: { label: "已通过", color: "#16a34a" },
rejected: { label: "已拒绝", color: "var(--danger)" },
};
export default function LinksBoard({
links,
isLoggedIn,
canManage,
siteName,
siteUrl,
siteLogo,
}: Props) {
const router = useRouter();
const [query, setQuery] = useState("");
const [mineOpen, setMineOpen] = useState(false);
const [applyOpen, setApplyOpen] = useState(false);
const [mine, setMine] = useState<FriendLink[] | null>(null);
const [mineLoading, setMineLoading] = useState(false);
const [edits, setEdits] = useState<Record<number, MineEdit>>({});
const [savingId, setSavingId] = useState<number | null>(null);
const [editMsg, setEditMsg] = useState<{ id: number; ok: boolean; text: string } | null>(null);
// 从其他入口带 #apply 锚点跳转过来时(如首页侧栏「申请」),自动打开申请弹窗
useEffect(() => {
if (window.location.hash === "#apply") setApplyOpen(true);
}, []);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return links;
return links.filter(
(l) => l.name.toLowerCase().includes(q) || domainOf(l.url).includes(q)
);
}, [links, query]);
const openMine = async () => {
setMineOpen(true);
if (mine == null) {
setMineLoading(true);
try {
const res = await apiMyFriendLinks();
const list = res.links ?? [];
setMine(list);
setEdits((e) => {
const next = { ...e };
for (const l of list) if (!next[l.id]) next[l.id] = toEdit(l);
return next;
});
} catch {
setMine([]);
} finally {
setMineLoading(false);
}
}
};
const setEdit = (id: number, k: keyof MineEdit, v: string) =>
setEdits((e) => ({ ...e, [id]: { ...e[id], [k]: v } }));
const saveMine = async (l: FriendLink) => {
const edit = edits[l.id];
if (!edit || savingId != null) return;
setSavingId(l.id);
setEditMsg(null);
try {
await apiUpdateMyFriendLink(l.id, {
name: edit.name.trim(),
url: edit.url.trim(),
reciprocal_url: edit.reciprocal_url.trim() || undefined,
logo_url: edit.logo_url.trim() || undefined,
});
// 重拉本人申请列表;已通过/已驳回的修改会回退待审,同时刷新 SSR 卡片墙
const res = await apiMyFriendLinks();
const list = res.links ?? [];
setMine(list);
setEdits((e) => {
const next = { ...e };
for (const nl of list) next[nl.id] = toEdit(nl);
return next;
});
const updated = list.find((x) => x.id === l.id);
setEditMsg({
id: l.id,
ok: true,
text:
updated && updated.status === "pending" && l.status !== "pending"
? "已保存,链接重新进入审核队列"
: "保存成功",
});
router.refresh();
} catch (err) {
setEditMsg({
id: l.id,
ok: false,
text: err instanceof Error ? err.message : "保存失败,请重试",
});
} finally {
setSavingId(null);
}
};
return (
<>
{/* 搜索 */}
<div className="mx-auto max-w-md">
<input
type="search"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜索友链(域名或名称)"
className="w-full rounded-full px-4 py-2 text-[14px] outline-none transition-colors"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
/>
</div>
{/* 操作按钮 */}
<div className="mt-4 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setApplyOpen(true)}
className="inline-flex items-center rounded-lg px-4 py-2 text-[13.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: "var(--accent)", color: "#fff" }}
>
申请友链
</button>
<button
type="button"
onClick={openMine}
className="inline-flex items-center rounded-lg px-4 py-2 text-[13.5px] font-semibold transition-opacity hover:opacity-90"
style={{ background: "var(--ink)", color: "var(--panel)" }}
>
我的申请
</button>
</div>
{/* 我的申请弹窗:列出本人提交的申请,可直接修改并保存 */}
{mineOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.5)" }}
onClick={() => setMineOpen(false)}
>
<div
className="panel w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl"
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<h3 className="text-[16px] font-bold" style={{ color: "var(--ink)" }}>
我的申请
</h3>
<button
type="button"
onClick={() => setMineOpen(false)}
className="rounded-full p-1 transition-colors hover:bg-[var(--panel-2)]"
aria-label="关闭"
>
<X size={18} style={{ color: "var(--danger)" }} />
</button>
</div>
<div className="p-5">
{!isLoggedIn ? (
<p className="meta py-6 text-center text-[13px]">
请先 <Link href="/login" className="underline hover:text-[var(--accent)]">登录</Link> 后查看申请记录
</p>
) : mineLoading ? (
<p className="meta flex items-center justify-center gap-2 py-6 text-[13px]">
<Loader2 size={14} className="animate-spin" /> 加载中…
</p>
) : mine == null || mine.length === 0 ? (
<p className="meta py-6 text-center text-[13px]">暂无申请记录</p>
) : (
<ul className="space-y-4">
{mine.map((l) => {
const edit = edits[l.id] ?? toEdit(l);
return (
<li
key={l.id}
className="rounded-xl p-4"
style={{ border: "1px solid var(--line)", background: "var(--panel-2)" }}
>
{/* 头部:LOGO + 名称 + 状态 */}
<div className="flex items-center gap-3">
{edit.logo_url ? (
// 40px 位:本站图走 48/96 变体;外链保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={isOwnImage(edit.logo_url) ? variantURL(edit.logo_url, pickVariantWidth(40)) : edit.logo_url}
srcSet={isOwnImage(edit.logo_url) ? densitySrcSet(edit.logo_url, 40) : undefined}
alt=""
width={40}
height={40}
className="rounded-lg object-contain shrink-0"
style={{ width: 40, height: 40, background: "var(--panel)" }}
/>
) : (
<span
className="flex shrink-0 items-center justify-center rounded-lg"
style={{ width: 40, height: 40, background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Link2 size={16} />
</span>
)}
<span className="min-w-0 flex-1 truncate text-[14.5px] font-semibold" style={{ color: "var(--ink)" }}>
{l.name}
</span>
<span
className="shrink-0 rounded-full px-2.5 py-1 text-[11.5px] font-medium"
style={{
color: STATUS_META[l.status]?.color ?? "var(--ink-3)",
background: "var(--panel)",
border: "1px solid var(--line)",
}}
>
{STATUS_META[l.status]?.label ?? l.status}
</span>
</div>
{l.status === "rejected" && l.reject_reason ? (
<p className="mt-2 text-[12px]" style={{ color: "var(--danger)" }}>
拒绝理由:{l.reject_reason}
</p>
) : null}
{/* 可编辑字段 */}
<div className="mt-3 grid gap-3 sm:grid-cols-2">
<label className="block">
<span className="meta block text-[12px]">名称</span>
<input
value={edit.name}
maxLength={64}
onChange={(e) => setEdit(l.id, "name", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
/>
</label>
<label className="block">
<span className="meta block text-[12px]">链接</span>
<input
type="url"
value={edit.url}
maxLength={512}
onChange={(e) => setEdit(l.id, "url", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
/>
</label>
</div>
<label className="mt-3 block">
<span className="meta block text-[12px]">友链地址</span>
<input
type="url"
value={edit.reciprocal_url}
maxLength={512}
onChange={(e) => setEdit(l.id, "reciprocal_url", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
placeholder="添加本站链接的页面"
/>
</label>
<label className="mt-3 block">
<span className="meta block text-[12px]">图片</span>
<input
type="url"
value={edit.logo_url}
maxLength={512}
onChange={(e) => setEdit(l.id, "logo_url", e.target.value)}
className="mt-1 w-full rounded-lg px-3 py-2 text-[13.5px] outline-none"
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line)" }}
placeholder="LOGO图片地址"
/>
</label>
{/* 底部:反馈 + 保存 */}
<div className="mt-3 flex items-center justify-end gap-3">
{editMsg?.id === l.id ? (
<span
className="mr-auto text-[12px]"
style={{ color: editMsg.ok ? "var(--accent)" : "var(--danger)" }}
>
{editMsg.text}
</span>
) : l.status !== "pending" ? (
<span className="meta mr-auto text-[11.5px]">修改后将重新进入审核</span>
) : null}
<button
type="button"
onClick={() => saveMine(l)}
disabled={savingId === l.id}
className="btn btn-primary btn-sm"
>
{savingId === l.id ? <Loader2 size={13} className="animate-spin" /> : null}
保存修改
</button>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
</div>
) : null}
{/* 申请友链弹窗:本站信息 + 申请表单 */}
{applyOpen ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ background: "rgba(0,0,0,0.5)" }}
onClick={() => setApplyOpen(false)}
>
<div
className="panel w-full max-w-2xl max-h-[85vh] overflow-y-auto rounded-2xl"
onClick={(e) => e.stopPropagation()}
>
<div
className="flex items-center justify-between px-5 py-4"
style={{ borderBottom: "1px solid var(--line)" }}
>
<h3 className="text-[16px] font-bold" style={{ color: "var(--ink)" }}>
申请友链
</h3>
<button
type="button"
onClick={() => setApplyOpen(false)}
className="rounded-full p-1 transition-colors hover:bg-[var(--panel-2)]"
aria-label="关闭"
>
<X size={18} style={{ color: "var(--danger)" }} />
</button>
</div>
<div className="p-5">
{/* 本站信息:供对方先添加我方链接 */}
<div
className="rounded-xl p-4"
style={{ background: "var(--accent-soft)", border: "1px solid var(--line)" }}
>
<h4 className="text-[14px] font-bold" style={{ color: "var(--ink)" }}>
本站信息(请添加本站链接后再申请)
</h4>
<dl className="mt-2.5 space-y-1.5 text-[13px]" style={{ color: "var(--ink)" }}>
<div className="flex gap-2">
<dt className="shrink-0 font-semibold">名称:</dt>
<dd className="min-w-0 break-all">{siteName}</dd>
</div>
{siteUrl ? (
<div className="flex gap-2">
<dt className="shrink-0 font-semibold">地址:</dt>
<dd className="min-w-0 break-all">
<a
href={siteUrl}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[var(--accent)]"
>
{siteUrl}
</a>
</dd>
</div>
) : null}
{siteLogo ? (
<div className="flex gap-2">
<dt className="shrink-0 font-semibold">LOGO:</dt>
<dd className="min-w-0 break-all">
{siteLogo}
<span className="ml-1 text-[12px]" style={{ color: "var(--ink-2)" }}>
(固定地址,换图不失效)
</span>
</dd>
</div>
) : null}
</dl>
</div>
{/* 申请须知 */}
<p
className="mt-3 flex items-start gap-2 rounded-lg px-3.5 py-2.5 text-[12.5px] leading-relaxed"
style={{
background: "var(--panel-2)",
borderLeft: "3px solid var(--accent)",
color: "var(--ink-2)",
}}
>
<Info size={15} style={{ color: "var(--accent)", flexShrink: 0, marginTop: 2 }} aria-hidden />
<span>{APPLY_NOTICE}</span>
</p>
<LinksClient
isLoggedIn={isLoggedIn}
canManage={canManage}
onClose={() => setApplyOpen(false)}
/>
</div>
</div>
</div>
) : null}
{/* 卡片墙 */}
{filtered.length === 0 ? (
<p className="meta mt-8 text-center text-[13.5px]">
{links.length === 0 ? "暂无友链,欢迎申请收录" : "没有匹配的友链"}
</p>
) : (
<div className="mt-6 grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6">
{filtered.map((l) => (
<a
key={l.id}
href={l.url}
target="_blank"
rel="noopener noreferrer"
title={l.description || l.name}
className="flex flex-col items-center justify-center gap-2.5 rounded-xl px-3 py-5 transition-all hover:-translate-y-0.5"
style={{
background: "var(--panel)",
border: "1px solid var(--line)",
minHeight: 116,
}}
>
{l.logo_url ? (
// 48px 位:本站图走 48/96 变体;外链保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={isOwnImage(l.logo_url) ? variantURL(l.logo_url, pickVariantWidth(48)) : l.logo_url}
srcSet={isOwnImage(l.logo_url) ? densitySrcSet(l.logo_url, 48) : undefined}
alt=""
loading="lazy"
width={48}
height={48}
className="rounded-lg object-contain"
style={{ width: 48, height: 48 }}
/>
) : (
<span
className="flex items-center justify-center rounded-lg"
style={{ width: 48, height: 48, background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
<Link2 size={22} />
</span>
)}
<span className="max-w-full truncate text-[13px] font-medium" style={{ color: "var(--ink)" }}>
{l.name}
</span>
</a>
))}
</div>
)}
</>
);
}