diff --git a/backend/handler/upload.go b/backend/handler/upload.go index 613f642..f028ffc 100644 --- a/backend/handler/upload.go +++ b/backend/handler/upload.go @@ -7,7 +7,6 @@ import ( "strconv" "github.com/freefire/jiang13-bbs/middleware" - "github.com/freefire/jiang13-bbs/realtime" "github.com/freefire/jiang13-bbs/service" "github.com/gin-gonic/gin" ) @@ -167,10 +166,9 @@ func (h *Handlers) DeleteAttachment(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"ok": true}) } -// UploadBackground 超管上传站点背景图(JPEG/PNG/WebP),并立即绑定前台或后台 +// UploadBackground 超管上传站点背景图(JPEG/PNG/WebP),只落盘返回 URL,不写站点设置 func (h *Handlers) UploadBackground(c *gin.Context) { - surface, ok := service.NormalizeBgSurface(c.PostForm("surface")) - if !ok { + if _, ok := service.NormalizeBgSurface(c.PostForm("surface")); !ok { c.JSON(http.StatusBadRequest, gin.H{"error": "请指定前台或后台(surface=site|admin)"}) return } @@ -209,26 +207,36 @@ func (h *Handlers) UploadBackground(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } + c.JSON(http.StatusOK, gin.H{"url": url}) +} - if err := h.applyBgURL(surface, url); err != nil { - writeBgSettingError(c, err) +type backgroundFromMediaRequest struct { + AttachmentID uint `json:"attachment_id"` + Surface string `json:"surface"` +} + +// UploadBackgroundFromMedia 把本人媒体库图片复制进 backgrounds,不写站点设置 +func (h *Handlers) UploadBackgroundFromMedia(c *gin.Context) { + var req backgroundFromMediaRequest + if err := c.ShouldBindJSON(&req); err != nil || req.AttachmentID == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "请选择媒体库中的图片"}) return } - - saved, err := h.Setting.Public() + if _, ok := service.NormalizeBgSurface(req.Surface); !ok { + c.JSON(http.StatusBadRequest, gin.H{"error": "请指定前台或后台(surface=site|admin)"}) + return + } + claims := middleware.CurrentUser(c) + if claims == nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "请先登录"}) + return + } + url, err := h.Upload.CopyBackgroundFromMedia(claims.ID, req.AttachmentID) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": "获取站点设置失败"}) + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) return } - payload := settingsPayload(saved) - h.Hub.BroadcastAll(realtime.Envelope{ - Type: realtime.EventSettingsChanged, - Data: payload, - }) - out := settingsPayload(saved) - out["ok"] = true - out["url"] = url - c.JSON(http.StatusOK, out) + c.JSON(http.StatusOK, gin.H{"url": url}) } // UploadBrand 超管上传 Logo / Favicon,只落盘返回 URL,不写站点设置 diff --git a/backend/router/router.go b/backend/router/router.go index 099647c..cbfdc60 100644 --- a/backend/router/router.go +++ b/backend/router/router.go @@ -267,6 +267,7 @@ func Setup(cfg *config.Config) (*gin.Engine, error) { staffAPI.GET("/settings", authMW.RequirePerm(service.PermSettings), h.AdminGetSettings) staffAPI.PUT("/settings", authMW.RequirePerm(service.PermSettings), h.UpdateSettings) staffAPI.POST("/upload/background", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackground) + staffAPI.POST("/upload/background/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBackgroundFromMedia) staffAPI.POST("/upload/brand", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrand) staffAPI.POST("/upload/brand/from-media", authMW.RequirePerm(service.PermSettings), middleware.RateLimitMiddleware(limiter, service.RateUpload), h.UploadBrandFromMedia) diff --git a/backend/service/upload.go b/backend/service/upload.go index 84ec79a..0873ab2 100644 --- a/backend/service/upload.go +++ b/backend/service/upload.go @@ -364,6 +364,24 @@ func (s *UploadService) SaveBackground(src io.Reader) (string, error) { return "/uploads/backgrounds/" + filename, nil } +// CopyBackgroundFromMedia 把当前用户媒体库里的一张图复制进 backgrounds,不写站点设置 +func (s *UploadService) CopyBackgroundFromMedia(userID, attachmentID uint) (string, error) { + var att model.Attachment + if err := s.db.Where("id = ? AND user_id = ?", attachmentID, userID).First(&att).Error; err != nil { + return "", errors.New("图片不存在或不属于你") + } + abs, ok := s.safeUploadPath(att.URL) + if !ok { + return "", errors.New("无效的图片地址") + } + f, err := os.Open(abs) + if err != nil { + return "", errors.New("读取图片失败") + } + defer f.Close() + return s.SaveBackground(f) +} + // BackgroundFileExists 确认 URL 对应文件在 backgrounds 目录内 func (s *UploadService) BackgroundFileExists(url string) bool { rel := BgRelPath(url) diff --git a/frontend/app/admin/appearance/AppearanceAdmin.tsx b/frontend/app/admin/appearance/AppearanceAdmin.tsx index b2f0a01..8c39c01 100644 --- a/frontend/app/admin/appearance/AppearanceAdmin.tsx +++ b/frontend/app/admin/appearance/AppearanceAdmin.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from "react"; import { useRouter } from "next/navigation"; import { Palette, @@ -8,7 +8,6 @@ import { Code2, Loader2, Save, - Undo2, RotateCcw, Pipette, Sparkles, @@ -17,21 +16,29 @@ import { LayoutDashboard, Trash2, Upload, + Images, } from "lucide-react"; import { AdminPageHeader, - AdminSection, - AdminSettingRow, + AdminSettingsActions, + AdminSettingsGroup, + AdminSettingsPage, + AdminSettingsRow, + AdminSettingsWork, AdminStepper, AdminSwitch, } from "@/components/admin"; import BgModePicker from "./BgModePicker"; +import BgMediaPicker from "./BgMediaPicker"; +import ConfirmDialog from "@/components/ConfirmDialog"; import { + apiBackgroundFromMedia, apiUpdateSiteSettings, apiUploadSiteBackground, type PublicSettings, } from "@/lib/api"; import { toast } from "@/lib/toast"; +import useUnsavedGuard from "@/hooks/useUnsavedGuard"; import { ACCENT_PRESETS, DEFAULT_ACCENT, @@ -50,8 +57,35 @@ import { type SiteBgConfig, } from "@/lib/bg"; -// 后台「外观设置」:主题色、代码块折叠与界面动画。 -// 主题色修改时立即对整页实时预览(仅本地),保存后才写库并对所有用户生效。 +type SectionId = "theme" | "background" | "code" | "motion"; + +const NAV: { id: SectionId; label: string }[] = [ + { id: "theme", label: "主题色" }, + { id: "background", label: "背景图" }, + { id: "code", label: "代码块" }, + { id: "motion", label: "界面动画" }, +]; + +function parseSection(hash: string): SectionId { + const id = hash.replace(/^#/, ""); + if (id === "background" || id === "code" || id === "motion" || id === "theme") return id; + return "theme"; +} + +function InstantNote({ saving }: { saving?: boolean }) { + return {saving ? "正在保存…" : "修改后立即生效"}; +} + +function bgEqual(a: SiteBgConfig, b: SiteBgConfig): boolean { + return ( + a.bg_site_url === b.bg_site_url && + a.bg_site_mode === b.bg_site_mode && + a.bg_admin_url === b.bg_admin_url && + a.bg_admin_mode === b.bg_admin_mode + ); +} + +// 后台「外观设置」:半屏导航;主题色/背景图草稿本地预览,保存后才写库并广播。 export default function AppearanceAdmin({ initial }: { initial: PublicSettings }) { const router = useRouter(); const initialAccent = initial.accent || ""; @@ -70,13 +104,33 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } ); const [animChrome, setAnimChrome] = useState(initial.anim_chrome !== false); const [togglingMotion, setTogglingMotion] = useState(false); - const [saving, setSaving] = useState(false); - const [bg, setBg] = useState(() => bgConfigFromSettings(initial)); - const [bgBusy, setBgBusy] = useState(null); + const [savingTheme, setSavingTheme] = useState(false); + const [themeError, setThemeError] = useState(""); + + const [savedBg, setSavedBg] = useState(() => bgConfigFromSettings(initial)); + const [bgDraft, setBgDraft] = useState(() => bgConfigFromSettings(initial)); + const [siteFile, setSiteFile] = useState(null); + const [adminFile, setAdminFile] = useState(null); + const [siteBlob, setSiteBlob] = useState(""); + const [adminBlob, setAdminBlob] = useState(""); + const [siteMediaId, setSiteMediaId] = useState(null); + const [adminMediaId, setAdminMediaId] = useState(null); + const [siteMediaUrl, setSiteMediaUrl] = useState(""); + const [adminMediaUrl, setAdminMediaUrl] = useState(""); + const [bgPicker, setBgPicker] = useState(null); + const [savingBg, setSavingBg] = useState(false); + const [bgError, setBgError] = useState(""); const siteFileRef = useRef(null); const adminFileRef = useRef(null); + const blobsRef = useRef([]); const savedRef = useRef(savedHex); savedRef.current = savedHex; + const savedBgRef = useRef(savedBg); + savedBgRef.current = savedBg; + + const [active, setActive] = useState("theme"); + const [pendingSection, setPendingSection] = useState(null); + const navRefs = useRef<(HTMLAnchorElement | null)[]>([]); const normalized = useMemo(() => normalizeHex(draft), [draft]); const pair = useMemo( @@ -85,8 +139,24 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } ); const preset = useMemo(() => (normalized ? findPreset(normalized) : null), [normalized]); const submitHex = normalized && normalized !== DEFAULT_ACCENT ? normalized : ""; - const dirty = (savedHex || DEFAULT_ACCENT) !== (normalized || ""); - const invalid = draft.trim() !== "" && !normalized; + const themeDirty = (savedHex || DEFAULT_ACCENT) !== (normalized || ""); + const themeInvalid = draft.trim() !== "" && !normalized; + const bgDirty = + !bgEqual(bgDraft, savedBg) || + siteFile != null || + adminFile != null || + siteMediaId != null || + adminMediaId != null; + const tabDirty = + (active === "theme" && themeDirty) || (active === "background" && bgDirty); + const leave = useUnsavedGuard(themeDirty || bgDirty); + + useEffect(() => { + const held = blobsRef; + return () => { + held.current.forEach((u) => URL.revokeObjectURL(u)); + }; + }, []); useEffect(() => { if (normalized) applyAccentTheme(normalized); @@ -95,9 +165,64 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } }; }, [normalized]); - const handleSave = async () => { - if (!normalized || saving) return; - setSaving(true); + // 离开页时恢复已保存背景,避免草稿残留在本标签 + useEffect(() => { + return () => { + applySiteBackground(savedBgRef.current); + }; + }, []); + + useEffect(() => { + const apply = () => setActive(parseSection(window.location.hash)); + apply(); + if (!window.location.hash) { + history.replaceState(null, "", `${window.location.pathname}${window.location.search}#theme`); + } + window.addEventListener("hashchange", apply); + return () => window.removeEventListener("hashchange", apply); + }, []); + + const goSection = (id: SectionId) => { + if (id === active) return; + setActive(id); + history.replaceState(null, "", `${window.location.pathname}${window.location.search}#${id}`); + document.getElementById("main")?.scrollTo({ top: 0 }); + }; + + const selectTab = (id: SectionId) => { + if (id === active) return; + if (tabDirty) { + setPendingSection(id); + return; + } + goSection(id); + }; + + const onNavKeyDown = (e: KeyboardEvent, i: number) => { + if ( + e.key !== "ArrowDown" && + e.key !== "ArrowUp" && + e.key !== "Home" && + e.key !== "End" && + e.key !== "ArrowRight" && + e.key !== "ArrowLeft" + ) { + return; + } + e.preventDefault(); + let next = i; + if (e.key === "ArrowDown" || e.key === "ArrowRight") next = (i + 1) % NAV.length; + else if (e.key === "ArrowUp" || e.key === "ArrowLeft") next = (i + NAV.length - 1) % NAV.length; + else if (e.key === "Home") next = 0; + else next = NAV.length - 1; + selectTab(NAV[next].id); + navRefs.current[next]?.focus(); + }; + + const handleSaveTheme = async () => { + if (!normalized || savingTheme || themeInvalid) return; + setSavingTheme(true); + setThemeError(""); try { const res = await apiUpdateSiteSettings({ accent: submitHex }); const next = (res.accent || "").toLowerCase(); @@ -106,18 +231,178 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } router.refresh(); toast(next ? "主题色已保存并对全站生效" : "已恢复默认主题色"); } catch (e) { - toast(e instanceof Error ? e.message : "保存失败", "error"); + const msg = e instanceof Error ? e.message : "保存失败"; + setThemeError(msg); + toast(msg, "error"); applyAccentTheme(savedRef.current || null); } finally { - setSaving(false); + setSavingTheme(false); } }; - const discard = () => { + const discardTheme = () => { setDraft(savedHex || DEFAULT_ACCENT); + setThemeError(""); applyAccentTheme(savedHex || null); }; + const revokeBlob = (url: string) => { + if (!url) return; + URL.revokeObjectURL(url); + blobsRef.current = blobsRef.current.filter((u) => u !== url); + }; + + const clearPending = (surface: BgSurface) => { + setBgError(""); + if (surface === "site") { + setSiteFile(null); + setSiteBlob((prev) => { + revokeBlob(prev); + return ""; + }); + setSiteMediaId(null); + setSiteMediaUrl(""); + } else { + setAdminFile(null); + setAdminBlob((prev) => { + revokeBlob(prev); + return ""; + }); + setAdminMediaId(null); + setAdminMediaUrl(""); + } + }; + + const setPendingFile = (surface: BgSurface, file: File | null) => { + setBgError(""); + if (!file) { + clearPending(surface); + return; + } + const blob = URL.createObjectURL(file); + blobsRef.current.push(blob); + if (surface === "site") { + setSiteFile(file); + setSiteBlob((prev) => { + revokeBlob(prev); + return blob; + }); + setSiteMediaId(null); + setSiteMediaUrl(""); + } else { + setAdminFile(file); + setAdminBlob((prev) => { + revokeBlob(prev); + return blob; + }); + setAdminMediaId(null); + setAdminMediaUrl(""); + } + }; + + const setPendingMedia = (surface: BgSurface, mediaId: number, mediaUrl: string) => { + setBgError(""); + if (surface === "site") { + setSiteFile(null); + setSiteBlob((prev) => { + revokeBlob(prev); + return ""; + }); + setSiteMediaId(mediaId); + setSiteMediaUrl(mediaUrl); + } else { + setAdminFile(null); + setAdminBlob((prev) => { + revokeBlob(prev); + return ""; + }); + setAdminMediaId(mediaId); + setAdminMediaUrl(mediaUrl); + } + }; + + const discardBg = () => { + setBgDraft(savedBg); + clearPending("site"); + clearPending("admin"); + setBgError(""); + }; + + const resolveSurfaceUrl = async ( + surface: BgSurface, + committed: string, + file: File | null, + mediaId: number | null + ): Promise => { + if (file) return apiUploadSiteBackground(surface, file); + if (mediaId != null) return apiBackgroundFromMedia(surface, mediaId); + return committed; + }; + + const handleSaveBg = async () => { + if (savingBg || !bgDirty) return; + setSavingBg(true); + setBgError(""); + try { + const siteUrl = await resolveSurfaceUrl("site", bgDraft.bg_site_url, siteFile, siteMediaId); + const adminUrl = await resolveSurfaceUrl( + "admin", + bgDraft.bg_admin_url, + adminFile, + adminMediaId + ); + const res = await apiUpdateSiteSettings({ + bg_site_url: siteUrl, + bg_site_mode: bgDraft.bg_site_mode, + bg_admin_url: adminUrl, + bg_admin_mode: bgDraft.bg_admin_mode, + }); + const next = bgConfigFromSettings(res); + setSavedBg(next); + setBgDraft(next); + clearPending("site"); + clearPending("admin"); + applySiteBackground(next); + router.refresh(); + toast("背景图已保存并对全站生效", "ok"); + } catch (e) { + const msg = e instanceof Error ? e.message : "保存失败"; + setBgError(msg); + toast(msg, "error"); + } finally { + setSavingBg(false); + } + }; + + const onPickBg = (surface: BgSurface, file: File | undefined) => { + if (!file || savingBg) return; + if (!/^image\/(jpeg|png|webp)$/i.test(file.type) && !/\.(jpe?g|png|webp)$/i.test(file.name)) { + toast("仅支持 JPEG / PNG / WebP", "error"); + return; + } + if (file.size > 8 * 1024 * 1024) { + toast("背景图不能超过 8MB", "error"); + return; + } + setPendingFile(surface, file); + }; + + const onChangeBgMode = (surface: BgSurface, mode: BgMode) => { + const key = surface === "site" ? "bg_site_mode" : "bg_admin_mode"; + setBgDraft((prev) => ({ ...prev, [key]: mode })); + }; + + const onClearBg = (surface: BgSurface) => { + const key = surface === "site" ? "bg_site_url" : "bg_admin_url"; + setBgDraft((prev) => ({ ...prev, [key]: "" })); + clearPending(surface); + }; + + const previewUrl = (surface: BgSurface) => { + if (surface === "site") return siteBlob || siteMediaUrl || bgDraft.bg_site_url; + return adminBlob || adminMediaUrl || bgDraft.bg_admin_url; + }; + const toggleCodeFold = async () => { if (togglingFold) return; const next = !codeAutoFold; @@ -185,82 +470,6 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } } }; - const commitBg = (next: SiteBgConfig) => { - setBg(next); - applySiteBackground(next); - }; - - const saveBgPatch = async (patch: Partial, toastOk: string) => { - const res = await apiUpdateSiteSettings(patch); - commitBg(bgConfigFromSettings(res)); - toast(toastOk, "ok"); - }; - - const onPickBg = async (surface: BgSurface, file: File | undefined) => { - if (!file || bgBusy) return; - if (!/^image\/(jpeg|png|webp)$/i.test(file.type) && !/\.(jpe?g|png|webp)$/i.test(file.name)) { - toast("仅支持 JPEG / PNG / WebP", "error"); - return; - } - if (file.size > 8 * 1024 * 1024) { - toast("背景图不能超过 8MB", "error"); - return; - } - setBgBusy(surface); - try { - const pendingMode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode; - let res = await apiUploadSiteBackground(surface, file); - let next = bgConfigFromSettings(res); - const savedMode = surface === "site" ? next.bg_site_mode : next.bg_admin_mode; - if (pendingMode !== savedMode) { - const modeKey = surface === "site" ? "bg_site_mode" : "bg_admin_mode"; - res = await apiUpdateSiteSettings({ [modeKey]: pendingMode }); - next = bgConfigFromSettings(res); - } - commitBg(next); - toast(surface === "site" ? "前台背景已更新" : "后台背景已更新", "ok"); - } catch (e) { - toast(e instanceof Error ? e.message : "上传失败", "error"); - } finally { - setBgBusy(null); - } - }; - - const onChangeBgMode = async (surface: BgSurface, mode: BgMode) => { - if (bgBusy) return; - const key = surface === "site" ? "bg_site_mode" : "bg_admin_mode"; - const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url; - if (!url) { - commitBg({ ...bg, [key]: mode }); - return; - } - setBgBusy(surface); - try { - await saveBgPatch( - { [key]: mode }, - surface === "site" ? "前台铺放方式已保存" : "后台铺放方式已保存" - ); - } catch (e) { - toast(e instanceof Error ? e.message : "保存失败", "error"); - } finally { - setBgBusy(null); - } - }; - - const onClearBg = async (surface: BgSurface) => { - if (bgBusy) return; - const key = surface === "site" ? "bg_site_url" : "bg_admin_url"; - setBgBusy(surface); - try { - await saveBgPatch( - { [key]: "" }, - surface === "site" ? "已恢复前台默认画布" : "已恢复后台默认画布" - ); - } catch (e) { - toast(e instanceof Error ? e.message : "保存失败", "error"); - } finally { - setBgBusy(null); - } }; const renderBgRail = ( @@ -270,18 +479,21 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } Icon: typeof Monitor, fileRef: typeof siteFileRef ) => { - const url = surface === "site" ? bg.bg_site_url : bg.bg_admin_url; - const mode = surface === "site" ? bg.bg_site_mode : bg.bg_admin_mode; - const busy = bgBusy === surface; + const url = previewUrl(surface); + const mode = surface === "site" ? bgDraft.bg_site_mode : bgDraft.bg_admin_mode; + const busy = savingBg; + const hasCommitted = surface === "site" ? !!bgDraft.bg_site_url : !!bgDraft.bg_admin_url; + const hasPending = + surface === "site" + ? siteFile != null || siteMediaId != null + : adminFile != null || adminMediaId != null; return (
-
-
-

- {title} -

-

{desc}

-
+
+

+ {title} +

+

{desc}

{ const f = e.target.files?.[0]; e.target.value = ""; - void onPickBg(surface, f); + onPickBg(surface, f); }} />
@@ -333,14 +545,22 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } onClick={() => fileRef.current?.click()} > {busy ? : } - {url ? "更换图片" : "上传图片"} + {hasCommitted || hasPending ? "更换图片" : "选择图片"} - {url ? ( + + {hasCommitted || hasPending ? ( @@ -352,384 +572,463 @@ export default function AppearanceAdmin({ initial }: { initial: PublicSettings } value={mode} disabled={busy} ariaLabel={`${title}铺放方式`} - onChange={(next) => void onChangeBgMode(surface, next)} + onChange={(next) => onChangeBgMode(surface, next)} />
); }; + const leaveOpen = leave.leaveOpen || pendingSection != null; + const confirmLeave = () => { + if (pendingSection) { + const next = pendingSection; + setPendingSection(null); + if (themeDirty) discardTheme(); + if (bgDirty) discardBg(); + goSection(next); + return; + } + leave.confirmLeave(); + }; + + const nav = ( + + ); + return ( -
+ -
- {/* 主题色列 */} -
- -
- {ACCENT_PRESETS.map((p) => { - const active = normalized === p.light; - const isDefault = p.light === DEFAULT_ACCENT; - return ( - - ); - })} -
-
- - - 自定义颜色 - - } - description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正" - > -
-
+ { + setPendingSection(null); + leave.cancelLeave(); + }} + /> + setBgPicker(null)} + onPick={(item) => { + if (!bgPicker) return; + setPendingMedia(bgPicker, item.id, item.url); + }} + /> + ); } diff --git a/frontend/app/admin/appearance/BgMediaPicker.tsx b/frontend/app/admin/appearance/BgMediaPicker.tsx new file mode 100644 index 0000000..aa2fbb0 --- /dev/null +++ b/frontend/app/admin/appearance/BgMediaPicker.tsx @@ -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 = { + 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([]); + 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 ( + + {loading ? ( +

+ 加载中 +

+ ) : error ? ( +

+ {error} +

+ ) : items.length === 0 ? ( +

+ 没有可用图片。请先在发帖或媒体库上传 JPEG / PNG / WebP。 +

+ ) : ( +
+ {items.map((item) => ( + + ))} +
+ )} +
+ ); +} diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index df6b921..fa4843f 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -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 { +): Promise { 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); + const url = String(data.url || ""); + if (!url) throw new Error("上传背景图失败"); + return url; +} + +/** 把本人媒体库图片复制进 backgrounds,不写入站点设置 */ +export async function apiBackgroundFromMedia( + surface: "site" | "admin", + attachmentId: number +): Promise { + 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; } // ===== 板块(客户端,供角色授权弹窗勾选) ===== diff --git a/frontend/lib/bg.ts b/frontend/lib/bg.ts index 4eed9a8..55f850a 100644 --- a/frontend/lib/bg.ts +++ b/frontend/lib/bg.ts @@ -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),