feat: 媒体库 blob 去重存储与品牌/注册等前端调整

新增:
- blob_store 二进制去重存储 + blob_dedup 测试

其它:
- 后端 settings/auth/media_library/upload/operations 服务与 handler 调整
- 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
This commit is contained in:
2026-10-03 02:38:11 +08:00
parent 8cfdfcbbf0
commit 46e0cdc0b3
32 changed files with 1242 additions and 228 deletions

View File

@@ -138,7 +138,7 @@ function DetailsInner() {
<div className="w-full sm:w-56">
<AdminSearchField
value={path}
onChange={(v) => { setPath(v); }}
onChange={(v) => { setPath(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setPath(""); resetPage(); }}
placeholder="路径模糊匹配,回车应用"
@@ -148,7 +148,7 @@ function DetailsInner() {
<div className="w-full sm:w-48">
<AdminSearchField
value={ip}
onChange={setIp}
onChange={(v) => { setIp(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setIp(""); resetPage(); }}
placeholder="按 IP 精确筛选"
@@ -158,7 +158,7 @@ function DetailsInner() {
<div className="w-full sm:w-44">
<AdminSearchField
value={bot}
onChange={setBot}
onChange={(v) => { setBot(v); resetPage(); }}
onSubmit={resetPage}
onClear={() => { setBot(""); resetPage(); }}
placeholder="按爬虫名精确筛选"

View File

@@ -179,7 +179,8 @@ export default function MediaLibrary({
<Modal
open={active !== null}
onClose={() => setActive(null)}
title={active?.name ?? ""}
title={<span className="break-all">{active?.name ?? ""}</span>}
ariaLabel={active?.name ?? ""}
description={active ? `${active.category_name}${active.uploader ? ` · 上传者 ${active.uploader}` : ""}` : undefined}
maxWidthClass="max-w-2xl"
dismissOnOverlay
@@ -218,6 +219,14 @@ export default function MediaLibrary({
["格式", active.mime],
["上传者", active.uploader || "—"],
["时间", formatTime(active.uploaded_at)],
...(active.ref_count && active.ref_count > 1
? [
[
"引用",
`${active.ref_count} 次上传 · 去重节省 ${formatBytes(active.size * (active.ref_count - 1))}`,
],
]
: []),
].map(([k, v]) => (
<div key={k} className="flex items-baseline gap-2 min-w-0">
<dt className="meta shrink-0 w-11 text-right">{k}</dt>

View File

@@ -2,7 +2,7 @@
import { X } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { AdminField, AdminSettingsActions, AdminSettingsGroup } from "@/components/admin";
import { AdminField, AdminSettingsActions, AdminSettingsGroup, AdminSwitch } from "@/components/admin";
import { apiUpdateSiteSettings, apiOperations, type PublicSettings } from "@/lib/api";
import { BRAND_LIMITS, runeCount, siteDocumentTitle } from "@/lib/brand";
import { toast } from "@/lib/toast";
@@ -25,6 +25,7 @@ export default function BasicIdentityPanel({
}, []);
const [wordmark, setWordmark] = useState(initial.site_wordmark);
const [slogan, setSlogan] = useState(initial.site_slogan);
const [sloganVisible, setSloganVisible] = useState(initial.brand_slogan_visible);
const [desc, setDesc] = useState(initial.site_description);
const [keywords, setKeywords] = useState<string[]>(initial.site_keywords);
const [kwInput, setKwInput] = useState("");
@@ -32,6 +33,7 @@ export default function BasicIdentityPanel({
name: initial.site_name,
wordmark: initial.site_wordmark,
slogan: initial.site_slogan,
sloganVisible: initial.brand_slogan_visible,
desc: initial.site_description,
keywords: initial.site_keywords,
});
@@ -44,6 +46,7 @@ export default function BasicIdentityPanel({
name.trim() !== saved.name ||
wordmark.trim() !== saved.wordmark ||
slogan.trim() !== saved.slogan ||
sloganVisible !== saved.sloganVisible ||
desc.trim() !== saved.desc ||
JSON.stringify(keywords) !== JSON.stringify(saved.keywords);
@@ -85,6 +88,7 @@ export default function BasicIdentityPanel({
setName(saved.name);
setWordmark(saved.wordmark);
setSlogan(saved.slogan);
setSloganVisible(saved.sloganVisible);
setDesc(saved.desc);
setKeywords([...saved.keywords]);
setKwInput("");
@@ -98,6 +102,7 @@ export default function BasicIdentityPanel({
name: name.trim(),
wordmark: wordmark.trim(),
slogan: slogan.trim(),
sloganVisible,
desc: desc.trim(),
keywords: [...keywords],
};
@@ -115,6 +120,7 @@ export default function BasicIdentityPanel({
site_name: snap.name,
site_wordmark: snap.wordmark,
site_slogan: snap.slogan,
brand_slogan_visible: snap.sloganVisible,
site_description: snap.desc,
site_keywords: snap.keywords,
});
@@ -123,12 +129,14 @@ export default function BasicIdentityPanel({
name: res.site_name,
wordmark: res.site_wordmark,
slogan: res.site_slogan,
sloganVisible: res.brand_slogan_visible,
desc: res.site_description,
keywords: res.site_keywords,
});
setName((v) => (v.trim() === snap.name ? res.site_name : v));
setWordmark((v) => (v.trim() === snap.wordmark ? res.site_wordmark : v));
setSlogan((v) => (v.trim() === snap.slogan ? res.site_slogan : v));
setSloganVisible((v) => (v === snap.sloganVisible ? res.brand_slogan_visible : v));
setDesc((v) => (v.trim() === snap.desc ? res.site_description : v));
setKeywords((prev) =>
JSON.stringify(prev) === JSON.stringify(snap.keywords) ? res.site_keywords : prev,
@@ -199,18 +207,49 @@ export default function BasicIdentityPanel({
/>
</div>
<AdminField
id="site-slogan"
label="标语"
className="admin-identity-field"
maxLength={BRAND_LIMITS.slogan}
value={slogan}
onChange={(v) => {
setSlogan(v);
clearSaveError();
}}
placeholder="技术分享与讨论"
/>
<div className="admin-identity-field">
<div className="admin-field-head">
<label className="field-label" htmlFor="site-slogan">
标语
</label>
<span className="admin-field-count" aria-hidden>
{slogan.length}/{BRAND_LIMITS.slogan}
</span>
</div>
<div
className="flex items-center gap-2 mb-2"
title={slogan.trim() ? "在页眉站名下方显示副标题" : "先填写标语后可开启"}
>
<AdminSwitch
checked={sloganVisible}
disabled={!slogan.trim()}
onChange={() => {
setSloganVisible((v) => !v);
clearSaveError();
}}
label="页眉显示标语"
/>
<span
className="text-[12px]"
style={{ color: slogan.trim() ? "var(--ink-2)" : "var(--ink-3)" }}
>
页眉显示标语
</span>
</div>
<div className="admin-field-shell">
<input
id="site-slogan"
className="admin-field-input"
maxLength={BRAND_LIMITS.slogan}
value={slogan}
onChange={(e) => {
setSlogan(e.target.value);
clearSaveError();
}}
placeholder="技术分享与讨论"
/>
</div>
</div>
<p className="admin-identity-title-preview" aria-live="polite">
浏览器标题预览 · <strong>{preview}</strong>
</p>

View File

@@ -1,6 +1,6 @@
"use client";
import { Check, ImagePlus, Images, Link2, X } from "lucide-react";
import { Check, Crop, ImagePlus, Images, Link2, X } from "lucide-react";
import { useEffect, useRef, useState, type Dispatch, type SetStateAction } from "react";
import {
AdminSegmented,
@@ -11,8 +11,6 @@ import BrandCropModal from "@/components/BrandCropModal";
import BrandLockup from "@/components/BrandLockup";
import { apiBrandFromMedia, apiUpdateSiteSettings, apiUploadBrand, type PublicSettings } from "@/lib/api";
import {
BRAND_LOGO_FITS,
BRAND_LOGO_SIZES,
brandAccept,
brandAliasPath,
brandFileOk,
@@ -313,6 +311,17 @@ export default function BrandSeoPanel({
>
<Images size={14} /> 库
</button>
{preview && !/\.(svg|ico)(\?.*)?$/i.test(preview) ? (
<button
type="button"
className="btn btn-line btn-sm"
disabled={saving}
title="调整画幅、铺法或重新裁剪"
onClick={() => openCrop(slot, null, preview)}
>
<Crop size={14} /> 调整
</button>
) : null}
<button
type="button"
className="admin-icon-btn"
@@ -361,7 +370,7 @@ export default function BrandSeoPanel({
frame
name={initial.site_name}
wordmark={initial.site_wordmark}
slogan={initial.site_slogan}
slogan={initial.brand_slogan_visible ? initial.site_slogan : ""}
logoLight={previewOf(light)}
logoDark={previewOf(dark)}
/>
@@ -388,46 +397,9 @@ export default function BrandSeoPanel({
/>
</div>
{mark !== "text" ? (
<>
<div className="admin-brand-studio-field">
<span className="admin-brand-studio-kicker">比例</span>
<div className="admin-brand-size-row" role="group" aria-label="Logo 比例">
{BRAND_LOGO_SIZES.map((item) => {
const on = logoSize === item.id;
return (
<button
key={item.id}
type="button"
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
onClick={() => {
setLogoSize(item.id);
if (saveError) setSaveError("");
}}
>
<span className="admin-brand-size-chip-label">{item.label}</span>
<span className="admin-brand-size-chip-px">
{item.w}×{item.h}
</span>
</button>
);
})}
</div>
</div>
<div className="admin-brand-studio-field">
<span className="admin-brand-studio-kicker">铺法</span>
<AdminSegmented
role="group"
size="sm"
ariaLabel="适配方式"
value={logoFit}
onChange={(v) => {
setLogoFit(v);
if (saveError) setSaveError("");
}}
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
/>
</div>
</>
<p className="admin-brand-studio-field text-[11.5px] self-center mb-0.5" style={{ color: "var(--ink-3)" }}>
画幅与铺法在上传/调整图片的裁剪弹窗中设置
</p>
) : null}
</div>
@@ -471,6 +443,16 @@ export default function BrandSeoPanel({
slot={cropSlot}
seedFile={cropSeedFile}
seedUrl={cropSeedUrl}
logoSize={logoSize}
logoFit={logoFit}
onSizeChange={(s) => {
setLogoSize(s);
if (saveError) setSaveError("");
}}
onFitChange={(f) => {
setLogoFit(f);
if (saveError) setSaveError("");
}}
onClose={closeCrop}
onSeedConsumed={() => {
setCropSeedFile(null);

View File

@@ -5150,6 +5150,15 @@ a.j13-mention-token:hover {
flex-shrink: 0;
overflow: hidden;
}
.j13-brand-slot[data-size="auto"] {
width: auto;
max-width: 176px;
}
.j13-brand-slot[data-size="auto"] > .j13-brand-logo {
width: auto;
max-width: 176px;
object-fit: contain;
}
.j13-brand-slot[data-frame="1"] {
border-radius: 8px;
background: color-mix(in srgb, var(--panel) 70%, transparent);

View File

@@ -1,6 +1,6 @@
"use client";
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useRouter } from "next/navigation";
import Link from "next/link";
import { UserPlus } from "lucide-react";
@@ -44,8 +44,33 @@ export default function RegisterClient({
const [notice, setNotice] = useState(registerNotice);
const [sending, setSending] = useState(false);
const [sentMessage, setSentMessage] = useState("");
// 重发倒计时(秒);>0 期间按钮锁定不可点
const [countdown, setCountdown] = useState(0);
const countdownTimer = useRef<ReturnType<typeof setInterval> | null>(null);
const verifyEmail = initialVerifyEmail;
// 卸载时清理倒计时,避免泄漏
useEffect(() => {
return () => {
if (countdownTimer.current) clearInterval(countdownTimer.current);
};
}, []);
const startCountdown = () => {
if (countdownTimer.current) clearInterval(countdownTimer.current);
setCountdown(60);
countdownTimer.current = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
if (countdownTimer.current) clearInterval(countdownTimer.current);
countdownTimer.current = null;
return 0;
}
return c - 1;
});
}, 1000);
};
// 已登录用户不必再注册,直接回首页
useEffect(() => {
if (!hasAuthCookieHint()) return;
@@ -166,8 +191,9 @@ export default function RegisterClient({
<button
type="button"
className="btn btn-line shrink-0"
disabled={sending || !email}
disabled={sending || countdown > 0 || !email}
onClick={async () => {
// 点击即锁定,防止重复发送
setSending(true);
setError("");
try {
@@ -176,6 +202,7 @@ export default function RegisterClient({
purpose: "register",
});
setSentMessage(r.message);
startCountdown();
} catch (err) {
setError((err as Error).message);
} finally {
@@ -183,7 +210,11 @@ export default function RegisterClient({
}
}}
>
{sending ? "正在排队…" : "发送验证码"}
{sending
? "正在排队…"
: countdown > 0
? `${countdown}s 后可重发`
: "发送验证码"}
</button>
</div>
{sentMessage && (