feat: add global background image setting to config page (#417)

Add app_bg_image setting to /config page to allow global background
customization.
This commit is contained in:
sagit
2026-04-05 18:36:31 +08:00
committed by GitHub
3 changed files with 126 additions and 172 deletions
+1 -1
View File
@@ -963,7 +963,7 @@ func (h *Handler) updateSingleConfig(w http.ResponseWriter, r *http.Request) {
return
}
if value == "" && name != "app_logo" && name != "app_favicon" {
if value == "" && name != "app_logo" && name != "app_favicon" && name != "app_bg_image" {
response.WriteJSON(w, response.ErrDefault("配置值不能为空"))
return
}
+124 -1
View File
@@ -65,7 +65,7 @@ interface ConfigItem {
label: string;
placeholder?: string;
description?: string;
type: "input" | "switch" | "select";
type: "input" | "switch" | "select" | "bg_image";
options?: { label: string; value: string; description?: string }[];
dependsOn?: string; // 依赖的配置项key
dependsValue?: string; // 依赖的配置项值
@@ -86,6 +86,12 @@ const toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => {
// 网站配置项定义
const CONFIG_ITEMS: ConfigItem[] = [
{
key: "app_bg_image",
label: "自定义背景",
description: "上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)",
type: "bg_image",
},
{
key: "ip",
label: "面板后端地址",
@@ -254,6 +260,8 @@ export default function ConfigPage() {
const logoFileInputRef = useRef<HTMLInputElement>(null);
const faviconFileInputRef = useRef<HTMLInputElement>(null);
const bgImageFileInputRef = useRef<HTMLInputElement>(null);
const [bgImageUploading, setBgImageUploading] = useState(false);
const [announcement, setAnnouncement] = useState<AnnouncementData>({
content: "",
@@ -547,6 +555,118 @@ export default function ConfigPage() {
}
};
const handleBgImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
if (!file.type.startsWith("image/")) {
toast.error("只能上传图片文件");
return;
}
setBgImageUploading(true);
try {
const compressedImage = await new Promise<string>((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);
});
handleConfigChange("app_bg_image", compressedImage);
toast.success("自定义背景上传成功");
} catch (error) {
toast.error(error instanceof Error ? error.message : "图片处理失败");
} finally {
setBgImageUploading(false);
e.target.value = "";
}
};
const renderBgImageUploader = () => {
const bgImage = configs["app_bg_image"] || "";
return (
<div className="flex flex-col gap-4 w-full">
<div className="flex items-center gap-4">
<input
type="file"
accept="image/*"
ref={bgImageFileInputRef}
className="hidden"
onChange={handleBgImageUpload}
/>
<Button
color="primary"
variant="flat"
onPress={() => bgImageFileInputRef.current?.click()}
isLoading={bgImageUploading}
>
选择图片
</Button>
{bgImage && (
<Button
color="danger"
variant="flat"
onPress={() => handleConfigChange("app_bg_image", "")}
isDisabled={bgImageUploading}
>
清除背景
</Button>
)}
</div>
{bgImage && (
<div className="relative rounded-xl overflow-hidden border border-divider">
<img
src={bgImage}
alt="背景预览"
className="w-full max-h-48 object-cover opacity-80"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent flex items-end p-4">
<span className="text-white text-sm font-medium">预览效果</span>
</div>
</div>
)}
</div>
);
};
const renderBrandPreview = (key: BrandPreviewKey) => {
const previewUrl = (configs[key] || "").trim();
const appNamePreview = (configs.app_name || "").trim() || "应用名称";
@@ -699,6 +819,9 @@ export default function ConfigPage() {
const isCommercialDisabled = ["app_name", "app_logo", "app_favicon", "hide_footer_brand"].includes(item.key) && configs.is_commercial !== "true";
switch (item.type) {
case "bg_image":
return renderBgImageUploader();
case "input":
if (isBrandPreviewKey(item.key)) {
return renderBrandAssetUploader(item.key, isChanged);
+1 -170
View File
@@ -1,4 +1,4 @@
import { useState, useEffect, useRef, ChangeEvent } from "react";
import { useState, useEffect } from "react";
import { useNavigate } from "react-router-dom";
import toast from "react-hot-toast";
@@ -23,7 +23,6 @@ import {
deletePanelAddress,
validatePanelAddress,
} from "@/utils/panel";
import { siteConfig, updateSiteConfig } from "@/config/site";
interface PanelAddress {
name: string;
@@ -47,100 +46,9 @@ 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<HTMLInputElement>(null);
const admin = isAdmin();
const handleBgImageUpload = async (e: ChangeEvent<HTMLInputElement>) => {
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<string>((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);
};
@@ -275,83 +183,6 @@ export const SettingsPage = () => {
{/* 内容区域 */}
<div className="max-w-4xl mx-auto px-4 py-6">
<div className="space-y-6">
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">
外观设置
</h2>
<div className="space-y-4">
<div className="flex flex-col gap-2">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
自定义背景图片
</label>
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
留空则使用系统默认的网格渐变背景。推荐上传 1920x1080 尺寸的图片。
</p>
<div className="flex items-center gap-4">
{bgImage ? (
<div className="relative w-40 h-24 rounded-md overflow-hidden border border-gray-200 dark:border-gray-700 group">
<img src={bgImage} alt="Background Preview" className="w-full h-full object-cover" />
</div>
) : (
<div className="flex items-center justify-center w-40 h-24 rounded-md border border-dashed border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 text-gray-400 text-xs">
无自定义背景
</div>
)}
<div>
<input
type="file"
accept="image/*"
className="hidden"
ref={fileInputRef}
onChange={handleBgImageUpload}
/>
<Button
color="primary"
variant="flat"
size="sm"
isDisabled={!admin || bgImageSaving}
onPress={() => fileInputRef.current?.click()}
>
选择图片...
</Button>
</div>
</div>
</div>
<div className="flex justify-end gap-2 mt-2">
{bgImage && (
<Button
color="danger"
variant="flat"
isDisabled={!admin || bgImageSaving}
onPress={() => {
setBgImage("");
void handleBgImageSave("");
}}
>
恢复默认
</Button>
)}
<Button
color="primary"
isDisabled={!admin}
isLoading={bgImageSaving}
onPress={() => handleBgImageSave()}
>
保存背景
</Button>
</div>
{!admin && (
<p className="mt-2 text-xs text-amber-600 dark:text-amber-400">
仅管理员可修改该全局配置。
</p>
)}
</div>
</div>
</CardBody>
</Card>
<Card>
<CardBody className="p-6">
<h2 className="text-lg font-medium text-gray-900 dark:text-white mb-4">