Files
jiang13-bbs/frontend/app/admin/ads/AdsAdmin.tsx
freefire 1d862a0dd3 feat: 站点媒体库与移动端底部导航,服务测试补强
- 新增媒体库:media_library/media_thumbs 服务(WebP 缩略图)、管理端 media 页面
- 移动端底部导航 MobileTabBar 替换 MobilePostBar
- 旧数据导入增强与测试、路由与登录会话/板块侧边栏测试补强
- site-doc 组件精简(移除 Breadcrumb),文档新增迁移公告说明
2026-09-27 02:36:11 +08:00

1154 lines
37 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, useRef, useState } from "react";
import Link from "next/link";
import {
CircleDollarSign,
Clock,
Eye,
Handshake,
ImageIcon,
Inbox,
Megaphone,
Plus,
Trash2,
Type,
Upload,
Wallet,
XCircle,
} from "lucide-react";
import {
apiAdminApproveAd,
apiAdminDeleteAd,
apiAdminListAds,
apiAdminRejectAd,
apiAdminSaveAdsConfig,
apiAdminSaveSponsorsConfig,
apiAdminUploadAdQR,
type AdConfig,
type AdPaymentOption,
type AdRecord,
type SponsorItem,
type SponsorsConfig,
} from "@/lib/api";
import {
AdminEmpty,
AdminPageHeader,
AdminSegmented,
AdminSettingsPage,
AdminSettingsWork,
AdminStatCard,
AdminStatusChip,
AdminSwitch,
} from "@/components/admin";
import ConfirmDialog from "@/components/ConfirmDialog";
import Modal from "@/components/Modal";
import { AdText } from "@/components/AdText";
import { formatRelative } from "@/lib/format";
import { toast } from "@/lib/toast";
type StatusFilter = "" | "pending" | "active" | "rejected" | "expired";
type SectionId = "overview" | "queue" | "promote" | "sponsors";
const statusLabel: Record<string, string> = {
pending: "待审",
active: "展示中",
rejected: "已拒绝",
expired: "已到期",
};
const SECTIONS: { id: SectionId; label: string; icon: typeof Inbox }[] = [
{ id: "overview", label: "概览", icon: Eye },
{ id: "queue", label: "推广申购", icon: Inbox },
{ id: "promote", label: "自助推广", icon: Megaphone },
{ id: "sponsors", label: "赞助商", icon: Handshake },
];
const emptySponsors = (): SponsorsConfig => ({
enabled: true,
panel_title: "赞助商",
items: [],
});
export default function AdsAdmin({
initialAds,
initialConfig,
initialSponsors,
initError,
}: {
initialAds: AdRecord[];
initialConfig: AdConfig | null;
initialSponsors: SponsorsConfig | null;
initError: string;
}) {
const [status, setStatus] = useState<StatusFilter>("");
const [ads, setAds] = useState(initialAds);
const [cfg, setCfg] = useState<AdConfig>(
initialConfig || {
enabled: true,
panel_title: "自助推广",
durations: [
{ days: 30, label: "1 个月", price_hint: "", price_hint_text: "" },
{ days: 60, label: "2 个月", price_hint: "", price_hint_text: "" },
{ days: 90, label: "3 个月", price_hint: "", price_hint_text: "" },
{ days: 180, label: "6 个月", price_hint: "", price_hint_text: "" },
{ days: 365, label: "12 个月", price_hint: "", price_hint_text: "" },
],
payments: [],
}
);
const [sponsors, setSponsors] = useState<SponsorsConfig>(initialSponsors || emptySponsors());
const [busy, setBusy] = useState(false);
const [deleteId, setDeleteId] = useState<number | null>(null);
const [rejectId, setRejectId] = useState<number | null>(null);
const [rejectReason, setRejectReason] = useState("");
const [activeSection, setActiveSection] = useState<SectionId>("overview");
const counts = useMemo(() => {
const c = { pending: 0, active: 0, rejected: 0, expired: 0 };
for (const a of ads) {
if (a.status in c) (c as Record<string, number>)[a.status] += 1;
}
return c;
}, [ads]);
const visibleAds = useMemo(() => {
if (!status) return ads;
return ads.filter((a) => a.status === status);
}, [ads, status]);
const reload = async (st: StatusFilter = status) => {
try {
const res = await apiAdminListAds(st);
setAds(res.ads ?? []);
} catch (e) {
toast(e instanceof Error ? e.message : "刷新失败", "error");
}
};
const onApprove = async (id: number) => {
setBusy(true);
try {
await apiAdminApproveAd(id);
toast("已上架", "ok");
await reload();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败", "error");
} finally {
setBusy(false);
}
};
const onRejectConfirm = async () => {
if (rejectId == null) return;
setBusy(true);
try {
await apiAdminRejectAd(rejectId, rejectReason.trim());
toast("已拒绝", "ok");
setRejectId(null);
setRejectReason("");
await reload();
} catch (e) {
toast(e instanceof Error ? e.message : "操作失败", "error");
} finally {
setBusy(false);
}
};
const onDelete = async () => {
if (deleteId == null) return;
setBusy(true);
try {
await apiAdminDeleteAd(deleteId);
toast("已删除", "ok");
setDeleteId(null);
await reload();
} catch (e) {
toast(e instanceof Error ? e.message : "删除失败", "error");
} finally {
setBusy(false);
}
};
const saveConfig = async () => {
setBusy(true);
try {
const next = await apiAdminSaveAdsConfig(cfg);
setCfg(next);
toast("配置已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
} finally {
setBusy(false);
}
};
const saveSponsors = async () => {
setBusy(true);
try {
const next = await apiAdminSaveSponsorsConfig(sponsors);
setSponsors(next);
toast("赞助商已保存", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "保存失败", "error");
} finally {
setBusy(false);
}
};
const uploadQR = async (index: number, file: File) => {
try {
const { url } = await apiAdminUploadAdQR(file);
setCfg((c) => {
const payments = [...c.payments];
payments[index] = { ...payments[index], qr_url: url };
return { ...c, payments };
});
toast("收款码已上传", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "上传失败", "error");
}
};
const uploadSponsorLogo = async (index: number, file: File) => {
try {
const { url } = await apiAdminUploadAdQR(file);
setSponsors((c) => {
const items = [...c.items];
items[index] = { ...items[index], logo_url: url };
return { ...c, items };
});
toast("Logo 已上传", "ok");
} catch (e) {
toast(e instanceof Error ? e.message : "上传失败", "error");
}
};
const addPayment = () => {
const id = `pay_${Date.now().toString(36)}`;
setCfg((c) => ({
...c,
payments: [
...c.payments,
{ id, name: "支付宝", kind: "alipay", qr_url: "", hint: "付款请备注邮箱", enabled: true },
],
}));
};
const addSponsor = () => {
const id = `sp_${Date.now().toString(36)}`;
setSponsors((c) => ({
...c,
items: [
...c.items,
{
id,
name: "",
logo_url: "",
link_url: "",
hover_text: "",
enabled: true,
sort_order: c.items.length,
},
],
}));
};
// 粘性分区导航:滚动高亮当前区块
const sectionRefs = useRef<Record<SectionId, HTMLElement | null>>({
overview: null,
queue: null,
promote: null,
sponsors: null,
});
useEffect(() => {
const els = SECTIONS.map((s) => sectionRefs.current[s.id]).filter(
(el): el is HTMLElement => !!el
);
if (els.length === 0) return;
const observer = new IntersectionObserver(
(entries) => {
const visible = entries
.filter((e) => e.isIntersecting)
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
if (visible[0]) {
const id = (visible[0].target as HTMLElement).dataset.section as SectionId;
if (id) setActiveSection(id);
}
},
{ rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5, 1] }
);
els.forEach((el) => observer.observe(el));
return () => observer.disconnect();
}, []);
const scrollTo = (id: SectionId) => {
const el = sectionRefs.current[id];
if (el) el.scrollIntoView({ behavior: "smooth", block: "start" });
};
if (initError && !initialConfig) {
return <p className="meta px-1 py-6">{initError}</p>;
}
const leftNav = (
<nav className="admin-settings-nav" aria-label="推广与赞助分区">
{SECTIONS.map((s) => {
const Icon = s.icon;
const active = activeSection === s.id;
return (
<button
key={s.id}
type="button"
onClick={() => scrollTo(s.id)}
aria-current={active ? "page" : undefined}
className="admin-settings-nav-link gap-2"
>
<Icon size={15} aria-hidden />
{s.label}
</button>
);
})}
</nav>
);
return (
<AdminSettingsPage>
<AdminPageHeader
icon={CircleDollarSign}
title="推广与赞助"
description="审核自助推广申购,配置收款码与赞助商 logo"
actions={
<Link href="/ads/buy" className="btn btn-line btn-sm" target="_blank">
预览推广页
</Link>
}
/>
<AdminSettingsWork nav={leftNav}>
<div className="space-y-5">
{/* ── 概览 KPI ─────────────────────────────── */}
<section
id="sec-overview"
data-section="overview"
ref={(el) => {
sectionRefs.current.overview = el;
}}
>
<div className="grid grid-cols-2 gap-3 sm:gap-4 lg:grid-cols-5">
<AdminStatCard
label="待审核"
value={counts.pending}
icon={<Clock size={17} />}
color="var(--gold)"
soft="var(--gold-soft)"
hint="需尽快处理"
/>
<AdminStatCard
label="展示中"
value={counts.active}
icon={<Eye size={17} />}
color="var(--ok)"
soft="color-mix(in srgb, var(--ok) 14%, var(--panel))"
hint="当前在线广告"
/>
<AdminStatCard
label="已到期"
value={counts.expired}
icon={<XCircle size={17} />}
color="var(--ink-3)"
soft="var(--panel-2)"
hint="可重新上架"
/>
<AdminStatCard
label="收款方式"
value={cfg.payments.length}
icon={<Wallet size={17} />}
color="var(--accent)"
soft="var(--accent-soft)"
hint={cfg.enabled ? "自助推广已开放" : "自助推广已关闭"}
/>
<AdminStatCard
label="赞助商"
value={sponsors.items.length}
icon={<Handshake size={17} />}
color="var(--accent)"
soft="var(--accent-soft)"
hint={sponsors.enabled ? "板块已展示" : "板块已隐藏"}
/>
</div>
</section>
{/* ── 推广申购 ─────────────────────────────── */}
<section
id="sec-queue"
data-section="queue"
ref={(el) => {
sectionRefs.current.queue = el;
}}
className="panel overflow-hidden"
>
<SectionHead
icon={<Inbox size={16} />}
title="推广申购"
desc="游客提交的推广申请,审核通过后展示在首页侧栏"
extra={
<AdminSegmented<StatusFilter>
ariaLabel="广告状态筛选"
size="sm"
role="group"
value={status}
onChange={(v) => {
setStatus(v);
void reload(v);
}}
options={[
{ key: "", label: "全部" },
{ key: "pending", label: "待审", count: counts.pending },
{ key: "active", label: "展示中", count: counts.active },
{ key: "rejected", label: "已拒绝" },
{ key: "expired", label: "已到期" },
]}
/>
}
/>
<div className="p-3 sm:p-4">
{visibleAds.length === 0 ? (
<AdminEmpty
icon={<CircleDollarSign size={22} />}
title="暂无广告记录"
description={status ? "该状态下没有记录" : "游客提交后会出现在此列表"}
/>
) : (
<ul className="flex flex-col gap-2.5">
{visibleAds.map((ad) => (
<AdCard
key={ad.id}
ad={ad}
busy={busy}
onApprove={() => void onApprove(ad.id)}
onReject={() => {
setRejectId(ad.id);
setRejectReason("");
}}
onDelete={() => setDeleteId(ad.id)}
/>
))}
</ul>
)}
</div>
</section>
{/* ── 自助推广配置 ─────────────────────────── */}
<section
id="sec-promote"
data-section="promote"
ref={(el) => {
sectionRefs.current.promote = el;
}}
className="panel overflow-hidden"
>
<SectionHead
icon={<Megaphone size={16} />}
title="自助推广"
desc="配置购买页的时长档位与收款方式"
extra={
<div className="flex items-center gap-2">
<span className="text-[12.5px]" style={{ color: "var(--ink-2)" }}>
{cfg.enabled ? "已开放" : "已关闭"}
</span>
<AdminSwitch
label="开放自助推广"
checked={cfg.enabled}
onChange={() => setCfg({ ...cfg, enabled: !cfg.enabled })}
/>
</div>
}
/>
<div className="space-y-6 p-4 sm:p-5">
<div className="flex flex-wrap items-end gap-3">
<div className="block">
<span className="meta mb-1 block text-[12px]">侧栏 / 页面标题</span>
<input
className="field w-full max-w-sm"
value={cfg.panel_title}
onChange={(e) => setCfg({ ...cfg, panel_title: e.target.value })}
maxLength={16}
/>
</div>
</div>
{/* 时长档位 */}
<SubBlock
title="时长档位"
action={
<button
type="button"
className="btn btn-line btn-sm"
onClick={() =>
setCfg({
...cfg,
durations: [
...cfg.durations,
{ days: 14, label: "14 天", price_hint: "", price_hint_text: "" },
],
})
}
>
<Plus size={14} /> 添加
</button>
}
>
{cfg.durations.length === 0 ? (
<p className="meta text-[13px]">至少保留一个时长档位。</p>
) : (
<div className="overflow-x-auto rounded-xl border" style={{ borderColor: "var(--line)" }}>
<table className="w-full min-w-[600px] text-left text-[13px]">
<thead>
<tr className="meta text-[12px]" style={{ background: "var(--surface)" }}>
<th className="px-3 py-2 font-medium">天数</th>
<th className="px-3 py-2 font-medium">标签</th>
<th className="px-3 py-2 font-medium">图片价格</th>
<th className="px-3 py-2 font-medium">文字价格</th>
<th className="px-3 py-2 text-right font-medium">操作</th>
</tr>
</thead>
<tbody>
{cfg.durations.map((d, i) => (
<tr key={i} style={{ borderTop: "1px solid var(--line)" }}>
<td className="px-3 py-2">
<input
type="number"
className="field w-20"
value={d.days}
min={1}
onChange={(e) => {
const durations = [...cfg.durations];
durations[i] = { ...d, days: Number(e.target.value) || 1 };
setCfg({ ...cfg, durations });
}}
aria-label="天数"
/>
</td>
<td className="px-3 py-2">
<input
className="field w-full min-w-0"
value={d.label}
placeholder="如 1 个月"
onChange={(e) => {
const durations = [...cfg.durations];
durations[i] = { ...d, label: e.target.value };
setCfg({ ...cfg, durations });
}}
/>
</td>
<td className="px-3 py-2">
<input
className="field w-full min-w-0"
value={d.price_hint}
maxLength={32}
placeholder="如 ¥299,可空"
onChange={(e) => {
const durations = [...cfg.durations];
durations[i] = { ...d, price_hint: e.target.value };
setCfg({ ...cfg, durations });
}}
aria-label="图片广告价格"
/>
</td>
<td className="px-3 py-2">
<input
className="field w-full min-w-0"
value={d.price_hint_text}
maxLength={32}
placeholder="如 ¥99,可空"
onChange={(e) => {
const durations = [...cfg.durations];
durations[i] = { ...d, price_hint_text: e.target.value };
setCfg({ ...cfg, durations });
}}
aria-label="文字广告价格"
/>
</td>
<td className="px-3 py-2 text-right">
<button
type="button"
className="btn btn-line btn-sm"
aria-label="删除档位"
onClick={() =>
setCfg({ ...cfg, durations: cfg.durations.filter((_, j) => j !== i) })
}
>
<Trash2 size={14} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</SubBlock>
{/* 收款方式 */}
<SubBlock
title="收款方式"
action={
<button type="button" className="btn btn-line btn-sm" onClick={addPayment}>
<Plus size={14} /> 添加
</button>
}
>
{cfg.payments.length === 0 ? (
<div className="flex gap-2 rounded-xl border border-dashed px-3 py-3 text-[12.5px] leading-relaxed" style={{ borderColor: "var(--line)", color: "var(--ink-3)" }}>
<Upload size={14} className="mt-0.5 shrink-0" />
<span>添加支付宝 / 微信收款码后,游客才能提交申购。</span>
</div>
) : (
<div className="grid grid-cols-1 gap-3">
{cfg.payments.map((p, i) => (
<PaymentEditor
key={p.id}
payment={p}
onChange={(next) => {
const payments = [...cfg.payments];
payments[i] = next;
setCfg({ ...cfg, payments });
}}
onUpload={(file) => void uploadQR(i, file)}
onRemove={() =>
setCfg({ ...cfg, payments: cfg.payments.filter((_, j) => j !== i) })
}
/>
))}
</div>
)}
</SubBlock>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => void saveConfig()}
>
{busy ? "保存中…" : "保存配置"}
</button>
</div>
</div>
</section>
{/* ── 赞助商(占满内容栏全宽)────────────── */}
<section
id="sec-sponsors"
data-section="sponsors"
ref={(el) => {
sectionRefs.current.sponsors = el;
}}
className="panel overflow-hidden"
>
<SectionHead
icon={<Handshake size={16} />}
title="赞助商"
desc="展示在首页侧栏的赞助商 logo 墙"
extra={
<div className="flex items-center gap-2">
<span className="text-[12.5px]" style={{ color: "var(--ink-2)" }}>
{sponsors.enabled ? "已展示" : "已隐藏"}
</span>
<AdminSwitch
label="展示赞助商板块"
checked={sponsors.enabled}
onChange={() => setSponsors({ ...sponsors, enabled: !sponsors.enabled })}
/>
</div>
}
/>
<div className="space-y-6 p-4 sm:p-5">
<div className="block">
<span className="meta mb-1 block text-[12px]">侧栏标题</span>
<input
className="field w-full max-w-sm"
value={sponsors.panel_title}
onChange={(e) => setSponsors({ ...sponsors, panel_title: e.target.value })}
maxLength={16}
/>
</div>
<SubBlock
title="赞助商列表"
action={
<button type="button" className="btn btn-line btn-sm" onClick={addSponsor}>
<Plus size={14} /> 添加
</button>
}
>
{sponsors.items.length === 0 ? (
<AdminEmpty
icon={<Handshake size={22} />}
title="暂无赞助商"
description="添加 logo 后,悬停或聚焦可显示赞助说明"
/>
) : (
<div className="flex flex-col gap-3">
{sponsors.items.map((sp, i) => (
<SponsorEditor
key={sp.id}
item={sp}
onChange={(next) => {
const items = [...sponsors.items];
items[i] = next;
setSponsors({ ...sponsors, items });
}}
onUpload={(file) => void uploadSponsorLogo(i, file)}
onRemove={() =>
setSponsors({
...sponsors,
items: sponsors.items.filter((_, j) => j !== i),
})
}
/>
))}
</div>
)}
</SubBlock>
<div className="flex justify-end">
<button
type="button"
className="btn btn-primary"
disabled={busy}
onClick={() => void saveSponsors()}
>
{busy ? "保存中…" : "保存赞助商"}
</button>
</div>
</div>
</section>
</div>
</AdminSettingsWork>
<ConfirmDialog
open={deleteId != null}
title="删除这条推广?"
message="删除后不可恢复。"
confirmLabel="删除"
danger
busy={busy}
onConfirm={() => void onDelete()}
onCancel={() => setDeleteId(null)}
/>
<Modal
open={rejectId != null}
title="拒绝推广"
onClose={() => {
if (!busy) {
setRejectId(null);
setRejectReason("");
}
}}
busy={busy}
maxWidthClass="max-w-sm"
footer={
<>
<button
type="button"
className="btn btn-line btn-sm"
disabled={busy}
onClick={() => {
setRejectId(null);
setRejectReason("");
}}
>
取消
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy}
onClick={() => void onRejectConfirm()}
>
{busy ? "处理中…" : "确认拒绝"}
</button>
</>
}
>
<label className="block">
<span className="meta mb-1.5 block text-[12px]">拒绝原因(可选)</span>
<textarea
className="field w-full min-h-[80px]"
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
maxLength={200}
placeholder="将记录在详情中"
/>
</label>
</Modal>
</AdminSettingsPage>
);
}
/* ─── 通用分块:卡片头 ─── */
function SectionHead({
icon,
title,
desc,
extra,
}: {
icon: React.ReactNode;
title: string;
desc?: string;
extra?: React.ReactNode;
}) {
return (
<div
className="flex flex-wrap items-center justify-between gap-3 px-4 py-3.5 sm:px-5"
style={{ borderBottom: "1px solid var(--line)" }}
>
<div className="flex items-center gap-2.5 min-w-0">
<span
className="w-8 h-8 rounded-lg flex items-center justify-center shrink-0"
style={{ background: "var(--accent-soft)", color: "var(--accent)" }}
aria-hidden
>
{icon}
</span>
<span className="min-w-0 leading-tight">
<span className="block text-[15px] font-semibold" style={{ color: "var(--ink)" }}>
{title}
</span>
{desc ? (
<span className="meta mt-0.5 block text-[12px] truncate">{desc}</span>
) : null}
</span>
</div>
{extra ? <div className="shrink-0">{extra}</div> : null}
</div>
);
}
/* ─── 子区块:带标题与右侧操作 ─── */
function SubBlock({
title,
action,
children,
}: {
title: string;
action?: React.ReactNode;
children: React.ReactNode;
}) {
return (
<div>
<div className="mb-2.5 flex items-center justify-between gap-2">
<p className="text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
{title}
</p>
{action}
</div>
{children}
</div>
);
}
/* ─── 申购行:缩略+信息在左,操作在右(始终相邻) ─── */
function AdCard({
ad,
busy,
onApprove,
onReject,
onDelete,
}: {
ad: AdRecord;
busy: boolean;
onApprove: () => void;
onReject: () => void;
onDelete: () => void;
}) {
const tone =
ad.status === "active"
? "ok"
: ad.status === "pending"
? "warn"
: ad.status === "rejected"
? "danger"
: "muted";
const canApprove = ad.status === "pending" || ad.status === "rejected" || ad.status === "expired";
const canReject = ad.status === "pending" || ad.status === "active";
return (
<li className="j13-ads-adrow">
{/* 预览缩略 */}
<div className="j13-ads-thumb shrink-0" aria-hidden>
{ad.kind === "image" && ad.image_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={ad.image_url} alt="" className="h-full w-full object-contain" />
) : ad.kind === "text" && ad.title ? (
<div className="flex h-full w-full items-center justify-center p-1.5">
<AdText title={ad.title} textColor={ad.text_color} bgColor={ad.bg_color} />
</div>
) : (
<span className="meta text-[11px]">无预览</span>
)}
</div>
{/* 信息 */}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<AdminStatusChip tone={tone as never}>
{statusLabel[ad.status] || ad.status}
</AdminStatusChip>
<span className="inline-flex items-center gap-1 text-[11.5px]" style={{ color: "var(--ink-3)" }}>
{ad.kind === "image" ? <ImageIcon size={12} /> : <Type size={12} />}
{ad.kind === "image" ? "图片" : "文字"} · {ad.duration_days} 天
</span>
</div>
<p className="mt-1 truncate text-[13.5px] font-medium" style={{ color: "var(--ink)" }}>
{ad.kind === "text" ? ad.title : ad.image_url}
</p>
<p className="meta mt-0.5 truncate text-[12px]">
{ad.contact_email}
{ad.link_url ? (
<>
{" · "}
<a
href={ad.link_url}
target="_blank"
rel="noopener noreferrer"
className="hover:text-[var(--accent)]"
>
{ad.link_url}
</a>
</>
) : null}
</p>
{ad.reject_reason ? (
<p className="mt-0.5 truncate text-[12px]" style={{ color: "var(--danger)" }}>
拒绝:{ad.reject_reason}
</p>
) : (
<p className="meta mt-0.5 text-[11.5px]">
提交于 {formatRelative(ad.created_at)}
{ad.ends_at ? ` · 到期 ${formatRelative(ad.ends_at)}` : null}
</p>
)}
</div>
{/* 操作:与信息同行右对齐,窄屏换行 */}
<div className="flex shrink-0 flex-wrap items-center justify-end gap-2">
{canApprove && (
<button
type="button"
className="btn btn-primary btn-sm"
disabled={busy}
onClick={onApprove}
>
上架
</button>
)}
{canReject && (
<button type="button" className="btn btn-line btn-sm" disabled={busy} onClick={onReject}>
拒绝
</button>
)}
<button
type="button"
className="btn btn-line btn-sm"
onClick={onDelete}
aria-label="删除"
>
<Trash2 size={14} />
</button>
</div>
</li>
);
}
/* ─── 收款方式编辑器:二维码居左可点传,字段居右 ─── */
const KIND_META: Record<string, { label: string; color: string }> = {
alipay: { label: "支付宝", color: "#1677FF" },
wechat: { label: "微信", color: "#07C160" },
other: { label: "其他", color: "var(--ink-3)" },
};
function PaymentEditor({
payment,
onChange,
onUpload,
onRemove,
}: {
payment: AdPaymentOption;
onChange: (p: AdPaymentOption) => void;
onUpload: (file: File) => void;
onRemove: () => void;
}) {
const meta = KIND_META[payment.kind] || KIND_META.other;
return (
<div className={`j13-ads-editor${payment.enabled ? "" : " is-off"}`}>
{/* 二维码:点击即上传 */}
<label className="j13-ads-qrbox shrink-0 cursor-pointer" title="点击上传/更换收款码">
{payment.qr_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={payment.qr_url} alt="" className="h-full w-full object-contain" />
) : (
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
<Upload size={16} />
上传收款码
</span>
)}
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onUpload(f);
e.target.value = "";
}}
/>
</label>
{/* 字段区 */}
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<div className="flex flex-wrap items-center gap-2">
<span
className="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-semibold"
style={{ color: meta.color, background: `color-mix(in srgb, ${meta.color} 14%, var(--panel))` }}
>
<Wallet size={12} aria-hidden />
{meta.label}
</span>
<input
className="field min-w-[120px] flex-1"
value={payment.name}
onChange={(e) => onChange({ ...payment, name: e.target.value })}
placeholder="收款方式名称,如:站长支付宝"
/>
<select
className="field w-24"
value={payment.kind}
onChange={(e) => onChange({ ...payment, kind: e.target.value })}
aria-label="收款类型"
>
<option value="alipay">支付宝</option>
<option value="wechat">微信</option>
<option value="other">其他</option>
</select>
<div className="ml-auto flex items-center gap-2">
<label className="flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<AdminSwitch
label="启用该收款方式"
checked={payment.enabled}
onChange={() => onChange({ ...payment, enabled: !payment.enabled })}
/>
启用
</label>
<button type="button" className="btn btn-line btn-sm" onClick={onRemove} aria-label="删除收款方式">
<Trash2 size={14} />
</button>
</div>
</div>
<input
className="field w-full"
value={payment.hint}
onChange={(e) => onChange({ ...payment, hint: e.target.value })}
placeholder="付款提示文案,如:付款请备注邮箱,便于核对"
/>
<input
className="field w-full text-[12px]"
value={payment.qr_url}
onChange={(e) => onChange({ ...payment, qr_url: e.target.value })}
placeholder="或直接填写 https 图片地址(已上传可留空)"
/>
</div>
</div>
);
}
/* ─── 赞助商编辑器:全宽行,Logo 左 / 字段中 / 操作最右 ─── */
function SponsorEditor({
item,
onChange,
onUpload,
onRemove,
}: {
item: SponsorItem;
onChange: (p: SponsorItem) => void;
onUpload: (file: File) => void;
onRemove: () => void;
}) {
return (
<div className="j13-ads-editor">
{/* Logo:点击即上传 */}
<label className="j13-ads-logo shrink-0 cursor-pointer" title="点击上传/更换 Logo">
{item.logo_url ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={item.logo_url} alt="" className="h-full w-full rounded-lg object-contain" />
) : (
<span className="flex flex-col items-center gap-1 text-[11px]" style={{ color: "var(--ink-3)" }}>
<Upload size={15} />
上传 Logo
</span>
)}
<input
type="file"
accept="image/png,image/jpeg,image/webp,image/gif"
className="hidden"
onChange={(e) => {
const f = e.target.files?.[0];
if (f) onUpload(f);
e.target.value = "";
}}
/>
</label>
{/* 字段区:输入框拉满,操作钉最右 */}
<div className="flex min-w-0 flex-1 flex-col gap-2.5">
<div className="flex items-center gap-2">
<input
className="field min-w-0 flex-1"
value={item.name}
onChange={(e) => onChange({ ...item, name: e.target.value })}
placeholder="赞助商名称"
maxLength={32}
/>
<div className="flex shrink-0 items-center gap-3">
<label className="flex items-center gap-1.5 text-[12.5px]" style={{ color: "var(--ink-2)" }}>
<AdminSwitch
label="启用该赞助商"
checked={item.enabled}
onChange={() => onChange({ ...item, enabled: !item.enabled })}
/>
启用
</label>
<button type="button" className="btn btn-line btn-sm" onClick={onRemove} aria-label="删除赞助商">
<Trash2 size={14} />
</button>
</div>
</div>
<input
className="field w-full"
value={item.link_url}
onChange={(e) => onChange({ ...item, link_url: e.target.value })}
placeholder="链接(可选,带 https)"
/>
<textarea
className="field w-full min-h-[56px]"
value={item.hover_text}
onChange={(e) => onChange({ ...item, hover_text: e.target.value })}
placeholder="悬停 / 聚焦时显示的赞助说明"
maxLength={120}
/>
<input
className="field w-full text-[12px]"
value={item.logo_url}
onChange={(e) => onChange({ ...item, logo_url: e.target.value })}
placeholder="或直接填写 https 图片地址(已上传可留空)"
/>
</div>
</div>
);
}