From 534d36f0a16ff9bbe703b9cfae273ca0c6bcf2cd Mon Sep 17 00:00:00 2001 From: sagit <36596628+Sagit-chu@users.noreply.github.com> Date: Sun, 5 Apr 2026 17:44:19 +0800 Subject: [PATCH] feat(ui): add persistent custom background image upload feature (#415) --- vite-frontend/src/App.tsx | 19 +++ vite-frontend/src/config/site.ts | 16 ++- vite-frontend/src/pages/settings.tsx | 171 ++++++++++++++++++++++++++- vite-frontend/src/styles/globals.css | 5 + 4 files changed, 209 insertions(+), 2 deletions(-) diff --git a/vite-frontend/src/App.tsx b/vite-frontend/src/App.tsx index 6fcface..e9de9d9 100644 --- a/vite-frontend/src/App.tsx +++ b/vite-frontend/src/App.tsx @@ -90,6 +90,25 @@ const LoginRoute = () => { function App() { const location = useLocation(); + // 处理自定义背景图片 + useEffect(() => { + const updateBg = () => { + const customBg = siteConfig.app_bg_image; + if (customBg) { + document.documentElement.style.setProperty("--custom-bg-image", `url(${customBg})`); + document.documentElement.classList.add("has-custom-bg"); + } else { + document.documentElement.style.removeProperty("--custom-bg-image"); + document.documentElement.classList.remove("has-custom-bg"); + } + }; + updateBg(); + window.addEventListener("site-config-updated", updateBg); + return () => { + window.removeEventListener("site-config-updated", updateBg); + }; + }, []); + // 立即设置页面标题(使用已从缓存读取的配置) useEffect(() => { document.title = siteConfig.name; diff --git a/vite-frontend/src/config/site.ts b/vite-frontend/src/config/site.ts index 98ce47c..188fe7b 100644 --- a/vite-frontend/src/config/site.ts +++ b/vite-frontend/src/config/site.ts @@ -20,6 +20,7 @@ const getInitialConfig = () => { github_repo: GITHUB_REPO, app_logo: "", app_favicon: "", + app_bg_image: "", is_commercial: false, hide_footer_brand: false, }; @@ -29,6 +30,8 @@ const getInitialConfig = () => { const cachedAppLogo = localStorage.getItem(CACHE_PREFIX + "app_logo") || ""; const cachedAppFavicon = localStorage.getItem(CACHE_PREFIX + "app_favicon") || ""; + const cachedAppBgImage = + localStorage.getItem(CACHE_PREFIX + "app_bg_image") || ""; const isCommercial = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true"; const hideFooterBrand = localStorage.getItem(CACHE_PREFIX + "hide_footer_brand") === "true"; @@ -40,6 +43,7 @@ const getInitialConfig = () => { github_repo: GITHUB_REPO, app_logo: cachedAppLogo, app_favicon: cachedAppFavicon, + app_bg_image: cachedAppBgImage, is_commercial: isCommercial, hide_footer_brand: hideFooterBrand, }; @@ -52,6 +56,7 @@ const getInitialConfig = () => { github_repo: GITHUB_REPO, app_logo: cachedAppLogo, app_favicon: cachedAppFavicon, + app_bg_image: cachedAppBgImage, is_commercial: isCommercial, hide_footer_brand: hideFooterBrand, }; @@ -123,7 +128,7 @@ export const getCachedConfig = async (key: string): Promise => { // 获取所有配置(优先从缓存) export const getCachedConfigs = async (): Promise> => { // 尝试从缓存获取所有配置 - const configKeys = ["app_name", "app_logo", "app_favicon"]; + const configKeys = ["app_name", "app_logo", "app_favicon", "app_bg_image"]; const cachedConfigs: Record = {}; let hasCachedData = false; @@ -263,6 +268,10 @@ export const updateSiteConfig = async (configMap?: Record) => { resolvedConfigMap, "app_favicon", ); + const hasAppBgImage = Object.prototype.hasOwnProperty.call( + resolvedConfigMap, + "app_bg_image", + ); const appName = hasAppName ? String(resolvedConfigMap.app_name || "").trim() @@ -273,6 +282,9 @@ export const updateSiteConfig = async (configMap?: Record) => { const appFavicon = hasAppFavicon ? String(resolvedConfigMap.app_favicon || "").trim() : (siteConfig.app_favicon || "").trim(); + const appBgImage = hasAppBgImage + ? String(resolvedConfigMap.app_bg_image || "").trim() + : (siteConfig.app_bg_image || "").trim(); if (appName && appName !== siteConfig.name) { siteConfig.name = appName; @@ -280,11 +292,13 @@ export const updateSiteConfig = async (configMap?: Record) => { siteConfig.app_logo = appLogo; siteConfig.app_favicon = appFavicon; + siteConfig.app_bg_image = appBgImage; siteConfig.is_commercial = resolvedConfigMap.is_commercial === "true"; siteConfig.hide_footer_brand = resolvedConfigMap.hide_footer_brand === "true"; if (typeof document !== "undefined") { document.title = siteConfig.name; + window.dispatchEvent(new Event("site-config-updated")); } updateDocumentFavicon(siteConfig.app_favicon); }; diff --git a/vite-frontend/src/pages/settings.tsx b/vite-frontend/src/pages/settings.tsx index ef6bebc..48dae5e 100644 --- a/vite-frontend/src/pages/settings.tsx +++ b/vite-frontend/src/pages/settings.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef, ChangeEvent } from "react"; import { useNavigate } from "react-router-dom"; import toast from "react-hot-toast"; @@ -23,6 +23,7 @@ import { deletePanelAddress, validatePanelAddress, } from "@/utils/panel"; +import { siteConfig, updateSiteConfig } from "@/config/site"; interface PanelAddress { name: string; @@ -46,9 +47,100 @@ export const SettingsPage = () => { const [forwardCompactMode, setForwardCompactMode] = useState(false); const [forwardCompactModeSaving, setForwardCompactModeSaving] = useState(false); + const [bgImage, setBgImage] = useState(() => siteConfig.app_bg_image || ""); + const [bgImageSaving, setBgImageSaving] = useState(false); + const fileInputRef = useRef(null); const admin = isAdmin(); + const handleBgImageUpload = async (e: ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + if (!file.type.startsWith("image/")) { + toast.error("只能上传图片文件"); + return; + } + + setBgImageSaving(true); + try { + const compressedImage = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (event) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement("canvas"); + let width = img.width; + let height = img.height; + const MAX_WIDTH = 1920; + const MAX_HEIGHT = 1080; + + if (width > height) { + if (width > MAX_WIDTH) { + height = Math.round((height * MAX_WIDTH) / width); + width = MAX_WIDTH; + } + } else { + if (height > MAX_HEIGHT) { + width = Math.round((width * MAX_HEIGHT) / height); + height = MAX_HEIGHT; + } + } + + canvas.width = width; + canvas.height = height; + const ctx = canvas.getContext("2d"); + if (!ctx) { + reject(new Error("Canvas context is null")); + return; + } + ctx.drawImage(img, 0, 0, width, height); + + // Output as webp for better compression + const dataUrl = canvas.toDataURL("image/webp", 0.8); + resolve(dataUrl); + }; + img.onerror = () => reject(new Error("图片加载失败")); + img.src = event.target?.result as string; + }; + reader.onerror = () => reject(new Error("文件读取失败")); + reader.readAsDataURL(file); + }); + + setBgImage(compressedImage); + // Optional: automatically save after processing + // void handleBgImageSave(compressedImage); + } catch (error) { + toast.error(error instanceof Error ? error.message : "图片处理失败"); + } finally { + setBgImageSaving(false); + // Reset input value so the same file can be selected again if needed + e.target.value = ""; + } + }; + + const handleBgImageSave = async (valueToSave?: string) => { + if (!admin || bgImageSaving) return; + setBgImageSaving(true); + try { + const finalValue = typeof valueToSave === "string" ? valueToSave : bgImage; + const response = await updateConfig("app_bg_image", finalValue); + if (response.code === 0) { + siteConfig.app_bg_image = finalValue; + // 触发配置更新,刷新相关页面或样式 + await updateSiteConfig(); + window.dispatchEvent(new Event("site-config-updated")); + toast.success("背景图片设置已保存"); + } else { + toast.error(response.msg || "保存背景失败"); + } + } catch { + toast.error("保存背景失败"); + } finally { + setBgImageSaving(false); + } + }; + const setPanelAddressesFunc = (newAddress: PanelAddress[]) => { setPanelAddresses(newAddress); }; @@ -183,6 +275,83 @@ export const SettingsPage = () => { {/* 内容区域 */}
+ + +

+ 外观设置 +

+
+
+
+ +

+ 留空则使用系统默认的网格渐变背景。推荐上传 1920x1080 尺寸的图片。 +

+
+ {bgImage ? ( +
+ Background Preview +
+ ) : ( +
+ 无自定义背景 +
+ )} +
+ + +
+
+
+
+ {bgImage && ( + + )} + +
+ {!admin && ( +

+ 仅管理员可修改该全局配置。 +

+ )} +
+
+
+
+

diff --git a/vite-frontend/src/styles/globals.css b/vite-frontend/src/styles/globals.css index 86cbfd8..4f87bd8 100644 --- a/vite-frontend/src/styles/globals.css +++ b/vite-frontend/src/styles/globals.css @@ -242,6 +242,11 @@ body { background-color: #1c1c1e; } +:root.has-custom-bg .bg-mesh-gradient { + background: var(--custom-bg-image) no-repeat center center fixed !important; + background-size: cover !important; +} + @layer utilities { .scrollbar-hide { /* IE and Edge */