Files
jiang13-bbs/frontend/app/links/LinksClient.tsx
freefire 4f3f3a265c feat: 友链/书库/移动端导航/排行榜等多模块功能
新增模块:
- 友链(friendlink)后端处理 + 前端管理页与友链板
- 书库(library):导入、章节、封面、阅读页、横竖版自适应 AdaptiveCoverSlot
- 顶栏导航(header_nav)配置与 MobileTabBar/MobileRailDrawers 移动端抽屉
- 排行榜 service 测试、站点页面测试、时间线发布(timeline_release)

其它改动:
- 后端 handlers/services 全量小幅调整
- 前端组件、库函数、URL/品牌/站点 URL 工具更新
- Lightbox 图片、MdEntries 条目卡、coverColor 派生色相等前端能力
2026-09-30 16:30:07 +08:00

248 lines
7.5 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 { useRef, useState } from "react";
import Link from "next/link";
import { Loader2, Upload } from "lucide-react";
import { apiApplyFriendLink, apiUploadPostImage } from "@/lib/api";
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 ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={form.logo_url}
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>
);
}