Files
jiang13-bbs/frontend/app/ads/buy/AdsBuyClient.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

594 lines
19 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 { useCallback, useEffect, useState } from "react";
import Link from "next/link";
import {
RefreshCw,
ImageIcon,
Type,
CheckCircle2,
Mail,
Link2,
Shield,
AlertCircle,
} from "lucide-react";
import {
apiAdsPublicConfig,
apiFetchCaptcha,
apiSubmitAd,
type AdConfig,
type AdPaymentOption,
} from "@/lib/api";
import ImageLightbox, { type LightboxItem } from "@/components/markdown/ImageLightbox";
import { AdText } from "@/components/AdText";
import { toast } from "@/lib/toast";
import {
AD_IMAGE_HEIGHT,
AD_IMAGE_SIZE_LABEL,
AD_IMAGE_SIZE_LABEL_2X,
AD_IMAGE_WIDTH,
AD_TEXT_COL_WIDTH,
} from "@/lib/ads";
import { densitySrcSet, isOwnImage, pickVariantWidth, variantURL } from "@/lib/responsiveImage";
type Kind = "image" | "text";
export default function AdsBuyClient() {
const [cfg, setCfg] = useState<AdConfig | null>(null);
const [loadErr, setLoadErr] = useState("");
const [kind, setKind] = useState<Kind>("image");
const [email, setEmail] = useState("");
const [link, setLink] = useState("");
const [imageURL, setImageURL] = useState("");
const [title, setTitle] = useState("");
const [textColor, setTextColor] = useState("#1E293B");
const [bgColor, setBgColor] = useState("#F1F5F9");
const [days, setDays] = useState(0);
const [paymentID, setPaymentID] = useState("");
const [note, setNote] = useState("");
const [captchaID, setCaptchaID] = useState("");
const [captchaImg, setCaptchaImg] = useState("");
const [captchaCode, setCaptchaCode] = useState("");
const [busy, setBusy] = useState(false);
const [done, setDone] = useState(false);
const [lightbox, setLightbox] = useState<{ items: LightboxItem[]; index: number } | null>(null);
const refreshCaptcha = useCallback(async () => {
try {
const c = await apiFetchCaptcha();
setCaptchaID(c.id);
setCaptchaImg(c.image);
setCaptchaCode("");
} catch (e) {
toast(e instanceof Error ? e.message : "验证码加载失败", "error");
}
}, []);
useEffect(() => {
let alive = true;
apiAdsPublicConfig()
.then((c) => {
if (!alive) return;
setCfg(c);
if (c.durations[0]) setDays(c.durations[0].days);
const pay = c.payments.find((p) => p.enabled && p.qr_url);
if (pay) setPaymentID(pay.id);
})
.catch((e) => {
if (alive) setLoadErr(e instanceof Error ? e.message : "加载失败");
});
void refreshCaptcha();
return () => {
alive = false;
};
}, [refreshCaptcha]);
const selectedPay: AdPaymentOption | undefined = cfg?.payments.find((p) => p.id === paymentID);
const selectedDuration = cfg?.durations.find((d) => d.days === days);
const selectedPrice = selectedDuration
? (kind === "text" ? selectedDuration.price_hint_text : selectedDuration.price_hint).trim()
: "";
const canSubmit = !!cfg && cfg.payments.length > 0 && !busy;
const kindLabel = kind === "image" ? "图片广告" : "文字广告";
const openLightbox = (src: string, alt = "") => {
setLightbox({ items: [{ src, alt }], index: 0 });
};
const onSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!canSubmit) return;
setBusy(true);
try {
await apiSubmitAd({
kind,
contact_email: email,
link_url: link,
image_url: kind === "image" ? imageURL : "",
title: kind === "text" ? title : "",
text_color: kind === "text" ? textColor : "",
bg_color: kind === "text" ? bgColor : "",
duration_days: days,
payment_id: paymentID,
buyer_note: note,
captcha_id: captchaID,
captcha_code: captchaCode,
});
setDone(true);
toast("提交成功,请完成付款并等待审核", "ok");
} catch (err) {
toast(err instanceof Error ? err.message : "提交失败", "error");
void refreshCaptcha();
} finally {
setBusy(false);
}
};
if (loadErr) {
return (
<Shell>
<div className="j13-ads-card p-8 text-center">
<p className="font-semibold" style={{ color: "var(--ink)" }}>
{loadErr}
</p>
<Link href="/" className="btn btn-line mt-5">
返回首页
</Link>
</div>
</Shell>
);
}
if (!cfg) {
return (
<Shell>
<div className="j13-ads-card p-6">
<p className="meta">加载中…</p>
</div>
</Shell>
);
}
if (!cfg.enabled) {
return (
<Shell>
<div className="j13-ads-card p-8 text-center">
<p className="text-lg font-semibold" style={{ color: "var(--ink)" }}>
自助推广暂未开放
</p>
<Link href="/" className="btn btn-primary mt-5">
返回首页
</Link>
</div>
</Shell>
);
}
if (done) {
return (
<Shell>
<div className="j13-ads-card overflow-hidden text-center">
<div className="j13-ads-card-head">
<h1 className="text-[17px] font-bold">申请已提交</h1>
</div>
<div className="space-y-4 px-5 py-6">
<CheckCircle2 size={36} className="mx-auto" style={{ color: "var(--ok)" }} />
<p className="meta mx-auto max-w-sm text-[13px] leading-relaxed">
请完成付款,管理员确认后将出现在首页侧栏。
</p>
{selectedPay?.qr_url ? (
<button
type="button"
className="mx-auto block cursor-zoom-in"
onClick={() => openLightbox(selectedPay.qr_url, `${selectedPay.name}收款码`)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isOwnImage(selectedPay.qr_url) ? variantURL(selectedPay.qr_url, pickVariantWidth(160)) : selectedPay.qr_url}
srcSet={isOwnImage(selectedPay.qr_url) ? densitySrcSet(selectedPay.qr_url, 160) : undefined}
alt="收款码"
className="mx-auto h-40 w-40 rounded-xl border bg-white object-contain p-2"
style={{ borderColor: "var(--line)" }}
/>
</button>
) : null}
<div className="flex justify-center gap-3 pt-1">
<Link href="/" className="btn btn-primary">
返回首页
</Link>
<button
type="button"
className="btn btn-line"
onClick={() => {
setDone(false);
void refreshCaptcha();
}}
>
再投一条
</button>
</div>
</div>
</div>
{lightbox ? (
<ImageLightbox
items={lightbox.items}
index={lightbox.index}
onClose={() => setLightbox(null)}
onIndexChange={(i) => setLightbox((prev) => (prev ? { ...prev, index: i } : prev))}
/>
) : null}
</Shell>
);
}
return (
<Shell>
<form onSubmit={onSubmit} className="j13-ads-card overflow-hidden">
<div className="j13-ads-card-head">
<h1 className="text-[17px] font-bold tracking-tight">自助购买广告位</h1>
<p className="text-[12.5px]">
{kindLabel}
{selectedDuration ? ` · ${selectedDuration.label}` : ""}
{selectedPrice ? ` · ${selectedPrice}` : ""}
</p>
</div>
<div className="space-y-3 px-4 py-4 sm:px-5 sm:py-5">
<AdPreview
kind={kind}
imageURL={imageURL}
title={title}
textColor={textColor}
bgColor={bgColor}
onImageClick={() => openLightbox(imageURL, "图片广告预览")}
/>
<div className="grid grid-cols-2 gap-2">
<ChipTab active={kind === "image"} onClick={() => setKind("image")}>
<ImageIcon size={15} /> 图片广告
</ChipTab>
<ChipTab active={kind === "text"} onClick={() => setKind("text")}>
<Type size={15} /> 文字广告
</ChipTab>
</div>
<IconField icon={<Mail size={15} />}>
<input
type="email"
required
className="j13-ads-input"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="联系邮箱"
autoComplete="email"
/>
</IconField>
<IconField icon={<Link2 size={15} />}>
<input
type="url"
required
className="j13-ads-input"
value={link}
onChange={(e) => setLink(e.target.value)}
placeholder="广告链接,带 https"
/>
</IconField>
{kind === "image" ? (
<>
<IconField icon={<ImageIcon size={15} />}>
<input
type="url"
required
className="j13-ads-input"
value={imageURL}
onChange={(e) => setImageURL(e.target.value)}
placeholder={`图片地址,带 https,尺寸 ${AD_IMAGE_SIZE_LABEL}(高清 ${AD_IMAGE_SIZE_LABEL_2X})`}
/>
</IconField>
</>
) : (
<>
<IconField icon={<Type size={15} />}>
<input
type="text"
required
maxLength={24}
className="j13-ads-input"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="广告标题,最多 24 字"
/>
</IconField>
<div className="grid grid-cols-2 gap-2">
<label className="j13-ads-field flex items-center gap-2 px-2.5">
<input
type="color"
value={normalizeHex(textColor)}
onChange={(e) => setTextColor(e.target.value.toUpperCase())}
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 bg-transparent"
/>
<input
className="j13-ads-input !pl-0 font-mono text-[12.5px]"
value={textColor}
onChange={(e) => setTextColor(e.target.value)}
aria-label="文字颜色"
/>
</label>
<label className="j13-ads-field flex items-center gap-2 px-2.5">
<input
type="color"
value={normalizeHex(bgColor)}
onChange={(e) => setBgColor(e.target.value.toUpperCase())}
className="h-7 w-7 shrink-0 cursor-pointer rounded border-0 bg-transparent"
/>
<input
className="j13-ads-input !pl-0 font-mono text-[12.5px]"
value={bgColor}
onChange={(e) => setBgColor(e.target.value)}
aria-label="背景颜色"
/>
</label>
</div>
</>
)}
<div className="grid grid-cols-5 gap-2">
{cfg.durations.map((d) => {
const active = days === d.days;
const price = (kind === "text" ? d.price_hint_text : d.price_hint).trim();
return (
<ChipTab
key={d.days}
active={active}
onClick={() => setDays(d.days)}
compact
priced={!!price}
>
<span>{d.label.replace(/\s/g, "")}</span>
{price ? <span className="j13-ads-chip-price">{price}</span> : null}
</ChipTab>
);
})}
</div>
{cfg.payments.length === 0 ? (
<div
className="flex gap-2 rounded-lg border px-3 py-2.5 text-[12.5px] leading-relaxed"
style={{
borderColor: "color-mix(in srgb, var(--accent) 28%, var(--line))",
background: "var(--accent-soft)",
color: "var(--ink-2)",
}}
>
<AlertCircle size={14} className="mt-0.5 shrink-0" style={{ color: "var(--accent)" }} />
<span>站长尚未配置收款码。</span>
</div>
) : (
<>
<div className="flex flex-wrap gap-2">
{cfg.payments.map((p) => (
<ChipTab key={p.id} active={paymentID === p.id} onClick={() => setPaymentID(p.id)}>
{p.name}
</ChipTab>
))}
</div>
{selectedPay?.qr_url ? (
<button
type="button"
className="mx-auto block cursor-zoom-in rounded-xl border bg-white p-2.5"
style={{ borderColor: "var(--line)" }}
onClick={() => openLightbox(selectedPay.qr_url, `${selectedPay.name}收款码`)}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={isOwnImage(selectedPay.qr_url) ? variantURL(selectedPay.qr_url, pickVariantWidth(144)) : selectedPay.qr_url}
srcSet={isOwnImage(selectedPay.qr_url) ? densitySrcSet(selectedPay.qr_url, 144) : undefined}
alt={`${selectedPay.name}收款码`}
className="h-36 w-36 object-contain"
/>
</button>
) : null}
{selectedPay?.hint ? (
<p className="meta text-center text-[12px] leading-relaxed">{selectedPay.hint}</p>
) : null}
</>
)}
<IconField icon={<Mail size={15} />}>
<input
className="j13-ads-input"
maxLength={100}
value={note}
onChange={(e) => setNote(e.target.value)}
placeholder="备注(可选)"
/>
</IconField>
<div className="flex items-stretch gap-2">
<IconField icon={<Shield size={15} />} className="min-w-0 flex-1">
<input
required
className="j13-ads-input uppercase tracking-widest"
value={captchaCode}
onChange={(e) => setCaptchaCode(e.target.value)}
placeholder="图片验证码"
autoComplete="off"
maxLength={8}
/>
</IconField>
<button
type="button"
onClick={() => void refreshCaptcha()}
className="shrink-0 overflow-hidden rounded-lg border"
style={{ borderColor: "var(--line)" }}
title="点击刷新"
aria-label="刷新验证码"
>
{captchaImg ? (
// eslint-disable-next-line @next/next/no-img-element
<img src={captchaImg} alt="验证码" width={110} height={42} className="block h-[42px] w-[110px] object-cover" />
) : (
<span className="meta flex h-[42px] w-[110px] items-center justify-center text-[12px]">
<RefreshCw size={14} />
</span>
)}
</button>
</div>
<button type="submit" className="j13-ads-submit" disabled={!canSubmit}>
{busy ? "提交中…" : "提交申请"}
</button>
<p className="text-center">
<Link href="/" className="meta text-[12.5px] hover:text-[var(--accent)]">
返回首页
</Link>
</p>
</div>
</form>
{lightbox ? (
<ImageLightbox
items={lightbox.items}
index={lightbox.index}
onClose={() => setLightbox(null)}
onIndexChange={(i) => setLightbox((prev) => (prev ? { ...prev, index: i } : prev))}
/>
) : null}
</Shell>
);
}
function Shell({ children }: { children: React.ReactNode }) {
return <div className="mx-auto w-full max-w-[560px] px-4 py-6 sm:py-10">{children}</div>;
}
function ChipTab({
active,
onClick,
children,
compact,
priced,
}: {
active: boolean;
onClick: () => void;
children: React.ReactNode;
compact?: boolean;
priced?: boolean;
}) {
return (
<button
type="button"
onClick={onClick}
className={`j13-ads-chip${active ? " is-on" : ""}${compact ? " is-compact" : ""}${
priced ? " is-priced" : ""
}`}
>
{children}
</button>
);
}
function IconField({
icon,
children,
className = "",
}: {
icon: React.ReactNode;
children: React.ReactNode;
className?: string;
}) {
return (
<label className={`j13-ads-field ${className}`.trim()}>
<span className="j13-ads-field-ico" aria-hidden>
{icon}
</span>
{children}
</label>
);
}
/**
* 上线预览:复用首页侧栏 .j13-ad-promo 结构与样式,
* 图片位 248×60、文字位两列网格,尺寸/圆角与侧栏完全一致。
*/
function AdPreview({
kind,
imageURL,
title,
textColor,
bgColor,
onImageClick,
}: {
kind: Kind;
imageURL: string;
title: string;
textColor: string;
bgColor: string;
onImageClick: () => void;
}) {
const text = title.trim();
return (
<div className="text-center">
<p className="meta mb-2 text-[12px]" style={{ color: "var(--ink-3)" }}>
上线预览 · 尺寸与首页侧栏一致
</p>
<div className="j13-ad-promo">
{kind === "image" ? (
<ul className="space-y-2" aria-label="图片广告预览">
<li>
{imageURL.startsWith("https://") ? (
<a
href={imageURL}
className="j13-ad-image-link"
onClick={(e) => {
e.preventDefault();
onImageClick();
}}
title="点击放大预览"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageURL}
alt="图片广告预览"
className="j13-ad-image"
width={AD_IMAGE_WIDTH}
height={AD_IMAGE_HEIGHT}
/>
</a>
) : (
<span className="j13-ad-slot j13-ad-slot-image">
图片广告位 {AD_IMAGE_SIZE_LABEL}
</span>
)}
</li>
</ul>
) : (
<ul
className="j13-ad-text-grid"
style={{ display: "flex", justifyContent: "center" }}
aria-label="文字广告预览"
>
<li style={{ width: AD_TEXT_COL_WIDTH, minWidth: 0 }}>
{text.length > 0 ? (
<AdText title={text} textColor={textColor} bgColor={bgColor} />
) : (
<span className="j13-ad-slot j13-ad-slot-text">文字广告位</span>
)}
</li>
</ul>
)}
</div>
</div>
);
}
function normalizeHex(v: string): string {
const s = v.trim();
if (/^#[0-9A-Fa-f]{6}$/.test(s)) return s;
if (/^[0-9A-Fa-f]{6}$/.test(s)) return `#${s}`;
return "#1E293B";
}