新增: - 书库导入导出(library_import/library_export)及测试 - 图片变体生成(image_variants)与响应式图片(responsiveImage) - 书籍搜索(bookSearch)+ BookSearch/LibrarySearchGrid 组件 - 小组件运行时(widgetRuntime)与静态检查(widgetLint) - 上传缓存中间件(upload_cache)与字体 CSS 提取脚本 其它: - 后端 handlers/services 全量调整 - 前端页面、组件、库函数与配置更新
594 lines
19 KiB
TypeScript
594 lines
19 KiB
TypeScript
"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";
|
||
}
|