1104 lines
34 KiB
TypeScript
1104 lines
34 KiB
TypeScript
"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: "" },
|
||
{ days: 60, label: "2 个月", price_hint: "" },
|
||
{ days: 90, label: "3 个月", price_hint: "" },
|
||
{ days: 180, label: "6 个月", price_hint: "" },
|
||
{ days: 365, label: "12 个月", price_hint: "" },
|
||
],
|
||
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: "" }],
|
||
})
|
||
}
|
||
>
|
||
<Plus size={14} /> 添加
|
||
</button>
|
||
}
|
||
>
|
||
{cfg.durations.length === 0 ? (
|
||
<p className="meta text-[13px]">至少保留一个时长档位。</p>
|
||
) : (
|
||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2">
|
||
{cfg.durations.map((d, i) => (
|
||
<div key={i} className="j13-ads-row">
|
||
<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="天数"
|
||
/>
|
||
<input
|
||
className="field min-w-0 flex-1"
|
||
value={d.label}
|
||
placeholder="标签,如 1 个月"
|
||
onChange={(e) => {
|
||
const durations = [...cfg.durations];
|
||
durations[i] = { ...d, label: e.target.value };
|
||
setCfg({ ...cfg, durations });
|
||
}}
|
||
/>
|
||
<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>
|
||
</div>
|
||
))}
|
||
</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>
|
||
);
|
||
}
|