Compare commits

...

6 Commits

Author SHA1 Message Date
sagit a45bd43e73 feat: add global background image setting to config page (#417)
Add app_bg_image setting to /config page to allow global background
customization.
2026-04-05 18:36:31 +08:00
sagitchu 364bace87f fix: remove unused imports and leftover jsx in settings 2026-04-05 18:35:07 +08:00
sagitchu 2caafab937 feat: add global background image setting to config page 2026-04-05 18:31:59 +08:00
sagit 901b903a70 fix: frontend browser caching issue (#416)
Disable caching for index.html and reduce static assets cache to 30d.
2026-04-05 17:51:56 +08:00
sagitchu 930cc86614 fix: frontend browser caching issue
Disable caching for index.html and reduce static assets cache to 30d.
2026-04-05 17:50:12 +08:00
sagit 101fd5805a feat(ui): add persistent custom background image upload feature (#415) 2026-04-05 17:44:19 +08:00
7 changed files with 176 additions and 4 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
}
+3
View File
@@ -2,6 +2,9 @@
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta http-equiv="Cache-Control" content="no-cache, no-store, must-revalidate" />
<meta http-equiv="Pragma" content="no-cache" />
<meta http-equiv="Expires" content="0" />
<meta name="theme-color" content="#2563eb" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
+9 -1
View File
@@ -45,10 +45,18 @@ http {
# 静态文件缓存
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ {
expires 1y;
expires 30d;
add_header Cache-Control "public, immutable";
}
# HTML 文件不缓存
location ~* \.(?:htm|html)$ {
add_header Cache-Control "no-cache, no-store, must-revalidate";
add_header Pragma "no-cache";
add_header Expires "0";
try_files $uri $uri/ /index.html;
}
# Vue Router的history模式支持
location / {
try_files $uri $uri/ /index.html;
+19
View File
@@ -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;
+15 -1
View File
@@ -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<string | null> => {
// 获取所有配置(优先从缓存)
export const getCachedConfigs = async (): Promise<Record<string, string>> => {
// 尝试从缓存获取所有配置
const configKeys = ["app_name", "app_logo", "app_favicon"];
const configKeys = ["app_name", "app_logo", "app_favicon", "app_bg_image"];
const cachedConfigs: Record<string, string> = {};
let hasCachedData = false;
@@ -263,6 +268,10 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
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<string, string>) => {
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<string, string>) => {
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);
};
+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);
+5
View File
@@ -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 */