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

251 lines
7.8 KiB
TypeScript
Raw Permalink 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 { useRef, useState } from "react";
import Link from "next/link";
import { Loader2, Upload } from "lucide-react";
import { apiApplyFriendLink, apiUploadPostImage } from "@/lib/api";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
interface Props {
isLoggedIn: boolean;
canManage: boolean;
/** 关闭所在弹窗(取消按钮);弹窗外渲染时可不传 */
onClose?: () => void;
}
interface FormState {
name: string;
url: string;
reciprocal_url: string;
logo_url: string;
}
const EMPTY_FORM: FormState = {
name: "",
url: "",
reciprocal_url: "",
logo_url: "",
};
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)",
};
export default function LinksClient({ isLoggedIn, canManage, onClose }: Props) {
const [form, setForm] = useState<FormState>(EMPTY_FORM);
const [submitting, setSubmitting] = useState(false);
const [uploading, setUploading] = useState(false);
const [msg, setMsg] = useState<{ ok: boolean; text: string } | null>(null);
const fileRef = useRef<HTMLInputElement>(null);
if (!isLoggedIn) {
return (
<p className="meta mt-3">
请先 <Link href="/login" className="underline hover:text-[var(--accent)]">登录</Link> 后申请友情链接。
</p>
);
}
const set = (k: keyof FormState, v: string) =>
setForm((f) => ({ ...f, [k]: v }));
const onPickFile = async (file: File | undefined) => {
if (!file || uploading) return;
setUploading(true);
setMsg(null);
try {
const res = await apiUploadPostImage(file, file.name);
if (res.url) {
set("logo_url", res.url);
} else {
setMsg({ ok: false, text: res.error || "上传失败,请稍后重试" });
}
} catch {
setMsg({ ok: false, text: "上传失败,请稍后重试" });
} finally {
setUploading(false);
if (fileRef.current) fileRef.current.value = "";
}
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (submitting) return;
setMsg(null);
setSubmitting(true);
try {
await apiApplyFriendLink({
name: form.name.trim(),
url: form.url.trim(),
reciprocal_url: form.reciprocal_url.trim() || undefined,
logo_url: form.logo_url.trim() || undefined,
});
setMsg({ ok: true, text: "申请已提交,待管理员审核通过后展示。" });
setForm(EMPTY_FORM);
} catch (e) {
setMsg({ ok: false, text: e instanceof Error ? e.message : "提交失败,请重试" });
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={onSubmit} className="mt-5 space-y-4">
<label className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
网站名称
</span>
<input
required
maxLength={64}
value={form.name}
onChange={(e) => set("name", e.target.value)}
className={`${FIELD_CLASS} mt-1.5`}
style={FIELD_STYLE}
placeholder="请输入您的网站名称"
/>
</label>
<label className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
网站链接
</span>
<input
required
type="url"
maxLength={512}
value={form.url}
onChange={(e) => set("url", e.target.value)}
className={`${FIELD_CLASS} mt-1.5`}
style={FIELD_STYLE}
placeholder="请输入您的网站地址(以http开头)"
/>
</label>
<label className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
添加我方链接的页面地址
</span>
<input
type="url"
maxLength={512}
value={form.reciprocal_url}
onChange={(e) => set("reciprocal_url", e.target.value)}
className={`${FIELD_CLASS} mt-1.5`}
style={FIELD_STYLE}
placeholder="如:https://您的域名/link.htm(非首页时必填)"
/>
<span className="meta mt-1.5 block text-[12px]">
若友链不在首页,请填写此页面地址,方便管理员核对是否添加了我方链接
</span>
</label>
<div className="block">
<span className="block text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
填写或上传本站LOGO
</span>
<div className="mt-1.5 flex items-stretch gap-2">
{/* 预览 */}
<span
className="flex w-16 shrink-0 items-center justify-center rounded-lg text-[11px] meta overflow-hidden"
style={{ border: "1px dashed var(--line)", background: "var(--panel-2)" }}
aria-hidden
>
{form.logo_url ? (
// 64px 预览位:本站图走 96/160 变体;外链地址保持原样
// eslint-disable-next-line @next/next/no-img-element
<img
src={isOwnImage(form.logo_url) ? variantURL(form.logo_url, pickVariantWidth(64)) : form.logo_url}
srcSet={isOwnImage(form.logo_url) ? densitySrcSet(form.logo_url, 64) : undefined}
alt=""
className="h-full w-full object-contain"
onError={(e) => {
(e.target as HTMLImageElement).style.display = "none";
}}
/>
) : (
"预览"
)}
</span>
<input
type="url"
maxLength={512}
value={form.logo_url}
onChange={(e) => set("logo_url", e.target.value)}
className={FIELD_CLASS}
style={FIELD_STYLE}
placeholder="LOGO图片地址"
/>
<button
type="button"
onClick={() => fileRef.current?.click()}
disabled={uploading}
className="btn btn-line shrink-0 whitespace-nowrap"
>
{uploading ? <Loader2 size={14} className="animate-spin" /> : <Upload size={14} />}
上传
</button>
<input
ref={fileRef}
type="file"
accept="image/*"
className="hidden"
onChange={(e) => onPickFile(e.target.files?.[0])}
/>
</div>
</div>
{msg ? (
<p
className="text-[13px] rounded-lg px-3 py-2"
style={{
background: msg.ok ? "var(--accent-soft)" : "var(--panel-2)",
color: msg.ok ? "var(--accent)" : "var(--ink)",
border: "1px solid var(--line)",
}}
>
{msg.text}
</p>
) : null}
<div className="flex items-center gap-3 pt-1">
<p className="meta flex-1 text-[12px]">
提交后进入待审核状态,可在「我的申请」中查看进度或修改
</p>
<button
type="button"
className="btn btn-line"
onClick={() => {
if (onClose) {
onClose();
} else {
setForm(EMPTY_FORM);
setMsg(null);
}
}}
disabled={submitting}
>
取消
</button>
<button type="submit" disabled={submitting} className="btn btn-primary">
{submitting ? "提交中…" : "提交申请"}
</button>
</div>
{canManage ? (
<p className="meta text-[12px]">
管理员可前往{" "}
<Link href="/admin/friend-links" className="underline hover:text-[var(--accent)]">
友情链接管理
</Link>{" "}
审批
</p>
) : null}
</form>
);
}