外观设置改为半屏布局;背景图仅保存后生效,并支持媒体库选用。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-20 10:51:20 +08:00
parent e598782279
commit 68e90ff6ff
7 changed files with 953 additions and 481 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,121 @@
"use client";
import { Images, Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import Modal from "@/components/Modal";
import { apiMyMedia, type MediaAttachment } from "@/lib/api";
import { mediaURLFitsBackground, type BgSurface } from "@/lib/bg";
const SURFACE_LABEL: Record<BgSurface, string> = {
site: "前台背景",
admin: "后台背景",
};
const BG_MAX_BYTES = 8 * 1024 * 1024;
const BG_MAX_DIM = 8192;
function mediaFitsBackground(item: MediaAttachment): boolean {
if (!mediaURLFitsBackground(item.url)) return false;
if (item.size > 0 && item.size > BG_MAX_BYTES) return false;
if (item.width > 0 && item.width > BG_MAX_DIM) return false;
if (item.height > 0 && item.height > BG_MAX_DIM) return false;
return true;
}
export default function BgMediaPicker({
open,
surface,
onClose,
onPick,
}: {
open: boolean;
surface: BgSurface;
onClose: () => void;
onPick: (item: MediaAttachment) => void;
}) {
const [items, setItems] = useState<MediaAttachment[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
useEffect(() => {
if (!open) return;
let cancelled = false;
setLoading(true);
setError("");
void apiMyMedia()
.then((res) => {
if (cancelled) return;
if (res.error) {
setError(res.error);
setItems([]);
return;
}
const all = res.attachments || [];
setItems(all.filter(mediaFitsBackground));
})
.catch(() => {
if (!cancelled) setError("加载媒体库失败");
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [open, surface]);
return (
<Modal
open={open}
title={`从媒体库选择 · ${SURFACE_LABEL[surface]}`}
description="只列出 JPEG / PNG / WebP(≤8MB、边长≤8192)。保存后会复制到背景目录,之后删除原图不影响背景。"
onClose={onClose}
maxWidthClass="max-w-lg"
dismissOnOverlay
>
{loading ? (
<p className="meta text-[13px] flex items-center gap-2 py-6 justify-center">
<Loader2 size={14} className="animate-spin" /> 加载中
</p>
) : error ? (
<p className="text-[13px] py-4" style={{ color: "var(--danger, #b42318)" }}>
{error}
</p>
) : items.length === 0 ? (
<p className="meta text-[13px] py-6 text-center">
没有可用图片。请先在发帖或媒体库上传 JPEG / PNG / WebP。
</p>
) : (
<div className="grid grid-cols-4 sm:grid-cols-5 gap-2">
{items.map((item) => (
<button
key={item.id}
type="button"
onClick={() => {
onPick(item);
onClose();
}}
className="relative group aspect-square rounded-xl overflow-hidden"
style={{ boxShadow: "0 0 0 1px var(--line)", background: "var(--panel-2)" }}
data-tip="用作草稿预览,点保存后才生效"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={item.url}
alt={`${SURFACE_LABEL[surface]}候选图`}
className="w-full h-full object-cover"
loading="lazy"
/>
<span
className="pointer-events-none absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100"
style={{ background: "color-mix(in srgb, var(--ink) 40%, transparent)", color: "#fff" }}
>
<Images size={14} />
</span>
</button>
))}
</div>
)}
</Modal>
);
}

View File

@@ -2268,10 +2268,11 @@ export async function apiBrandFromMedia(slot: BrandSlot, attachmentId: number):
}
/** 上传站点背景并立即绑定前台或后台 */
/** 超管上传站点背景图:只落盘返回 URL,需再 PUT settings 才绑定并广播 */
export async function apiUploadSiteBackground(
surface: "site" | "admin",
file: File
): Promise<PublicSettings> {
): Promise<string> {
const form = new FormData();
form.append("surface", surface);
form.append("file", file, file.name || "background.jpg");
@@ -2282,7 +2283,26 @@ export async function apiUploadSiteBackground(
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "上传背景图失败");
return normalizePublicSettings(data as Partial<PublicSettings>);
const url = String(data.url || "");
if (!url) throw new Error("上传背景图失败");
return url;
}
/** 把本人媒体库图片复制进 backgrounds,不写入站点设置 */
export async function apiBackgroundFromMedia(
surface: "site" | "admin",
attachmentId: number
): Promise<string> {
const res = await fetchWithRefresh("/api/admin/upload/background/from-media", {
method: "POST",
headers: clientHeaders({ "Content-Type": "application/json" }),
body: JSON.stringify({ attachment_id: attachmentId, surface }),
});
const data = await res.json().catch(() => ({}));
if (!res.ok) throw new Error(data.error || "选用媒体库图片失败");
const url = String(data.url || "");
if (!url) throw new Error("选用媒体库图片失败");
return url;
}
// ===== 板块(客户端,供角色授权弹窗勾选) =====

View File

@@ -50,6 +50,11 @@ export function normalizeBgMode(raw: string | null | undefined): BgMode {
return MODE_SET.has(m) ? m : DEFAULT_BG_MODE;
}
/** 媒体库条目是否可作为背景图(JPEG / PNG / WebP) */
export function mediaURLFitsBackground(url: string): boolean {
return /\.(jpe?g|png|webp)(\?|$)/i.test(url);
}
export function bgConfigFromSettings(s: SiteBgInput | null | undefined): SiteBgConfig {
return {
bg_site_url: normalizeBgURL(s?.bg_site_url),