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 ? ( + setBgPicker(surface)} + > + 媒体库 + + {hasCommitted || hasPending ? ( void onClearBg(surface)} + onClick={() => onClearBg(surface)} > 恢复默认 @@ -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 = ( + + {NAV.map((item, i) => ( + { + navRefs.current[i] = el; + }} + href={`#${item.id}`} + className="admin-settings-nav-link" + aria-current={active === item.id ? "page" : undefined} + onClick={(e) => { + e.preventDefault(); + selectTab(item.id); + }} + onKeyDown={(e) => onNavKeyDown(e, i)} + > + {item.label} + + ))} + + ); + return ( - + - - {/* 主题色列 */} - - - - {ACCENT_PRESETS.map((p) => { - const active = normalized === p.light; - const isDefault = p.light === DEFAULT_ACCENT; - return ( - setDraft(p.light)} - className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors" - style={{ - borderColor: active ? "var(--accent)" : "var(--line)", - background: active ? "var(--accent-soft)" : "var(--panel)", - cursor: "pointer", - }} - aria-pressed={active} - data-tip={isDefault ? `${p.name}(默认)` : p.name} - > - - - - - - {active && ( - - - - )} - - - {p.name} - - - ); - })} - - - - - 自定义颜色 - - } - description="取色器选色或输入 HEX 值;浅色下对比度过低时会自动校正" - > - - + + {active === "theme" && ( + <> + - setDraft(e.target.value)} - aria-label="自定义主题色取色器" - /> - - - - # - setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, ""))} - className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase" - maxLength={7} - spellCheck={false} - aria-label="主题色 HEX 值" - /> + + {ACCENT_PRESETS.map((p) => { + const selected = normalized === p.light; + const isDefault = p.light === DEFAULT_ACCENT; + return ( + { + setThemeError(""); + setDraft(p.light); + }} + className="group flex flex-col items-center gap-2 py-3 rounded-[var(--r-sm)] border transition-colors" + style={{ + borderColor: selected ? "var(--accent)" : "var(--line)", + background: selected ? "var(--accent-soft)" : "var(--panel)", + cursor: "pointer", + }} + aria-pressed={selected} + data-tip={isDefault ? `${p.name}(默认)` : p.name} + > + + + + + + {selected && ( + + + + )} + + + {p.name} + + + ); + })} - - {pair && ( - - - - 浅色 {pair.light} - - - - 暗色 {pair.dark} - - - )} - - {invalid && ( - - 颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB) - - )} - {pair?.adjusted && ( - - 该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "} - {pair.dark}) - - )} - {preset && preset.light === DEFAULT_ACCENT && ( - 当前为内置默认色,保存时会清除自定义配置 - )} - + - - - - - 主按钮 - - - 次要按钮 - - e.preventDefault()} - > - 文字链接示例 - - - - 未选中 - - 选中标签 - - - 导航选中态 - - - - - 板块目录 - - - - - 选中的板块 - - - - - - - - {/* 代码折叠 + 动画列 */} - - - 代码块自动折叠 - > - } - description="超过阈值行数的 Markdown 代码块默认折叠,读者可点「展开全部」。" - control={ - void toggleCodeFold()} - label="代码块自动折叠" - /> - } - > - {codeAutoFold ? ( - <> - - 折叠行数阈值 - - - 超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。 - - - - void saveFoldLines()} + void handleSaveTheme()} + onDiscard={discardTheme} + saveDisabled={!normalized || savingTheme || !themeDirty || themeInvalid} + error={themeError} > - {savingFoldLines ? ( - - ) : ( - + { + setThemeError(""); + setDraft(DEFAULT_ACCENT); + }} + > + 恢复默认 + + + } + > + + 主题色 + + } + hintBelow="保存前仅本页预览,不会推送给其他访客。" + error={themeInvalid ? "颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB)" : undefined} + > + + + { + setThemeError(""); + setDraft(e.target.value); + }} + aria-label="自定义主题色取色器" + /> + + + # + { + setThemeError(""); + setDraft("#" + e.target.value.replace(/[^0-9a-fA-F#]/g, "")); + }} + className="admin-field-input flex-1 min-w-0 px-0 py-0 ml-1 uppercase" + maxLength={7} + spellCheck={false} + aria-label="主题色 HEX 值" + /> + + {pair && ( + + + + 浅色 {pair.light} + + + + 暗色 {pair.dark} + + )} - 保存 - - - > - ) : null} - - - - 界面动画 - > - } - description="总开关关闭后全站即时切换。系统「减少动态效果」始终优先。" - control={ - void toggleMotionField("ui_animations", !uiAnimations)} - label="界面动画总开关" - /> - } - > - {uiAnimations ? ( - - - - - 代码折叠动画 - - - 代码块展开/收起的高度过渡与跟滚。 - + {pair?.adjusted && ( + + 该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "} + {pair.dark}) + + )} + {preset && preset.light === DEFAULT_ACCENT && ( + 当前为内置默认色,保存时会清除自定义配置 + )} + + + + + + + 主按钮 + + + 次要按钮 + + e.preventDefault()} + > + 文字链接示例 + + + + 未选中 + + 选中标签 + + + 导航选中态 + + + + + 板块目录 + + + + + 选中的板块 + + + + + + + > + )} + + {active === "background" && ( + void handleSaveBg()} + onDiscard={discardBg} + error={bgError} + /> + } + > + + 前台 / 后台 + + } + hintBelow="选择图片与铺放方式仅在本页预览;点保存后才写入并广播。" + wide + > + + {renderBgRail( + "site", + "前台", + "访客看到的论坛页面,含首页、帖子与个人主页。", + Monitor, + siteFileRef + )} + {renderBgRail( + "admin", + "后台", + "管理面板专用。与前台互不影响,可配更克制的图案。", + LayoutDashboard, + adminFileRef + )} + + + + )} + + {active === "code" && ( + + + 自动折叠 + + } + > + void toggleCodeFold()} + label="代码块自动折叠" + /> + + + + {codeAutoFold ? ( + + + + void saveFoldLines()} + > + {savingFoldLines ? ( + + ) : ( + + )} + 保存 + + + + ) : null} + + )} + + {active === "motion" && ( + + + 总开关 + + } + > + + void toggleMotionField("anim_code_fold", !animCodeFold)} + onChange={() => void toggleMotionField("ui_animations", !uiAnimations)} + label="界面动画总开关" + /> + + + + {uiAnimations ? ( + <> + - - - - - 平滑滚动 - - - 目录跳转、回顶、新内容提示等 scroll 行为。 - - - - void toggleMotionField("anim_smooth_scroll", !animSmoothScroll) - } + hintBelow="代码块展开/收起的高度过渡与跟滚。" + > + + void toggleMotionField("anim_code_fold", !animCodeFold)} + label="代码折叠动画" + /> + + + + - - - - - 装饰入场 - - - 列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。 - - - void toggleMotionField("anim_chrome", !animChrome)} + hintBelow="目录跳转、回顶、新内容提示等 scroll 行为。" + > + + + void toggleMotionField("anim_smooth_scroll", !animSmoothScroll) + } + label="平滑滚动" + /> + + + + - - - ) : null} - + hintBelow="列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。" + > + + void toggleMotionField("anim_chrome", !animChrome)} + label="装饰入场" + /> + + + + > + ) : null} + + )} - + - - 站点背景图 - - } - description="前台与后台可分别上传 JPEG / PNG / WebP(≤8MB)。预览按 16:9 常见显示器比例。未配置时使用默认纹饰画布。" - > - - {renderBgRail( - "site", - "前台", - "访客看到的论坛页面,含首页、帖子与个人主页。", - Monitor, - siteFileRef - )} - {renderBgRail( - "admin", - "后台", - "管理面板专用。与前台互不影响,可配更克制的图案。", - LayoutDashboard, - adminFileRef - )} - - - - - - {dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"} - - - {dirty && ( - - 撤销改动 - - )} - { - setDraft(DEFAULT_ACCENT); - }} - > - 恢复默认 - - - {saving ? : } - 保存并全站生效 - - - - + { + 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) => ( + { + 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 */} + + + + + + ))} + + )} + + ); +} 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),
{desc}
- 颜色格式无效,请输入 6 位 HEX 值(如 3B5BDB) -
- 该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "} - {pair.dark}) -
当前为内置默认色,保存时会清除自定义配置
板块目录
- 超过该值时折叠;折叠后约露出同样行数。设为 0 表示无预览。范围 0–100,默认 5。 -
- 代码折叠动画 -
- 代码块展开/收起的高度过渡与跟滚。 -
+ 该颜色与白底对比度不足,浅色模式已自动校正为 {pair.light}(暗色模式同步派生{" "} + {pair.dark}) +
- 平滑滚动 -
- 目录跳转、回顶、新内容提示等 scroll 行为。 -
- 装饰入场 -
- 列表入场、Toast、点赞弹出、在线脉冲等装饰性动画。 -
- {dirty ? "有未保存的改动,整页正处于实时预览状态" : "设置已是保存状态"} -
+ 加载中 +
+ {error} +
+ 没有可用图片。请先在发帖或媒体库上传 JPEG / PNG / WebP。 +