外观设置改为半屏布局;背景图仅保存后生效,并支持媒体库选用。
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
121
frontend/app/admin/appearance/BgMediaPicker.tsx
Normal file
121
frontend/app/admin/appearance/BgMediaPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
// ===== 板块(客户端,供角色授权弹窗勾选) =====
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in New Issue
Block a user