feat: add ads/sponsor/captcha modules and polish home & chat UI
This commit is contained in:
1103
frontend/app/admin/ads/AdsAdmin.tsx
Normal file
1103
frontend/app/admin/ads/AdsAdmin.tsx
Normal file
File diff suppressed because it is too large
Load Diff
56
frontend/app/admin/ads/page.tsx
Normal file
56
frontend/app/admin/ads/page.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Suspense } from "react";
|
||||
import { cookies } from "next/headers";
|
||||
import AdsAdmin from "./AdsAdmin";
|
||||
import { authCookieHeader } from "@/lib/cookies";
|
||||
import {
|
||||
fetchAdminAds,
|
||||
fetchAdminAdsConfig,
|
||||
fetchAdminSponsorsConfig,
|
||||
type AdConfig,
|
||||
type AdRecord,
|
||||
type SponsorsConfig,
|
||||
} from "@/lib/api";
|
||||
import { getMeCached } from "@/lib/serverData";
|
||||
import { isAdminOrAbove } from "@/lib/roles";
|
||||
import Forbidden from "../Forbidden";
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "推广与赞助",
|
||||
};
|
||||
|
||||
export default async function AdminAdsPage() {
|
||||
const cookie = authCookieHeader(await cookies());
|
||||
const me = await getMeCached(cookie || undefined);
|
||||
if (!me.user || !isAdminOrAbove(me.user.role)) {
|
||||
return <Forbidden text="推广与赞助管理仅管理员及以上角色可操作。" />;
|
||||
}
|
||||
|
||||
let ads: AdRecord[] = [];
|
||||
let cfg: AdConfig | null = null;
|
||||
let sponsors: SponsorsConfig | null = null;
|
||||
let initError = "";
|
||||
try {
|
||||
const [list, config, sp] = await Promise.all([
|
||||
fetchAdminAds(cookie || undefined),
|
||||
fetchAdminAdsConfig(cookie || undefined),
|
||||
fetchAdminSponsorsConfig(cookie || undefined),
|
||||
]);
|
||||
ads = list.ads ?? [];
|
||||
cfg = config;
|
||||
sponsors = sp;
|
||||
} catch (e) {
|
||||
initError = e instanceof Error ? e.message : "加载失败";
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={<p className="meta px-1 py-6">加载中…</p>}>
|
||||
<AdsAdmin
|
||||
initialAds={ads}
|
||||
initialConfig={cfg}
|
||||
initialSponsors={sponsors}
|
||||
initError={initError}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
574
frontend/app/ads/buy/AdsBuyClient.tsx
Normal file
574
frontend/app/ads/buy/AdsBuyClient.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
"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";
|
||||
|
||||
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 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={selectedPay.qr_url}
|
||||
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}` : ""}
|
||||
</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;
|
||||
return (
|
||||
<ChipTab key={d.days} active={active} onClick={() => setDays(d.days)} compact>
|
||||
{d.label.replace(/\s/g, "")}
|
||||
</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={selectedPay.qr_url}
|
||||
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,
|
||||
}: {
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
children: React.ReactNode;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={`j13-ads-chip${active ? " is-on" : ""}${compact ? " is-compact" : ""}`}
|
||||
>
|
||||
{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";
|
||||
}
|
||||
15
frontend/app/ads/buy/page.tsx
Normal file
15
frontend/app/ads/buy/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import type { Metadata } from "next";
|
||||
import AdsBuyClient from "./AdsBuyClient";
|
||||
import { requestCanonical } from "@/lib/canonicalMeta";
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
return {
|
||||
title: "自助购买广告位",
|
||||
description: "在首页侧栏投放图片或文字推广,支持游客申购。",
|
||||
...(await requestCanonical("/ads/buy")),
|
||||
};
|
||||
}
|
||||
|
||||
export default function AdsBuyPage() {
|
||||
return <AdsBuyClient />;
|
||||
}
|
||||
@@ -738,8 +738,7 @@ function CreateGroupModal({
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={32}
|
||||
placeholder="2-32 个字符"
|
||||
className="w-full px-3.5 h-10 rounded-lg outline-none"
|
||||
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
|
||||
className="field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -751,8 +750,7 @@ function CreateGroupModal({
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={200}
|
||||
rows={3}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg outline-none resize-none"
|
||||
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
|
||||
className="field resize-none"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -796,8 +794,7 @@ function CreateGroupModal({
|
||||
value={memberInput}
|
||||
onChange={(e) => setMemberInput(e.target.value)}
|
||||
placeholder="用户名,多个用空格或逗号分隔"
|
||||
className="w-full px-3.5 h-10 rounded-lg outline-none"
|
||||
style={{ background: "var(--bg)", border: "1px solid var(--line)", color: "var(--ink)" }}
|
||||
className="field"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1856,12 +1856,7 @@ function InvitePanel({
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
placeholder="username1, username2"
|
||||
className="w-full px-3.5 h-10 rounded-lg outline-none"
|
||||
style={{
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
className="field"
|
||||
/>
|
||||
<button
|
||||
onClick={submit}
|
||||
@@ -1929,12 +1924,7 @@ function SettingsPanel({
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
maxLength={32}
|
||||
className="w-full px-3.5 h-10 rounded-lg outline-none"
|
||||
style={{
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
className="field"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
@@ -1949,12 +1939,7 @@ function SettingsPanel({
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
maxLength={200}
|
||||
rows={4}
|
||||
className="w-full px-3.5 py-2.5 rounded-lg outline-none resize-none"
|
||||
style={{
|
||||
background: "var(--bg)",
|
||||
border: "1px solid var(--line)",
|
||||
color: "var(--ink)",
|
||||
}}
|
||||
className="field resize-none"
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
|
||||
@@ -89,6 +89,12 @@
|
||||
--r: 12px;
|
||||
--r-sm: 12px;
|
||||
|
||||
/* 文本控件:柔和默认边 + 贴边细聚焦(见下方 .j13-control-* / 各输入选择器) */
|
||||
--control-border: var(--line);
|
||||
--control-border-hover: color-mix(in srgb, var(--line-2) 40%, var(--line));
|
||||
--focus-glow: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
--focus-glow-danger: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
|
||||
/* 顶栏 68px 条 + 1px 底边;main 桌面 py-9。侧栏 sticky 必须等于
|
||||
栏在 scrollY=0 时的视口 top,否则第一下滚动会先空走一截再吸住 */
|
||||
--header-bar: 68px;
|
||||
@@ -167,6 +173,12 @@
|
||||
--bg-grid-line: rgba(238, 241, 246, 0.045);
|
||||
--bg-grid-size: 28px;
|
||||
|
||||
/* 暗色沿用浅色同款 color-mix 公式(依赖上方已覆写的 --line / --accent 等) */
|
||||
--control-border: var(--line);
|
||||
--control-border-hover: color-mix(in srgb, var(--line-2) 40%, var(--line));
|
||||
--focus-glow: color-mix(in srgb, var(--accent) 22%, transparent);
|
||||
--focus-glow-danger: color-mix(in srgb, var(--danger) 18%, transparent);
|
||||
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
@@ -1341,18 +1353,13 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
|
||||
min-width: 0;
|
||||
padding: 8px 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--panel);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.j13-deadline-field:last-child {
|
||||
width: 8.75rem;
|
||||
}
|
||||
.j13-deadline-field:focus-within {
|
||||
border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.j13-deadline-field-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -1639,26 +1646,20 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
|
||||
width: 100%;
|
||||
padding: 10px 14px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-family: var(--font-sans);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.j13-compose-field::placeholder { color: var(--ink-3); }
|
||||
.j13-compose-field:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.j13-compose-field:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.j13-compose-field-invalid {
|
||||
border-color: color-mix(in srgb, var(--danger) 55%, var(--line));
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--danger) 14%, transparent);
|
||||
box-shadow: 0 0 0 2px var(--focus-glow-danger);
|
||||
}
|
||||
|
||||
.j13-compose-title {
|
||||
@@ -1697,13 +1698,8 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
|
||||
padding: 4px 12px;
|
||||
cursor: text;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.j13-tag-field:focus-within {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.j13-tag-field-input,
|
||||
.j13-tag-field-input:focus,
|
||||
@@ -1723,23 +1719,13 @@ html.j13-has-admin-bg[data-j13-chrome="admin"] body::after {
|
||||
min-height: 3.25rem;
|
||||
padding: 8px 10px 8px 8px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
color: var(--ink);
|
||||
text-align: left;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, box-shadow 0.15s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
.j13-board-trigger:hover:not(:disabled) {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
|
||||
}
|
||||
.j13-board-trigger:focus { outline: none; }
|
||||
.j13-board-trigger:focus-visible,
|
||||
.j13-board-trigger[aria-expanded="true"] {
|
||||
outline: none !important;
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.j13-board-trigger:disabled {
|
||||
opacity: 0.65;
|
||||
cursor: not-allowed;
|
||||
@@ -2000,15 +1986,11 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
|
||||
height: 34px;
|
||||
padding: 0 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--bg);
|
||||
color: var(--ink);
|
||||
font-size: 13px;
|
||||
}
|
||||
.j13-md-popover-input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 0;
|
||||
}
|
||||
.j13-md-popover-list {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
@@ -2327,19 +2309,14 @@ html.dark .j13-board-trigger.is-invalid .j13-board-trigger-hint {
|
||||
height: 36px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
.j13-hide-pwd-input:focus {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 0;
|
||||
}
|
||||
.j13-hide-pwd-input.is-invalid {
|
||||
border-color: var(--danger);
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 2px color-mix(in srgb, var(--danger) 22%, transparent);
|
||||
box-shadow: 0 0 0 2px var(--focus-glow-danger);
|
||||
}
|
||||
.j13-hide-pwd-error {
|
||||
margin: 0;
|
||||
@@ -3361,11 +3338,91 @@ h1, h2, h3, h4 {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 消息侧栏搜索:单层描边,避免外框 + focus-visible 双线 */
|
||||
.j13-chat-search:focus-within {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
/* ---------- 文本控件:统一柔边 + 贴边 2px 聚焦(消双层嵌套) ----------
|
||||
未分层,才能压过上方全局 :focus-visible 的 outline-offset。 */
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]):not([type="color"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]):not([type="hidden"]):focus,
|
||||
input:not([type="checkbox"]):not([type="radio"]):not([type="range"]):not([type="file"]):not([type="color"]):not([type="button"]):not([type="submit"]):not([type="reset"]):not([type="image"]):not([type="hidden"]):focus-visible,
|
||||
textarea:focus,
|
||||
textarea:focus-visible,
|
||||
select:focus,
|
||||
select:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.field,
|
||||
.j13-compose-field,
|
||||
.j13-search-field,
|
||||
.j13-md-popover-input,
|
||||
.j13-hide-pwd-input,
|
||||
.j13-tl-field,
|
||||
.j13-tl-urls,
|
||||
.j13-tl-select,
|
||||
.admin-doc-number {
|
||||
border-color: var(--control-border);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.field:hover:not(:focus):not(:disabled),
|
||||
.j13-compose-field:hover:not(:focus):not(:disabled),
|
||||
.j13-search-field:hover:not(:focus):not(:disabled),
|
||||
.j13-md-popover-input:hover:not(:focus):not(:disabled),
|
||||
.j13-hide-pwd-input:hover:not(:focus):not(:disabled),
|
||||
.j13-tl-field:hover:not(:focus):not(:disabled),
|
||||
.j13-tl-urls:hover:not(:focus):not(:disabled),
|
||||
.j13-tl-select:hover:not(:focus):not(:disabled),
|
||||
.admin-doc-number:hover:not(:focus):not(:disabled) {
|
||||
border-color: var(--control-border-hover);
|
||||
}
|
||||
.field:focus,
|
||||
.j13-compose-field:focus,
|
||||
.j13-search-field:focus,
|
||||
.j13-search-field:focus-visible,
|
||||
.j13-md-popover-input:focus,
|
||||
.j13-hide-pwd-input:focus,
|
||||
.j13-tl-field:focus,
|
||||
.j13-tl-urls:focus,
|
||||
.j13-tl-select:focus,
|
||||
.admin-doc-number:focus {
|
||||
outline: none !important;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 2px var(--focus-glow);
|
||||
}
|
||||
|
||||
/* 壳层:边框画在父级,内层输入取消描边 */
|
||||
.admin-field-shell,
|
||||
.admin-stepper,
|
||||
.j13-tag-field,
|
||||
.j13-board-trigger,
|
||||
.j13-deadline-field,
|
||||
.j13-chat-search,
|
||||
.j13-chat-composer,
|
||||
.admin-doc-slug {
|
||||
border-color: var(--control-border);
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.admin-field-shell:hover:not(:focus-within),
|
||||
.admin-stepper:hover:not(:focus-within),
|
||||
.j13-tag-field:hover:not(:focus-within),
|
||||
.j13-board-trigger:hover:not(:disabled):not(:focus-visible):not([aria-expanded="true"]),
|
||||
.j13-deadline-field:hover:not(:focus-within),
|
||||
.j13-chat-search:hover:not(:focus-within),
|
||||
.j13-chat-composer:hover:not(:focus-within),
|
||||
.admin-doc-slug:hover:not(:focus-within) {
|
||||
border-color: var(--control-border-hover);
|
||||
}
|
||||
.admin-field-shell:focus-within,
|
||||
.admin-stepper:focus-within,
|
||||
.j13-tag-field:focus-within,
|
||||
.j13-deadline-field:focus-within,
|
||||
.j13-chat-search:focus-within,
|
||||
.j13-chat-composer:focus-within,
|
||||
.admin-doc-slug:focus-within,
|
||||
.j13-board-trigger:focus-visible,
|
||||
.j13-board-trigger[aria-expanded="true"] {
|
||||
outline: none !important;
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 2px var(--focus-glow);
|
||||
}
|
||||
|
||||
.j13-chat-search-input,
|
||||
.j13-chat-search-input:focus,
|
||||
.j13-chat-search-input:focus-visible {
|
||||
@@ -3373,11 +3430,6 @@ h1, h2, h3, h4 {
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 聊天输入区:与搜索框同款聚焦态 */
|
||||
.j13-chat-composer:focus-within {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.j13-chat-composer-input,
|
||||
.j13-chat-composer-input:focus,
|
||||
.j13-chat-composer-input:focus-visible {
|
||||
@@ -3393,22 +3445,14 @@ h1, h2, h3, h4 {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* 后台输入:与聊天搜索同一套壳层聚焦(未分层,才能压过全局 :focus-visible 双线) */
|
||||
.admin-field-shell,
|
||||
.admin-stepper {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.admin-field-shell:hover:not(:focus-within),
|
||||
.admin-stepper:hover:not(:focus-within) {
|
||||
border-color: color-mix(in srgb, var(--line-2) 40%, var(--line));
|
||||
}
|
||||
.admin-field-shell:focus-within,
|
||||
.admin-stepper:focus-within {
|
||||
border-color: var(--accent) !important;
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
background: var(--panel);
|
||||
}
|
||||
.admin-field-input,
|
||||
@@ -3431,7 +3475,19 @@ h1, h2, h3, h4 {
|
||||
border-color: var(--danger) !important;
|
||||
}
|
||||
.admin-field-shell.is-invalid:focus-within {
|
||||
box-shadow: 0 0 0 3px var(--danger-soft);
|
||||
box-shadow: 0 0 0 2px var(--focus-glow-danger);
|
||||
}
|
||||
|
||||
/* 顶栏搜索:pill 圆角,聚焦同款贴边细环 */
|
||||
.j13-search-field {
|
||||
width: 100%;
|
||||
border: 1px solid var(--control-border);
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
}
|
||||
.j13-search-field:focus,
|
||||
.j13-search-field:focus-visible {
|
||||
background: var(--panel);
|
||||
}
|
||||
.j13-chat-composer-mirror {
|
||||
position: absolute;
|
||||
@@ -4847,15 +4903,11 @@ a.j13-mention-token:hover {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 2.5rem;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 0.7rem;
|
||||
background: var(--panel);
|
||||
overflow: hidden;
|
||||
}
|
||||
.admin-doc-slug:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.admin-doc-slug.is-invalid {
|
||||
border-color: var(--danger);
|
||||
}
|
||||
@@ -4905,17 +4957,12 @@ a.j13-mention-token:hover {
|
||||
width: 7.5rem;
|
||||
height: 2.5rem;
|
||||
padding: 0 0.75rem;
|
||||
border: 1px solid var(--line);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 0.7rem;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 14px;
|
||||
}
|
||||
.admin-doc-number:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
}
|
||||
.admin-doc-actions {
|
||||
min-width: 0;
|
||||
}
|
||||
@@ -5767,21 +5814,14 @@ a.j13-mention-token:hover {
|
||||
width: 100%;
|
||||
padding: 12px 15px;
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: var(--r-sm);
|
||||
color: var(--ink);
|
||||
font-size: 15px;
|
||||
font-family: var(--font-sans);
|
||||
transition: border-color 0.15s, box-shadow 0.15s, background 0.15s;
|
||||
}
|
||||
/* 占位符不使用 opacity:透明度与底色合成后浅色仅 3.7:1,实色 ink-3 达 5.72:1 */
|
||||
.field::placeholder { color: var(--ink-3); }
|
||||
.field:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3.5px var(--accent-soft);
|
||||
background: var(--panel);
|
||||
}
|
||||
textarea.field {
|
||||
font-family: var(--font-sans);
|
||||
font-size: 15px;
|
||||
@@ -6291,7 +6331,7 @@ a.j13-mention-token:hover {
|
||||
.j13-tl-field,
|
||||
.j13-tl-select {
|
||||
width: 100%;
|
||||
border: 1px solid var(--line-2);
|
||||
border: 1px solid var(--control-border);
|
||||
border-radius: 8px;
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
@@ -6568,6 +6608,429 @@ a.j13-mention-token:hover {
|
||||
color: var(--ink);
|
||||
}
|
||||
|
||||
/* 赞助商 logo 网格:悬停 / 聚焦显示赞助说明 */
|
||||
.j13-sponsors-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
.j13-sponsor {
|
||||
position: relative;
|
||||
list-style: none;
|
||||
}
|
||||
.j13-sponsor-hit {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 76px;
|
||||
padding: 10px;
|
||||
border-radius: 12px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel-2);
|
||||
outline: none;
|
||||
transition: border-color 0.15s ease, background 0.15s ease;
|
||||
}
|
||||
.j13-sponsor-hit:hover,
|
||||
.j13-sponsor-hit:focus-visible {
|
||||
border-color: var(--accent);
|
||||
background: var(--panel);
|
||||
}
|
||||
.j13-sponsor-logo {
|
||||
max-width: 100%;
|
||||
max-height: 52px;
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
/* 自助购买广告位:紧凑单栏卡片,色跟随主题 accent */
|
||||
.j13-ads-card {
|
||||
background: var(--panel);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.j13-ads-card-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 14px 18px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-on);
|
||||
}
|
||||
.j13-ads-card-head p {
|
||||
opacity: 0.88;
|
||||
}
|
||||
.j13-ads-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
min-height: 40px;
|
||||
padding: 0 12px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
color: var(--ink);
|
||||
font-size: 13.5px;
|
||||
font-weight: 600;
|
||||
line-height: 1.2;
|
||||
transition: background 0.12s ease, border-color 0.12s ease, color 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.j13-ads-chip:hover:not(.is-on) {
|
||||
border-color: color-mix(in srgb, var(--accent) 40%, var(--line));
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
.j13-ads-chip.is-compact {
|
||||
min-height: 36px;
|
||||
padding: 0 4px;
|
||||
font-size: 12.5px;
|
||||
}
|
||||
.j13-ads-chip.is-on {
|
||||
border-color: var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--accent-on);
|
||||
box-shadow: 0 1px 2px color-mix(in srgb, var(--accent) 28%, transparent);
|
||||
}
|
||||
.j13-ads-chip:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.j13-ads-field {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
min-height: 42px;
|
||||
padding: 0 12px 0 10px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--line);
|
||||
background: var(--panel);
|
||||
}
|
||||
.j13-ads-field:hover {
|
||||
border-color: color-mix(in srgb, var(--line-2) 35%, var(--line));
|
||||
}
|
||||
.j13-ads-field:focus-within {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--focus-glow);
|
||||
}
|
||||
.j13-ads-field-ico {
|
||||
display: inline-flex;
|
||||
color: var(--ink-3);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.j13-ads-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
height: 40px;
|
||||
border: 0;
|
||||
outline: none;
|
||||
background: transparent;
|
||||
color: var(--ink);
|
||||
font-size: 13.5px;
|
||||
}
|
||||
.j13-ads-input::placeholder {
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.j13-ads-submit {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 44px;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
background: var(--accent);
|
||||
color: var(--accent-on);
|
||||
font-size: 15px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.02em;
|
||||
cursor: pointer;
|
||||
box-shadow: 0 1px 2px rgba(16, 24, 40, 0.1),
|
||||
0 2px 8px color-mix(in srgb, var(--accent) 26%, transparent);
|
||||
transition: background 0.12s ease, transform 0.12s ease, box-shadow 0.12s ease;
|
||||
}
|
||||
.j13-ads-submit:hover:not(:disabled) {
|
||||
background: var(--accent-hi);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(16, 24, 40, 0.1),
|
||||
0 8px 18px color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
}
|
||||
.j13-ads-submit:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
.j13-ads-submit:disabled {
|
||||
opacity: 0.45;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* 自助推广侧栏占位:空槽可点去购买 */
|
||||
.j13-ad-slot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
border-radius: 8px;
|
||||
border: 1px dashed color-mix(in srgb, var(--accent) 35%, var(--line));
|
||||
background: color-mix(in srgb, var(--accent-soft) 55%, var(--panel));
|
||||
color: var(--ink-3);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.02em;
|
||||
transition: border-color 0.15s ease, color 0.15s ease, background 0.15s ease, transform 0.18s ease;
|
||||
}
|
||||
.j13-ad-slot:hover {
|
||||
border-color: var(--accent);
|
||||
color: var(--accent);
|
||||
background: var(--accent-soft);
|
||||
}
|
||||
/* 自助推广:图片宽 = 两列文字总宽(248 = 121+6+121) */
|
||||
.j13-ad-promo {
|
||||
width: 248px;
|
||||
max-width: 100%;
|
||||
margin-inline: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.j13-ad-slot-image {
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
max-width: none;
|
||||
margin-inline: 0;
|
||||
min-height: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.j13-ad-image {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 60px;
|
||||
max-width: none;
|
||||
margin-inline: 0;
|
||||
object-fit: cover;
|
||||
border-radius: 8px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.j13-ad-image-link {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin-inline: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--line);
|
||||
transition: opacity 0.15s ease, transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
|
||||
}
|
||||
.j13-ad-image-link:hover {
|
||||
opacity: 1;
|
||||
border-color: color-mix(in srgb, var(--accent) 55%, var(--line));
|
||||
box-shadow: 0 6px 16px -4px color-mix(in srgb, var(--accent) 32%, transparent);
|
||||
}
|
||||
.j13-ad-text-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
.j13-ad-text-grid > li {
|
||||
min-width: 0;
|
||||
}
|
||||
.j13-ad-slot-text {
|
||||
min-height: 34px;
|
||||
padding: 0 6px;
|
||||
font-size: 11.5px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.j13-ad-text {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
border-radius: 6px;
|
||||
padding: 8px 0;
|
||||
text-decoration: none;
|
||||
white-space: nowrap;
|
||||
line-height: 1.2;
|
||||
transition: opacity 0.15s ease, transform 0.18s ease, box-shadow 0.18s ease;
|
||||
}
|
||||
.j13-ad-text.is-overflow {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
.j13-ad-text:hover,
|
||||
.j13-ad-text:focus-visible {
|
||||
opacity: 1;
|
||||
box-shadow: 0 4px 12px -3px color-mix(in srgb, var(--accent) 30%, transparent);
|
||||
}
|
||||
.j13-ad-text:focus-visible {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.j13-ad-text-track {
|
||||
display: inline-flex;
|
||||
}
|
||||
.j13-ad-text-track.is-overflow {
|
||||
width: max-content;
|
||||
transform: translateX(0);
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
.j13-ad-text:hover .j13-ad-text-track.is-overflow,
|
||||
.j13-ad-text:focus-visible .j13-ad-text-track.is-overflow {
|
||||
animation: j13-ad-marquee 10s linear infinite;
|
||||
}
|
||||
.j13-ad-text-run {
|
||||
display: inline-block;
|
||||
padding: 0 12px;
|
||||
font-size: 12.5px;
|
||||
font-weight: 400;
|
||||
}
|
||||
@keyframes j13-ad-marquee {
|
||||
from {
|
||||
transform: translateX(0);
|
||||
}
|
||||
to {
|
||||
transform: translateX(-50%);
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.j13-ad-text-track {
|
||||
animation: none;
|
||||
width: 100%;
|
||||
justify-content: center;
|
||||
}
|
||||
.j13-ad-text-run[aria-hidden] {
|
||||
display: none;
|
||||
}
|
||||
.j13-ad-text-run {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.j13-ad-image-link:hover,
|
||||
.j13-ad-text:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
.j13-ad-slot:hover {
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── 后台「推广与赞助」工作台 ── */
|
||||
/* 复用站点设置骨架(.admin-settings-*):左粘性菜单 + 右内容,此处仅补充区块样式 */
|
||||
/* 锚点跳转时为粘性导航留出避让空间,避免遮挡标题 */
|
||||
[data-section] {
|
||||
scroll-margin-top: 64px;
|
||||
}
|
||||
|
||||
/* 申购行:缩略 + 信息 + 操作同一行,操作始终紧邻数据 */
|
||||
.j13-ads-adrow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
.j13-ads-adrow:hover {
|
||||
border-color: color-mix(in srgb, var(--accent) 35%, var(--line));
|
||||
box-shadow: 0 4px 14px -6px color-mix(in srgb, var(--ink) 18%, transparent);
|
||||
}
|
||||
@media (max-width: 640px) {
|
||||
.j13-ads-adrow {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.j13-ads-adrow > div:last-child {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
padding-top: 2px;
|
||||
}
|
||||
}
|
||||
.j13-ads-thumb {
|
||||
width: 88px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: var(--panel-2);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 8px;
|
||||
}
|
||||
.j13-ads-thumb img {
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
}
|
||||
|
||||
/* 编辑器行(时长档位) */
|
||||
.j13-ads-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
/* 收款码 / 赞助商编辑器:左侧预览 + 右侧字段 */
|
||||
.j13-ads-editor {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
align-items: flex-start;
|
||||
padding: 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 12px;
|
||||
transition: opacity 0.15s ease, border-color 0.15s ease;
|
||||
}
|
||||
.j13-ads-editor.is-off {
|
||||
opacity: 0.6;
|
||||
}
|
||||
.j13-ads-qrbox {
|
||||
width: 96px;
|
||||
height: 96px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: #fff;
|
||||
border: 1px dashed var(--line);
|
||||
border-radius: 10px;
|
||||
transition: border-color 0.15s ease, transform 0.15s ease;
|
||||
}
|
||||
.j13-ads-qrbox:hover {
|
||||
border-color: var(--accent);
|
||||
border-style: solid;
|
||||
}
|
||||
.j13-ads-logo {
|
||||
width: 120px;
|
||||
height: 56px;
|
||||
max-width: 100%;
|
||||
background: #fff;
|
||||
}
|
||||
@media (max-width: 520px) {
|
||||
.j13-ads-editor {
|
||||
flex-direction: column;
|
||||
}
|
||||
.j13-ads-qrbox,
|
||||
.j13-ads-logo {
|
||||
width: 100%;
|
||||
max-width: 140px;
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
|
||||
/* 关于本站:右栏底部署录式目录,与内容模块区分开 */
|
||||
.j13-about-lead {
|
||||
margin-top: 8px;
|
||||
@@ -8158,7 +8621,7 @@ html.j13-reduce-chrome .j13-att-card.is-fresh {
|
||||
.ops-select:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 3px var(--accent-soft);
|
||||
box-shadow: 0 0 0 2px var(--focus-glow);
|
||||
}
|
||||
.ops-datetime { display: flex; flex-direction: column; gap: 0.35rem; max-width: min(22rem, 100%); }
|
||||
.ops-datetime-input { width: 100%; max-width: 16rem; }
|
||||
|
||||
@@ -276,6 +276,12 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
canManagePages={canManagePages}
|
||||
sidebarPages={sidebarPages}
|
||||
siteDescription={publicSettings.site_description}
|
||||
ads={overview.ads ?? []}
|
||||
adsPanelTitle={overview.ads_panel_title || "自助推广"}
|
||||
adsEnabled={overview.ads_enabled !== false}
|
||||
sponsors={overview.sponsors ?? []}
|
||||
sponsorsPanelTitle={overview.sponsors_panel_title || "赞助商"}
|
||||
sponsorsEnabled={overview.sponsors_enabled !== false}
|
||||
/>
|
||||
)
|
||||
)}
|
||||
@@ -311,6 +317,12 @@ export default async function HomePage({ searchParams }: HomePageProps) {
|
||||
canManagePages={canManagePages}
|
||||
sidebarPages={sidebarPages}
|
||||
siteDescription={publicSettings.site_description}
|
||||
ads={overview.ads ?? []}
|
||||
adsPanelTitle={overview.ads_panel_title || "自助推广"}
|
||||
adsEnabled={overview.ads_enabled !== false}
|
||||
sponsors={overview.sponsors ?? []}
|
||||
sponsorsPanelTitle={overview.sponsors_panel_title || "赞助商"}
|
||||
sponsorsEnabled={overview.sponsors_enabled !== false}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
73
frontend/components/AdText.tsx
Normal file
73
frontend/components/AdText.tsx
Normal file
@@ -0,0 +1,73 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
|
||||
type AdTextProps = {
|
||||
title: string;
|
||||
textColor?: string;
|
||||
bgColor?: string;
|
||||
href?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* 文字广告:仅当文字宽度超出容器时才启用跑马灯滚动,
|
||||
* 能完整显示时居中静止。结构与样式复用侧栏 .j13-ad-text。
|
||||
*/
|
||||
export function AdText({ title, textColor, bgColor, href, className = "" }: AdTextProps) {
|
||||
const trackRef = useRef<HTMLSpanElement>(null);
|
||||
const [overflow, setOverflow] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const track = trackRef.current;
|
||||
if (!track) return;
|
||||
const measure = () => {
|
||||
const run = track.querySelector<HTMLElement>(".j13-ad-text-run");
|
||||
const container = track.parentElement;
|
||||
if (!run || !container) return;
|
||||
setOverflow(run.scrollWidth > container.clientWidth);
|
||||
};
|
||||
measure();
|
||||
window.addEventListener("resize", measure);
|
||||
return () => window.removeEventListener("resize", measure);
|
||||
}, [title]);
|
||||
|
||||
const cls = `j13-ad-text${overflow ? " is-overflow" : ""}${className ? ` ${className}` : ""}`;
|
||||
const style: CSSProperties = { color: textColor, background: bgColor };
|
||||
|
||||
const inner = (
|
||||
<span
|
||||
className={`j13-ad-text-track${overflow ? " is-overflow" : ""}`}
|
||||
ref={trackRef}
|
||||
>
|
||||
<span className="j13-ad-text-run">{title}</span>
|
||||
{overflow ? (
|
||||
<span className="j13-ad-text-run" aria-hidden>
|
||||
{title}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
);
|
||||
|
||||
if (href) {
|
||||
return (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className={cls}
|
||||
style={style}
|
||||
data-tip={title}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={cls} style={style} data-tip={title}>
|
||||
{inner}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default AdText;
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
@@ -263,12 +263,7 @@ export default function Header({
|
||||
onKeyDown={onSearchKeyDown}
|
||||
placeholder="搜索帖子..."
|
||||
aria-label="搜索帖子"
|
||||
className="w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
|
||||
style={{
|
||||
background: "var(--panel)",
|
||||
color: "var(--ink)",
|
||||
border: "1px solid var(--line)",
|
||||
}}
|
||||
className="j13-search-field w-full pl-10 pr-10 py-2.5 text-sm rounded-full"
|
||||
/>
|
||||
<kbd
|
||||
className="absolute right-3.5 top-1/2 -translate-y-1/2 text-[11px] w-5 h-5 flex items-center justify-center rounded-md pointer-events-none"
|
||||
@@ -364,8 +359,7 @@ export default function Header({
|
||||
onKeyDown={onSearchKeyDown}
|
||||
aria-label="搜索帖子"
|
||||
placeholder="搜索帖子...(Esc 清除)"
|
||||
className="w-full pl-10 pr-4 py-3 rounded-full text-sm"
|
||||
style={{ background: "var(--panel)", color: "var(--ink)", border: "1px solid var(--line-2)" }}
|
||||
className="j13-search-field w-full pl-10 pr-4 py-3 rounded-full text-sm"
|
||||
/>
|
||||
</form>
|
||||
<div className="flex flex-col">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"use client";
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
Settings,
|
||||
ChevronDown,
|
||||
FileText,
|
||||
CircleDollarSign,
|
||||
} from "lucide-react";
|
||||
import type { User } from "@/lib/api";
|
||||
import { apiAdminPendingCounts, apiLogout } from "@/lib/api";
|
||||
@@ -87,10 +88,16 @@ const NAV_GROUPS: NavGroup[] = [
|
||||
},
|
||||
{
|
||||
href: "/admin/pages",
|
||||
label: "单页",
|
||||
label: "单页管理",
|
||||
icon: FileText,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
{
|
||||
href: "/admin/ads",
|
||||
label: "推广与赞助",
|
||||
icon: CircleDollarSign,
|
||||
visible: (u) => isAdminOrAbove(u.role),
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -13,6 +13,8 @@ import {
|
||||
TrendingUp,
|
||||
Pin,
|
||||
ChevronRight,
|
||||
CircleDollarSign,
|
||||
Handshake,
|
||||
} from "lucide-react";
|
||||
import type {
|
||||
PostListItem,
|
||||
@@ -22,13 +24,17 @@ import type {
|
||||
NewUser,
|
||||
CheckinStatus,
|
||||
SidebarPageItem,
|
||||
PublicAdItem,
|
||||
PublicSponsorItem,
|
||||
} from "@/lib/api";
|
||||
import Avatar from "@/components/Avatar";
|
||||
import { AdText } from "@/components/AdText";
|
||||
import { PulseCheckin } from "./CheckinCard";
|
||||
import { FRIEND_LINKS } from "@/lib/friendLinks";
|
||||
import { formatCompact, formatShortDate } from "@/lib/format";
|
||||
import { pageGlyph } from "@/lib/pageGlyph";
|
||||
import { AboutSiteHeader } from "@/components/site-doc";
|
||||
import { AD_IMAGE_HEIGHT, AD_IMAGE_SIZE_LABEL, AD_IMAGE_WIDTH } from "@/lib/ads";
|
||||
|
||||
/* ---------- 站点数据(2×2 bento 统计瓦片) ---------- */
|
||||
|
||||
@@ -427,8 +433,157 @@ type PanelsProps = {
|
||||
canManagePages?: boolean;
|
||||
sidebarPages?: SidebarPageItem[];
|
||||
siteDescription?: string;
|
||||
ads?: PublicAdItem[];
|
||||
adsPanelTitle?: string;
|
||||
adsEnabled?: boolean;
|
||||
sponsors?: PublicSponsorItem[];
|
||||
sponsorsPanelTitle?: string;
|
||||
sponsorsEnabled?: boolean;
|
||||
};
|
||||
|
||||
function AdsPanel({
|
||||
items,
|
||||
title,
|
||||
enabled,
|
||||
}: {
|
||||
items: PublicAdItem[];
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
if (!enabled) return null;
|
||||
|
||||
const IMAGE_SLOTS = 5;
|
||||
const TEXT_SLOTS = 6;
|
||||
const images = items.filter((a) => a.kind === "image").slice(0, IMAGE_SLOTS);
|
||||
const texts = items.filter((a) => a.kind === "text").slice(0, TEXT_SLOTS);
|
||||
const imageSlots = Array.from({ length: IMAGE_SLOTS }, (_, i) => images[i] ?? null);
|
||||
const textSlots = Array.from({ length: TEXT_SLOTS }, (_, i) => texts[i] ?? null);
|
||||
|
||||
return (
|
||||
<section className="j13-module" aria-label={title || "自助推广"}>
|
||||
<p className="j13-module-title mb-3">
|
||||
<CircleDollarSign size={15} style={{ color: "var(--accent)" }} />
|
||||
{title || "自助推广"}
|
||||
<Link
|
||||
href="/ads/buy"
|
||||
className="meta ml-auto text-[12px] font-normal transition-colors hover:text-[var(--accent)]"
|
||||
>
|
||||
购买广告位
|
||||
</Link>
|
||||
</p>
|
||||
|
||||
<div className="j13-ad-promo">
|
||||
<ul className="space-y-2" aria-label="图片广告位">
|
||||
{imageSlots.map((ad, i) =>
|
||||
ad ? (
|
||||
<li key={`img-${ad.id}`}>
|
||||
<a
|
||||
href={ad.link_url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className="j13-ad-image-link"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={ad.image_url}
|
||||
alt="推广"
|
||||
className="j13-ad-image"
|
||||
width={AD_IMAGE_WIDTH}
|
||||
height={AD_IMAGE_HEIGHT}
|
||||
loading="lazy"
|
||||
/>
|
||||
</a>
|
||||
</li>
|
||||
) : (
|
||||
<li key={`img-slot-${i}`}>
|
||||
<Link href="/ads/buy" className="j13-ad-slot j13-ad-slot-image">
|
||||
<span>图片广告位 {AD_IMAGE_SIZE_LABEL}</span>
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
|
||||
<ul className="j13-ad-text-grid" aria-label="文字广告位">
|
||||
{textSlots.map((ad, i) =>
|
||||
ad ? (
|
||||
<li key={`txt-${ad.id}`}>
|
||||
<AdText
|
||||
title={ad.title ?? ""}
|
||||
textColor={ad.text_color || "#1E293B"}
|
||||
bgColor={ad.bg_color || "#F1F5F9"}
|
||||
href={ad.link_url}
|
||||
/>
|
||||
</li>
|
||||
) : (
|
||||
<li key={`txt-slot-${i}`}>
|
||||
<Link href="/ads/buy" className="j13-ad-slot j13-ad-slot-text">
|
||||
文字广告位
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function SponsorsPanel({
|
||||
items,
|
||||
title,
|
||||
enabled,
|
||||
}: {
|
||||
items: PublicSponsorItem[];
|
||||
title: string;
|
||||
enabled: boolean;
|
||||
}) {
|
||||
if (!enabled || items.length === 0) return null;
|
||||
return (
|
||||
<section className="j13-module" aria-label={title || "赞助商"}>
|
||||
<p className="j13-module-title mb-3">
|
||||
<Handshake size={15} style={{ color: "var(--accent)" }} />
|
||||
{title || "赞助商"}
|
||||
</p>
|
||||
<ul className="j13-sponsors-grid">
|
||||
{items.map((sp) => {
|
||||
const tip = (sp.hover_text || sp.name || "").trim();
|
||||
const href = sp.link_url?.trim() || undefined;
|
||||
const inner = (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img src={sp.logo_url} alt={sp.name} className="j13-sponsor-logo" loading="lazy" />
|
||||
);
|
||||
return (
|
||||
<li key={sp.id} className="j13-sponsor">
|
||||
{href ? (
|
||||
<a
|
||||
href={href}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer sponsored"
|
||||
className="j13-sponsor-hit"
|
||||
aria-label={sp.name}
|
||||
data-tip={tip || undefined}
|
||||
>
|
||||
{inner}
|
||||
</a>
|
||||
) : (
|
||||
<span
|
||||
className="j13-sponsor-hit"
|
||||
tabIndex={0}
|
||||
aria-label={sp.name}
|
||||
data-tip={tip || undefined}
|
||||
>
|
||||
{inner}
|
||||
</span>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityPanelsBody({
|
||||
stats,
|
||||
hot,
|
||||
@@ -441,11 +596,19 @@ function CommunityPanelsBody({
|
||||
canManagePages,
|
||||
sidebarPages = [],
|
||||
siteDescription,
|
||||
ads = [],
|
||||
adsPanelTitle = "自助推广",
|
||||
adsEnabled = true,
|
||||
sponsors = [],
|
||||
sponsorsPanelTitle = "赞助商",
|
||||
sponsorsEnabled = true,
|
||||
}: PanelsProps) {
|
||||
return (
|
||||
<>
|
||||
<PulseCard stats={stats} checkin={checkin} allowComments={allowComments} />
|
||||
<Announcements items={announcements} canManageAnnouncements={canManageAnnouncements} />
|
||||
<AdsPanel items={ads} title={adsPanelTitle} enabled={adsEnabled} />
|
||||
<SponsorsPanel items={sponsors} title={sponsorsPanelTitle} enabled={sponsorsEnabled} />
|
||||
<HotRanking posts={hot} allowComments={allowComments} />
|
||||
<NewUsers users={newUsers} />
|
||||
<ActiveUsers users={activeUsers} allowComments={allowComments} />
|
||||
|
||||
8
frontend/lib/ads.ts
Normal file
8
frontend/lib/ads.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
/** 首页侧栏图片广告展示尺寸(与 .j13-ad-promo 宽一致:248 = 两列文字 + 中间 6px 间距) */
|
||||
export const AD_IMAGE_WIDTH = 248;
|
||||
export const AD_IMAGE_HEIGHT = 60;
|
||||
/** 单列文字槽宽度:(248 - 6) / 2 */
|
||||
export const AD_TEXT_COL_WIDTH = 121;
|
||||
/** 购买页建议文案 */
|
||||
export const AD_IMAGE_SIZE_LABEL = `${AD_IMAGE_WIDTH}×${AD_IMAGE_HEIGHT}`;
|
||||
export const AD_IMAGE_SIZE_LABEL_2X = `${AD_IMAGE_WIDTH * 2}×${AD_IMAGE_HEIGHT * 2}`;
|
||||
@@ -213,6 +213,30 @@ export interface OverviewResponse {
|
||||
sidebar_pages?: SidebarPageItem[];
|
||||
new_users: NewUser[];
|
||||
checkin?: CheckinStatus | null;
|
||||
ads?: PublicAdItem[];
|
||||
ads_panel_title?: string;
|
||||
ads_enabled?: boolean;
|
||||
sponsors?: PublicSponsorItem[];
|
||||
sponsors_panel_title?: string;
|
||||
sponsors_enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface PublicAdItem {
|
||||
id: number;
|
||||
kind: "image" | "text" | string;
|
||||
link_url: string;
|
||||
image_url?: string;
|
||||
title?: string;
|
||||
text_color?: string;
|
||||
bg_color?: string;
|
||||
}
|
||||
|
||||
export interface PublicSponsorItem {
|
||||
id: string;
|
||||
name: string;
|
||||
logo_url: string;
|
||||
link_url?: string;
|
||||
hover_text?: string;
|
||||
}
|
||||
|
||||
/** 板块右栏:管理员公开资料 */
|
||||
@@ -3031,3 +3055,164 @@ export async function apiOperations<T = Record<string, unknown>>(path: string, m
|
||||
if (!res.ok) throw new Error(data.error || "操作失败,请重试");
|
||||
return data as T;
|
||||
}
|
||||
|
||||
/* ---------- 自助推广 / 赞助商 ---------- */
|
||||
|
||||
export interface AdDurationOption {
|
||||
days: number;
|
||||
label: string;
|
||||
price_hint: string;
|
||||
}
|
||||
|
||||
export interface AdPaymentOption {
|
||||
id: string;
|
||||
name: string;
|
||||
kind: "alipay" | "wechat" | "other" | string;
|
||||
qr_url: string;
|
||||
hint: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface AdConfig {
|
||||
enabled: boolean;
|
||||
panel_title: string;
|
||||
durations: AdDurationOption[];
|
||||
payments: AdPaymentOption[];
|
||||
}
|
||||
|
||||
export interface SponsorItem {
|
||||
id: string;
|
||||
name: string;
|
||||
logo_url: string;
|
||||
link_url: string;
|
||||
hover_text: string;
|
||||
enabled: boolean;
|
||||
sort_order: number;
|
||||
}
|
||||
|
||||
export interface SponsorsConfig {
|
||||
enabled: boolean;
|
||||
panel_title: string;
|
||||
items: SponsorItem[];
|
||||
}
|
||||
|
||||
export interface AdRecord {
|
||||
id: number;
|
||||
kind: string;
|
||||
status: string;
|
||||
contact_email: string;
|
||||
link_url: string;
|
||||
image_url?: string;
|
||||
title?: string;
|
||||
text_color?: string;
|
||||
bg_color?: string;
|
||||
duration_days: number;
|
||||
payment_id: string;
|
||||
buyer_note?: string;
|
||||
starts_at?: string | null;
|
||||
ends_at?: string | null;
|
||||
reject_reason?: string;
|
||||
sort_order: number;
|
||||
created_at: string;
|
||||
reviewed_at?: string | null;
|
||||
}
|
||||
|
||||
export async function apiFetchCaptcha(): Promise<{ id: string; image: string }> {
|
||||
const res = await fetch("/api/captcha", { credentials: "include", cache: "no-store" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || "获取验证码失败");
|
||||
return data as { id: string; image: string };
|
||||
}
|
||||
|
||||
export async function apiAdsPublicConfig(): Promise<AdConfig> {
|
||||
const res = await fetch("/api/ads/config", { credentials: "include", cache: "no-store" });
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || "加载广告配置失败");
|
||||
return data as AdConfig;
|
||||
}
|
||||
|
||||
export async function apiSubmitAd(body: Record<string, unknown>): Promise<{ ad: AdRecord; message: string }> {
|
||||
const res = await fetch("/api/ads", {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: clientHeaders({ "Content-Type": "application/json" }),
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || "提交失败");
|
||||
return data as { ad: AdRecord; message: string };
|
||||
}
|
||||
|
||||
export async function apiAdminSaveAdsConfig(cfg: AdConfig): Promise<AdConfig> {
|
||||
return apiOperations<AdConfig>("/api/admin/ads/config", "PUT", cfg);
|
||||
}
|
||||
|
||||
export async function apiAdminListAds(
|
||||
status = "",
|
||||
page = 1
|
||||
): Promise<{ ads: AdRecord[]; total: number }> {
|
||||
const q = new URLSearchParams({ page: String(page), size: "30" });
|
||||
if (status) q.set("status", status);
|
||||
return apiOperations(`/api/admin/ads?${q}`);
|
||||
}
|
||||
|
||||
export async function fetchAdminAds(
|
||||
cookie?: string,
|
||||
status = ""
|
||||
): Promise<{ ads: AdRecord[]; total: number }> {
|
||||
const q = new URLSearchParams({ page: "1", size: "50" });
|
||||
if (status) q.set("status", status);
|
||||
const res = await fetch(`${API_BASE}/api/admin/ads?${q}`, {
|
||||
...ssrInit(cookie ? { Cookie: cookie } : undefined),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("加载广告列表失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function fetchAdminAdsConfig(cookie?: string): Promise<AdConfig> {
|
||||
const res = await fetch(`${API_BASE}/api/admin/ads/config`, {
|
||||
...ssrInit(cookie ? { Cookie: cookie } : undefined),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("加载广告配置失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export async function apiAdminApproveAd(id: number) {
|
||||
return apiOperations(`/api/admin/ads/${id}/approve`, "PUT");
|
||||
}
|
||||
|
||||
export async function apiAdminRejectAd(id: number, reason = "") {
|
||||
return apiOperations(`/api/admin/ads/${id}/reject`, "PUT", { reason });
|
||||
}
|
||||
|
||||
export async function apiAdminDeleteAd(id: number) {
|
||||
return apiOperations(`/api/admin/ads/${id}`, "DELETE");
|
||||
}
|
||||
|
||||
export async function apiAdminUploadAdQR(file: File): Promise<{ url: string }> {
|
||||
const fd = new FormData();
|
||||
fd.append("file", file);
|
||||
const res = await fetchWithRefresh("/api/admin/upload/ad-qr", {
|
||||
method: "POST",
|
||||
headers: clientHeaders(),
|
||||
body: fd,
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error((data as { error?: string }).error || "上传失败");
|
||||
return data as { url: string };
|
||||
}
|
||||
|
||||
export async function apiAdminSaveSponsorsConfig(cfg: SponsorsConfig): Promise<SponsorsConfig> {
|
||||
return apiOperations<SponsorsConfig>("/api/admin/sponsors/config", "PUT", cfg);
|
||||
}
|
||||
|
||||
export async function fetchAdminSponsorsConfig(cookie?: string): Promise<SponsorsConfig> {
|
||||
const res = await fetch(`${API_BASE}/api/admin/sponsors/config`, {
|
||||
...ssrInit(cookie ? { Cookie: cookie } : undefined),
|
||||
credentials: "include",
|
||||
});
|
||||
if (!res.ok) throw new Error("加载赞助商配置失败");
|
||||
return res.json();
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user