feat: 媒体库 blob 去重存储与品牌/注册等前端调整
新增: - blob_store 二进制去重存储 + blob_dedup 测试 其它: - 后端 settings/auth/media_library/upload/operations 服务与 handler 调整 - 前端品牌(BrandLockup/BrandCropModal/BrandSeoPanel)、注册、媒体库、分析详情页、Header/SiteChrome 等更新
This commit is contained in:
@@ -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="按爬虫名精确筛选"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, type CSSProperties } from "react";
|
||||
import Cropper, { type Area } from "react-easy-crop";
|
||||
import "react-easy-crop/react-easy-crop.css";
|
||||
import {
|
||||
@@ -10,29 +10,25 @@ import {
|
||||
apiMyMedia, apiUploadBrand,
|
||||
type MediaAttachment,
|
||||
} from "@/lib/api";
|
||||
import { getCroppedBrandWebp } from "@/lib/cropImage";
|
||||
import { getCroppedBrandWebp, getFullBrandWebp } from "@/lib/cropImage";
|
||||
import Modal from "@/components/Modal";
|
||||
import { brandFileOk, type BrandSlot, type BrandLogoSize } from "@/lib/brand";
|
||||
import BrandLockup from "@/components/BrandLockup";
|
||||
import { AdminSegmented } from "@/components/admin";
|
||||
import {
|
||||
BRAND_LOGO_FITS,
|
||||
BRAND_LOGO_SIZES,
|
||||
BRAND_LOGO_SIZE_ASPECT,
|
||||
brandFileOk,
|
||||
brandLogoBox,
|
||||
suggestLogoSize,
|
||||
type BrandLogoFit,
|
||||
type BrandLogoSize,
|
||||
type BrandSlot,
|
||||
} from "@/lib/brand";
|
||||
|
||||
// 源文件大小保护(防止手机相册原图撑爆浏览器内存;最终限制以裁剪后 2MB 为准)
|
||||
const SOURCE_MAX_BYTES = 15 * 1024 * 1024;
|
||||
|
||||
// 品牌图槽位到默认裁剪比例的映射
|
||||
const SLOT_ASPECT: Record<BrandSlot, number> = {
|
||||
logo_light: 1, // 方标 1:1
|
||||
logo_dark: 1, // 方标 1:1
|
||||
favicon: 1, // Favicon 1:1
|
||||
};
|
||||
|
||||
// 品牌图比例选项(与 BRAND_LOGO_SIZES 对应)
|
||||
const ASPECT_OPTIONS: { id: BrandLogoSize; label: string; aspect: number }[] = [
|
||||
{ id: "sq", label: "1:1", aspect: 1 },
|
||||
{ id: "2x1", label: "2:1", aspect: 2 },
|
||||
{ id: "3x1", label: "3:1", aspect: 3 },
|
||||
{ id: "4x1", label: "4:1", aspect: 4 },
|
||||
{ id: "16x9", label: "16:9", aspect: 16 / 9 },
|
||||
];
|
||||
|
||||
interface BrandCropModalProps {
|
||||
open: boolean;
|
||||
slot: BrandSlot;
|
||||
@@ -45,10 +41,19 @@ interface BrandCropModalProps {
|
||||
seedUrl?: string;
|
||||
/** 已消费 seedFile / seedUrl */
|
||||
onSeedConsumed?: () => void;
|
||||
/** 当前显示画幅(与工具栏同源,受控) */
|
||||
logoSize?: BrandLogoSize;
|
||||
/** 当前铺法(与工具栏同源,受控) */
|
||||
logoFit?: BrandLogoFit;
|
||||
/** 画幅变更回调(同步到父组件工具栏) */
|
||||
onSizeChange?: (size: BrandLogoSize) => void;
|
||||
/** 铺法变更回调(同步到父组件工具栏) */
|
||||
onFitChange?: (fit: BrandLogoFit) => void;
|
||||
}
|
||||
|
||||
export default function BrandCropModal({
|
||||
open, slot, onClose, onCropped, seedFile, seedUrl, onSeedConsumed,
|
||||
logoSize = "sq", logoFit = "contain", onSizeChange, onFitChange,
|
||||
}: BrandCropModalProps) {
|
||||
const [media, setMedia] = useState<MediaAttachment[]>([]);
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
@@ -56,11 +61,18 @@ export default function BrandCropModal({
|
||||
const [crop, setCrop] = useState({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const [pixelCrop, setPixelCrop] = useState<Area | null>(null);
|
||||
const [aspect, setAspect] = useState<number>(SLOT_ASPECT[slot] || 1);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [dragOver, setDragOver] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
// 已识别过比例的图片(每张图进入裁剪时只自动推荐一次画幅)
|
||||
const detectedSrcRef = useRef<string | null>(null);
|
||||
|
||||
// Favicon 固定方形,不参与画幅/铺法;auto=原图比例自由裁剪
|
||||
const isFavicon = slot === "favicon";
|
||||
const effectiveSize: BrandLogoSize = isFavicon ? "sq" : logoSize;
|
||||
const isAuto = effectiveSize === "auto";
|
||||
const cropAspect: number | undefined = isFavicon ? 1 : BRAND_LOGO_SIZE_ASPECT[effectiveSize];
|
||||
|
||||
// 打开时拉取媒体库
|
||||
useEffect(() => {
|
||||
@@ -71,6 +83,23 @@ export default function BrandCropModal({
|
||||
.catch(() => setMedia([]));
|
||||
}, [open]);
|
||||
|
||||
// 图片进入裁剪后,按原始比例自动推荐画幅(差距大→原图自适应),每张图仅一次
|
||||
useEffect(() => {
|
||||
if (!imageSrc || isFavicon || detectedSrcRef.current === imageSrc) return;
|
||||
detectedSrcRef.current = imageSrc;
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
onSizeChange?.(suggestLogoSize(img.naturalWidth, img.naturalHeight));
|
||||
};
|
||||
img.src = imageSrc;
|
||||
}, [imageSrc, isFavicon, onSizeChange]);
|
||||
|
||||
// 画幅变化时重置构图位置(换画幅=重新构图;缩放保留)
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
setCrop({ x: 0, y: 0 });
|
||||
}, [effectiveSize, open]);
|
||||
|
||||
// 关闭时复位并释放本地 blob URL
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
@@ -82,10 +111,10 @@ export default function BrandCropModal({
|
||||
setCrop({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
setPixelCrop(null);
|
||||
setAspect(SLOT_ASPECT[slot] || 1);
|
||||
setError("");
|
||||
setBusy(false);
|
||||
setDragOver(false);
|
||||
detectedSrcRef.current = null;
|
||||
}
|
||||
}, [open, slot]);
|
||||
|
||||
@@ -178,12 +207,15 @@ export default function BrandCropModal({
|
||||
};
|
||||
|
||||
const confirmUpload = async () => {
|
||||
if (!imageSrc || !pixelCrop || busy) return;
|
||||
if (!imageSrc || busy) return;
|
||||
if (!isAuto && !pixelCrop) return;
|
||||
setBusy(true);
|
||||
setError("");
|
||||
try {
|
||||
// 浏览器端裁剪并以 WebP 导出(质量自适应,直到 ≤ 2MB)
|
||||
const blob = await getCroppedBrandWebp(imageSrc, pixelCrop, aspect);
|
||||
// 原图模式:整图直传不裁剪;固定画幅:按裁剪区域导出
|
||||
const blob = isAuto
|
||||
? await getFullBrandWebp(imageSrc)
|
||||
: await getCroppedBrandWebp(imageSrc, pixelCrop!, cropAspect!);
|
||||
const file = new File([blob], "brand.webp", { type: "image/webp" });
|
||||
const url = await apiUploadBrand(slot, file);
|
||||
onCropped(url);
|
||||
@@ -292,64 +324,166 @@ export default function BrandCropModal({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* 比例选择 */}
|
||||
<div className="flex items-center gap-2 mb-3 flex-wrap">
|
||||
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>比例</span>
|
||||
{ASPECT_OPTIONS.map((opt) => (
|
||||
<button
|
||||
key={opt.id}
|
||||
type="button"
|
||||
onClick={() => setAspect(opt.aspect)}
|
||||
className={`px-2.5 py-1 rounded-md text-[12px] font-medium transition-colors ${
|
||||
aspect === opt.aspect
|
||||
? "bg-[var(--accent)] text-white"
|
||||
: "bg-[var(--panel-2)] text-[var(--ink-2)] hover:bg-[var(--panel-3)]"
|
||||
}`}
|
||||
{/* 画幅:唯一的比例控件(同时决定裁剪比例与页眉图框);favicon 固定方形不展示 */}
|
||||
{!isFavicon && (
|
||||
<div className="mb-3">
|
||||
<div className="flex items-center gap-1.5 mb-1.5">
|
||||
<span className="text-[12px] font-semibold" style={{ color: "var(--ink-3)" }}>画幅</span>
|
||||
<span className="text-[11px]" style={{ color: "var(--ink-3)" }}>已按图片比例推荐,可手动调整</span>
|
||||
</div>
|
||||
<div className="admin-brand-size-row" role="group" aria-label="显示画幅">
|
||||
{BRAND_LOGO_SIZES.map((item) => {
|
||||
const on = effectiveSize === item.id;
|
||||
return (
|
||||
<button
|
||||
key={item.id}
|
||||
type="button"
|
||||
className={`admin-brand-size-chip${on ? " is-on" : ""}`}
|
||||
onClick={() => onSizeChange?.(item.id)}
|
||||
>
|
||||
<span className="admin-brand-size-chip-label">{item.label}</span>
|
||||
<span className="admin-brand-size-chip-px">
|
||||
{item.w ? `${item.w}×${item.h}` : "自适应"}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 原图模式:整图直传,不裁剪;固定画幅:裁剪 */}
|
||||
{isAuto ? (
|
||||
<div
|
||||
className="relative w-full h-72 rounded-xl overflow-hidden flex items-center justify-center p-4"
|
||||
style={{ background: cropBg }}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
src={imageSrc || ""}
|
||||
alt="整图预览"
|
||||
className="max-w-full max-h-full object-contain"
|
||||
style={{ borderRadius: 6 }}
|
||||
/>
|
||||
<span
|
||||
className="absolute left-2.5 top-2.5 text-[11px] px-1.5 py-0.5 rounded"
|
||||
style={{
|
||||
background: "rgba(0,0,0,.45)",
|
||||
color: "#fff",
|
||||
}}
|
||||
>
|
||||
{opt.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
整图使用,不裁剪
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
className="relative w-full h-72 rounded-xl overflow-hidden"
|
||||
style={{ background: cropBg }}
|
||||
>
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={cropAspect}
|
||||
cropShape="rect"
|
||||
showGrid={true}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 裁剪区 */}
|
||||
{/* 缩放 + 重置 */}
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
|
||||
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
|
||||
aria-label="缩放"
|
||||
/>
|
||||
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setZoom(1);
|
||||
setCrop({ x: 0, y: 0 });
|
||||
}}
|
||||
className="btn btn-line btn-sm shrink-0"
|
||||
title="重置缩放与位置"
|
||||
>
|
||||
<RotateCcw size={13} /> 重置
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 铺法:仅固定画幅需要;原图模式图框随图,无需铺法 */}
|
||||
{!isFavicon && (
|
||||
isAuto ? (
|
||||
<p className="text-[11.5px] mt-3" style={{ color: "var(--ink-3)" }}>
|
||||
原图模式:整图使用不裁剪,页眉图框按图片原始比例自动给宽,图片完整不变形。
|
||||
</p>
|
||||
) : (
|
||||
<div className="mt-3">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<span className="text-[12px] font-semibold shrink-0" style={{ color: "var(--ink-3)" }}>铺法</span>
|
||||
<AdminSegmented
|
||||
role="group"
|
||||
size="sm"
|
||||
ariaLabel="适配方式"
|
||||
value={logoFit}
|
||||
onChange={(v) => onFitChange?.(v)}
|
||||
options={BRAND_LOGO_FITS.map((item) => ({ key: item.id, label: item.label }))}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-[11.5px] mt-1.5" style={{ color: "var(--ink-3)" }}>
|
||||
{logoFit === "contain"
|
||||
? "适应:完整显示图片,比例不匹配时留空(推荐)"
|
||||
: logoFit === "cover"
|
||||
? "填充:裁切填满画幅"
|
||||
: logoFit === "repeat"
|
||||
? "平铺:重复铺满画幅"
|
||||
: "拉伸:变形填满画幅"}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{/* 页眉实时预览:深色 Logo 用深色衬底并局部反转文字变量 */}
|
||||
<div
|
||||
className="relative w-full h-72 rounded-xl overflow-hidden"
|
||||
style={{ background: cropBg }}
|
||||
className="mt-3 rounded-xl px-3.5 py-2.5 flex items-center min-h-[56px]"
|
||||
style={{
|
||||
background: cropBg,
|
||||
...(slot === "logo_dark"
|
||||
? ({ "--ink": "#f5f6f8", "--ink-3": "rgba(255,255,255,.55)" } as CSSProperties)
|
||||
: null),
|
||||
}}
|
||||
aria-label="页眉预览"
|
||||
>
|
||||
<Cropper
|
||||
image={imageSrc}
|
||||
crop={crop}
|
||||
zoom={zoom}
|
||||
aspect={aspect}
|
||||
cropShape="rect"
|
||||
showGrid={true}
|
||||
onCropChange={setCrop}
|
||||
onZoomChange={setZoom}
|
||||
onCropComplete={onCropComplete}
|
||||
<BrandLockup
|
||||
mode="image_text"
|
||||
size={effectiveSize}
|
||||
fit={logoFit}
|
||||
name="站点名称"
|
||||
wordmark=""
|
||||
logoLight={imageSrc || ""}
|
||||
logoDark=""
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 缩放 */}
|
||||
<div className="flex items-center gap-3 mt-4">
|
||||
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={15} /></span>
|
||||
<input
|
||||
type="range"
|
||||
min={1}
|
||||
max={3}
|
||||
step={0.01}
|
||||
value={zoom}
|
||||
onChange={(e) => setZoom(Number(e.target.value))}
|
||||
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer"
|
||||
style={{ accentColor: "var(--accent)", background: "var(--panel-3)" }}
|
||||
aria-label="缩放"
|
||||
/>
|
||||
<span style={{ color: "var(--ink-3)" }}><ZoomIn size={19} /></span>
|
||||
</div>
|
||||
|
||||
{/* 输出说明 + 操作 */}
|
||||
<div className="flex items-center justify-between mt-5">
|
||||
<span className="text-[12px]" style={{ color: "var(--ink-3)" }}>
|
||||
输出 WebP,高度 ≤ 2048px
|
||||
{isAuto
|
||||
? "输出 WebP · 高度 40px,宽度随原图比例"
|
||||
: `输出 WebP · 图框 ${brandLogoBox(effectiveSize).w}×${brandLogoBox(effectiveSize).h}px`}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button type="button" onClick={backToLibrary} disabled={busy} className="btn btn-line btn-sm">
|
||||
|
||||
@@ -29,6 +29,8 @@ export default function BrandLockup({
|
||||
const showImage = mode !== "text";
|
||||
const showText = mode !== "image";
|
||||
const box = brandLogoBox(size);
|
||||
// auto 图槽宽度随图(可达最宽画幅量级),按 176 取密度变体保证清晰
|
||||
const cssPx = size === "auto" ? 176 : Math.max(box.w, box.h);
|
||||
const src = logoLight || logoDark;
|
||||
const both = !!(logoLight && logoDark && logoLight !== logoDark);
|
||||
const useBox = showImage && (!!src || frame);
|
||||
@@ -41,16 +43,17 @@ export default function BrandLockup({
|
||||
className="j13-brand-slot"
|
||||
data-frame={frame ? "1" : undefined}
|
||||
data-fit={src ? fit : undefined}
|
||||
style={{ width: box.w, height: box.h }}
|
||||
data-size={size}
|
||||
style={{ width: box.w || undefined, height: box.h }}
|
||||
>
|
||||
{src ? (
|
||||
both ? (
|
||||
<>
|
||||
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={Math.max(box.w, box.h)} />
|
||||
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={Math.max(box.w, box.h)} />
|
||||
<Logo src={logoLight} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-light" cssPx={cssPx} />
|
||||
<Logo src={logoDark} fit={fit} alt={label} decorative={showText} extra="j13-brand-logo-dark" cssPx={cssPx} />
|
||||
</>
|
||||
) : (
|
||||
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={Math.max(box.w, box.h)} />
|
||||
<Logo src={src} fit={fit} alt={label} decorative={showText} cssPx={cssPx} />
|
||||
)
|
||||
) : (
|
||||
<Bubble h={box.h} label={showText ? undefined : label} />
|
||||
|
||||
@@ -62,6 +62,8 @@ export default function Header({
|
||||
brandMark = "image_text",
|
||||
brandLogoSize = "sq",
|
||||
brandLogoFit = "contain",
|
||||
slogan = "",
|
||||
sloganVisible = true,
|
||||
}: {
|
||||
initialUser: User | null;
|
||||
initialUnread?: number;
|
||||
@@ -74,6 +76,8 @@ export default function Header({
|
||||
brandMark?: BrandMark;
|
||||
brandLogoSize?: BrandLogoSize;
|
||||
brandLogoFit?: BrandLogoFit;
|
||||
slogan?: string;
|
||||
sloganVisible?: boolean;
|
||||
}) {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
@@ -274,6 +278,7 @@ export default function Header({
|
||||
fit={brandLogoFit}
|
||||
name={siteName}
|
||||
wordmark={wordmark}
|
||||
slogan={sloganVisible ? slogan : ""}
|
||||
logoLight={logoLight}
|
||||
logoDark={logoDark}
|
||||
/>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from "react";
|
||||
import { apiGetSettings, type PublicSettings } from "@/lib/api";
|
||||
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeFooterLinks } from "@/lib/brand";
|
||||
import { siteDocumentTitle, normalizeBrandLogoFit, normalizeBrandLogoSize, normalizeBrandMark, normalizeBrandSloganVisible, normalizeFooterLinks } from "@/lib/brand";
|
||||
import { normalizeUrlStyle, type UrlStyle } from "@/lib/urlStyle";
|
||||
import { DEFAULT_MOBILE_TABS, normalizeMobileTabs, type MobileTabKey } from "@/lib/mobileTabs";
|
||||
import { DEFAULT_HEADER_NAV, normalizeHeaderNav, type HeaderNavItem } from "@/lib/headerNav";
|
||||
@@ -19,6 +19,7 @@ export type SiteBrand = {
|
||||
brand_mark: PublicSettings["brand_mark"];
|
||||
brand_logo_size: PublicSettings["brand_logo_size"];
|
||||
brand_logo_fit: PublicSettings["brand_logo_fit"];
|
||||
brand_slogan_visible: boolean;
|
||||
footer_links: PublicSettings["footer_links"];
|
||||
url_style: UrlStyle;
|
||||
mobile_tabs: MobileTabKey[];
|
||||
@@ -38,6 +39,7 @@ function fromSettings(s: PublicSettings): SiteBrand {
|
||||
brand_mark: s.brand_mark,
|
||||
brand_logo_size: s.brand_logo_size,
|
||||
brand_logo_fit: s.brand_logo_fit,
|
||||
brand_slogan_visible: s.brand_slogan_visible,
|
||||
footer_links: s.footer_links,
|
||||
url_style: s.url_style,
|
||||
mobile_tabs: s.mobile_tabs,
|
||||
@@ -56,6 +58,7 @@ function sameBrand(a: SiteBrand, b: SiteBrand): boolean {
|
||||
a.brand_mark === b.brand_mark &&
|
||||
a.brand_logo_size === b.brand_logo_size &&
|
||||
a.brand_logo_fit === b.brand_logo_fit &&
|
||||
a.brand_slogan_visible === b.brand_slogan_visible &&
|
||||
a.url_style === b.url_style &&
|
||||
JSON.stringify(a.footer_links) === JSON.stringify(b.footer_links) &&
|
||||
JSON.stringify(a.mobile_tabs) === JSON.stringify(b.mobile_tabs) &&
|
||||
@@ -108,6 +111,7 @@ export function useSiteBrand(): SiteBrand {
|
||||
brand_mark: "image_text",
|
||||
brand_logo_size: "sq",
|
||||
brand_logo_fit: "contain",
|
||||
brand_slogan_visible: true,
|
||||
footer_links: [],
|
||||
url_style: "default",
|
||||
mobile_tabs: [...DEFAULT_MOBILE_TABS],
|
||||
@@ -159,6 +163,10 @@ export default function SiteBrandProvider({
|
||||
brand_mark: normalizeBrandMark(raw.brand_mark ?? prev.brand_mark),
|
||||
brand_logo_size: normalizeBrandLogoSize(raw.brand_logo_size ?? prev.brand_logo_size),
|
||||
brand_logo_fit: normalizeBrandLogoFit(raw.brand_logo_fit ?? prev.brand_logo_fit),
|
||||
brand_slogan_visible:
|
||||
raw.brand_slogan_visible !== undefined
|
||||
? normalizeBrandSloganVisible(raw.brand_slogan_visible)
|
||||
: prev.brand_slogan_visible,
|
||||
footer_links: Array.isArray(raw.footer_links)
|
||||
? normalizeFooterLinks(raw.footer_links)
|
||||
: prev.footer_links,
|
||||
|
||||
@@ -106,6 +106,8 @@ export default function SiteChrome({
|
||||
brandMark={brand.brand_mark}
|
||||
brandLogoSize={brand.brand_logo_size}
|
||||
brandLogoFit={brand.brand_logo_fit}
|
||||
slogan={brand.site_slogan}
|
||||
sloganVisible={brand.brand_slogan_visible}
|
||||
/>
|
||||
<main
|
||||
id="main"
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
normalizeBrandLogoFit,
|
||||
normalizeBrandLogoSize,
|
||||
normalizeBrandMark,
|
||||
normalizeBrandSloganVisible,
|
||||
normalizeBrandURL,
|
||||
normalizeFooterLinks,
|
||||
normalizeKeywords,
|
||||
@@ -1041,6 +1042,8 @@ export interface PublicSettings {
|
||||
brand_logo_size: BrandLogoSize;
|
||||
/** 图槽铺法:cover 填充 / contain 适应 / repeat 平铺 / stretch 拉伸 */
|
||||
brand_logo_fit: BrandLogoFit;
|
||||
/** 标语是否在页眉显示;缺省 true */
|
||||
brand_slogan_visible: boolean;
|
||||
footer_links: FooterLink[];
|
||||
allow_register: boolean;
|
||||
/** 全站是否开放评论;关闭后前台不展示评论能力 */
|
||||
@@ -1144,6 +1147,7 @@ const DEFAULT_PUBLIC_SETTINGS: PublicSettings = {
|
||||
brand_mark: "image_text",
|
||||
brand_logo_size: "sq",
|
||||
brand_logo_fit: "contain",
|
||||
brand_slogan_visible: true,
|
||||
footer_links: [],
|
||||
allow_register: true,
|
||||
allow_comments: true,
|
||||
@@ -1235,6 +1239,7 @@ function normalizePublicSettings(data: Partial<PublicSettings> | null | undefine
|
||||
brand_mark: normalizeBrandMark(data?.brand_mark),
|
||||
brand_logo_size: normalizeBrandLogoSize(data?.brand_logo_size),
|
||||
brand_logo_fit: normalizeBrandLogoFit(data?.brand_logo_fit),
|
||||
brand_slogan_visible: normalizeBrandSloganVisible(data?.brand_slogan_visible),
|
||||
footer_links: normalizeFooterLinks(data?.footer_links),
|
||||
allow_register: data?.allow_register !== false,
|
||||
allow_comments: data?.allow_comments !== false,
|
||||
@@ -3760,6 +3765,7 @@ export type UpdateSiteSettingsBody = {
|
||||
brand_mark?: BrandMark;
|
||||
brand_logo_size?: BrandLogoSize;
|
||||
brand_logo_fit?: BrandLogoFit;
|
||||
brand_slogan_visible?: boolean;
|
||||
footer_links?: FooterLink[];
|
||||
allow_register?: boolean;
|
||||
allow_comments?: boolean;
|
||||
@@ -4825,6 +4831,7 @@ export interface MediaLibraryItem {
|
||||
height: number;
|
||||
uploader?: string;
|
||||
uploaded_at?: string;
|
||||
ref_count?: number; // 引用计数(>1 表示内容相同的重复上传共享此文件)
|
||||
source_url?: string; // 来源回链(帖子/评论锚点/用户主页)
|
||||
source_label?: string; // 来源描述(如:帖子《…》下的评论)
|
||||
}
|
||||
|
||||
@@ -28,8 +28,8 @@ export type BrandSlot = "logo_light" | "logo_dark" | "favicon";
|
||||
/** 页眉品牌形式 */
|
||||
export type BrandMark = "image_text" | "image" | "text";
|
||||
|
||||
/** 图槽画幅。方标是默认;其余是页眉里常见的横版比例 */
|
||||
export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9";
|
||||
/** 图槽画幅。方标是默认;其余是页眉里常见的横版比例;auto=原图比例自适应宽度 */
|
||||
export type BrandLogoSize = "sq" | "2x1" | "3x1" | "4x1" | "16x9" | "auto";
|
||||
|
||||
export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h: number }[] = [
|
||||
{ id: "sq", label: "方标", w: 36, h: 36 },
|
||||
@@ -37,6 +37,7 @@ export const BRAND_LOGO_SIZES: { id: BrandLogoSize; label: string; w: number; h:
|
||||
{ id: "3x1", label: "3:1", w: 120, h: 40 },
|
||||
{ id: "4x1", label: "4:1", w: 176, h: 44 },
|
||||
{ id: "16x9", label: "16:9", w: 96, h: 54 },
|
||||
{ id: "auto", label: "原图", w: 0, h: 40 },
|
||||
];
|
||||
|
||||
export function normalizeBrandMark(raw: unknown): BrandMark {
|
||||
@@ -47,10 +48,38 @@ export function normalizeBrandMark(raw: unknown): BrandMark {
|
||||
|
||||
export function normalizeBrandLogoSize(raw: unknown): BrandLogoSize {
|
||||
const s = String(raw ?? "").trim();
|
||||
if (s === "sq" || s === "2x1" || s === "3x1" || s === "4x1" || s === "16x9") return s;
|
||||
if (s === "sq" || s === "2x1" || s === "3x1" || s === "4x1" || s === "16x9" || s === "auto") return s;
|
||||
return "sq";
|
||||
}
|
||||
|
||||
/** 各固定画幅的宽高比(auto 不在此表,代表自由比例) */
|
||||
export const BRAND_LOGO_SIZE_ASPECT: Partial<Record<BrandLogoSize, number>> = {
|
||||
sq: 1,
|
||||
"2x1": 2,
|
||||
"3x1": 3,
|
||||
"4x1": 4,
|
||||
"16x9": 16 / 9,
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据图片原始比例推荐画幅:
|
||||
* 与某个固定画幅差距 ≤15% 时推荐该画幅,否则推荐 auto(原图自适应)。
|
||||
*/
|
||||
export function suggestLogoSize(naturalW: number, naturalH: number): BrandLogoSize {
|
||||
if (!naturalW || !naturalH) return "auto";
|
||||
const ratio = naturalW / naturalH;
|
||||
let best: BrandLogoSize = "sq";
|
||||
let bestDelta = Infinity;
|
||||
for (const [id, a] of Object.entries(BRAND_LOGO_SIZE_ASPECT) as [BrandLogoSize, number][]) {
|
||||
const delta = Math.abs(ratio - a) / a;
|
||||
if (delta < bestDelta) {
|
||||
bestDelta = delta;
|
||||
best = id;
|
||||
}
|
||||
}
|
||||
return bestDelta <= 0.15 ? best : "auto";
|
||||
}
|
||||
|
||||
export function brandLogoBox(size: BrandLogoSize): { w: number; h: number } {
|
||||
const found = BRAND_LOGO_SIZES.find((item) => item.id === size);
|
||||
return found ? { w: found.w, h: found.h } : { w: 36, h: 36 };
|
||||
@@ -72,6 +101,13 @@ export function normalizeBrandLogoFit(raw: unknown): BrandLogoFit {
|
||||
return "contain";
|
||||
}
|
||||
|
||||
/** 标语是否在页眉显示;缺省 true(与后端缺省一致) */
|
||||
export function normalizeBrandSloganVisible(raw: unknown): boolean {
|
||||
if (typeof raw === "boolean") return raw;
|
||||
if (typeof raw === "string") return raw.trim().toLowerCase() !== "false";
|
||||
return true;
|
||||
}
|
||||
|
||||
export const BRAND_LIMITS = {
|
||||
wordmark: 16,
|
||||
slogan: 32,
|
||||
|
||||
@@ -119,3 +119,27 @@ export async function getCroppedBrandWebp(
|
||||
|
||||
return exportWebp(canvas, BRAND_MAX_BYTES);
|
||||
}
|
||||
|
||||
/**
|
||||
* 整图直传(「原图自适应」用):不做任何裁剪,仅按比例缩放到 BRAND_MAX_DIM 内并导出 WebP。
|
||||
* 页眉图框高度固定、宽度按图片原始比例自适应。
|
||||
*/
|
||||
export async function getFullBrandWebp(imageSrc: string): Promise<Blob> {
|
||||
const image = await loadImage(imageSrc);
|
||||
const canvas = document.createElement("canvas");
|
||||
|
||||
const scale = Math.min(1, BRAND_MAX_DIM / Math.max(image.naturalWidth, image.naturalHeight));
|
||||
const outW = Math.max(1, Math.round(image.naturalWidth * scale));
|
||||
const outH = Math.max(1, Math.round(image.naturalHeight * scale));
|
||||
|
||||
canvas.width = outW;
|
||||
canvas.height = outH;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) throw new Error("当前浏览器不支持图片处理");
|
||||
|
||||
ctx.imageSmoothingEnabled = true;
|
||||
ctx.imageSmoothingQuality = "high";
|
||||
ctx.drawImage(image, 0, 0, outW, outH);
|
||||
|
||||
return exportWebp(canvas, BRAND_MAX_BYTES);
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export interface RtSettingsData {
|
||||
brand_mark?: string;
|
||||
brand_logo_size?: string;
|
||||
brand_logo_fit?: string;
|
||||
brand_slogan_visible?: boolean;
|
||||
footer_links?: { label: string; url: string; new_tab: boolean; align?: string }[];
|
||||
allow_register?: boolean;
|
||||
allow_comments?: boolean;
|
||||
|
||||
Reference in New Issue
Block a user