feat: add mode-specific wallpapers and installer regression coverage

This commit is contained in:
sagitchu
2026-09-24 14:59:52 +08:00
parent 2e845030de
commit 62c56e0a79
9 changed files with 233 additions and 40 deletions
+12
View File
@@ -7,6 +7,18 @@ on:
branches: ['**'] branches: ['**']
jobs: jobs:
install-scripts:
name: Test Installer Scripts (systemd and Alpine OpenRC)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Run installer regression tests
run: bash test-install-scripts-proxy.sh
- name: Test Alpine bootstrap and OpenRC lifecycle
run: docker run --rm -v "$PWD:/workspace:ro" alpine:3.22 sh /workspace/test-install-scripts-alpine.sh
frontend: frontend:
name: Build Frontend name: Build Frontend
runs-on: ubuntu-latest runs-on: ubuntu-latest
+3
View File
@@ -304,6 +304,9 @@ jobs:
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/install.sh sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/install.sh
sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/panel_install.sh sed -i "s|^PINNED_VERSION=\"\"|PINNED_VERSION=\"${VERSION}\"|" ./artifacts/panel_install.sh
- name: Verify release installer on Alpine OpenRC
run: docker run --rm -v "$PWD:/workspace:ro" alpine:3.22 sh /workspace/test-install-scripts-alpine.sh /workspace/artifacts/install.sh
- name: Create Release - name: Create Release
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
@@ -19,6 +19,8 @@ func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
seedConfigValue(t, r, "app_logo", "logo-data") seedConfigValue(t, r, "app_logo", "logo-data")
seedConfigValue(t, r, "app_favicon", "favicon-data") seedConfigValue(t, r, "app_favicon", "favicon-data")
seedConfigValue(t, r, "app_bg_image", "bg-data") seedConfigValue(t, r, "app_bg_image", "bg-data")
seedConfigValue(t, r, "app_bg_image_light", "light-bg-data")
seedConfigValue(t, r, "app_bg_image_dark", "dark-bg-data")
seedConfigValue(t, r, "cloudflare_site_key", "site-key") seedConfigValue(t, r, "cloudflare_site_key", "site-key")
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`)) req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"app_name"}`))
@@ -28,6 +30,16 @@ func TestPublicConfigGetAllowsBrandKeys(t *testing.T) {
router.ServeHTTP(resp, req) router.ServeHTTP(resp, req)
assertHandlerCode(t, resp, 0) assertHandlerCode(t, resp, 0)
for name, want := range map[string]string{
"app_bg_image_light": "light-bg-data",
"app_bg_image_dark": "dark-bg-data",
} {
req := httptest.NewRequest(http.MethodPost, "/api/v1/public/config/get", bytes.NewBufferString(`{"name":"`+name+`"}`))
req.Header.Set("Content-Type", "application/json")
resp := httptest.NewRecorder()
router.ServeHTTP(resp, req)
assertHandlerConfigValue(t, resp, name, want)
}
} }
func TestPublicBrandConfigFallsBackWithoutCommercialLicense(t *testing.T) { func TestPublicBrandConfigFallsBackWithoutCommercialLicense(t *testing.T) {
@@ -14,6 +14,8 @@ var publicConfigKeys = map[string]struct{}{
"app_logo": {}, "app_logo": {},
"app_favicon": {}, "app_favicon": {},
"app_bg_image": {}, "app_bg_image": {},
"app_bg_image_light": {},
"app_bg_image_dark": {},
"cloudflare_site_key": {}, "cloudflare_site_key": {},
"is_commercial": {}, "is_commercial": {},
"hide_footer_brand": {}, "hide_footer_brand": {},
@@ -12,6 +12,8 @@ func TestConfigPolicy(t *testing.T) {
{name: "app_logo is public", key: "app_logo", want: ConfigAccessPublic}, {name: "app_logo is public", key: "app_logo", want: ConfigAccessPublic},
{name: "app_favicon is public", key: "app_favicon", want: ConfigAccessPublic}, {name: "app_favicon is public", key: "app_favicon", want: ConfigAccessPublic},
{name: "app_bg_image is public", key: "app_bg_image", want: ConfigAccessPublic}, {name: "app_bg_image is public", key: "app_bg_image", want: ConfigAccessPublic},
{name: "app_bg_image_light is public", key: "app_bg_image_light", want: ConfigAccessPublic},
{name: "app_bg_image_dark is public", key: "app_bg_image_dark", want: ConfigAccessPublic},
{name: "cloudflare_site_key is public", key: "cloudflare_site_key", want: ConfigAccessPublic}, {name: "cloudflare_site_key is public", key: "cloudflare_site_key", want: ConfigAccessPublic},
{name: "is_commercial is public", key: "is_commercial", want: ConfigAccessPublic}, {name: "is_commercial is public", key: "is_commercial", want: ConfigAccessPublic},
{name: "hide_footer_brand is public", key: "hide_footer_brand", want: ConfigAccessPublic}, {name: "hide_footer_brand is public", key: "hide_footer_brand", want: ConfigAccessPublic},
@@ -31,7 +33,7 @@ func TestConfigPolicy(t *testing.T) {
} }
func TestConfigPolicyHelpers(t *testing.T) { func TestConfigPolicyHelpers(t *testing.T) {
publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "cloudflare_site_key", "is_commercial", "hide_footer_brand"} publicKeys := []string{"app_name", "app_logo", "app_favicon", "app_bg_image", "app_bg_image_light", "app_bg_image_dark", "cloudflare_site_key", "is_commercial", "hide_footer_brand"}
for _, key := range publicKeys { for _, key := range publicKeys {
if !IsPublicConfigKey(key) { if !IsPublicConfigKey(key) {
t.Fatalf("expected %q to be public", key) t.Fatalf("expected %q to be public", key)
+86
View File
@@ -0,0 +1,86 @@
#!/bin/sh
set -eu
# Run only in a disposable Alpine container: this exercises real OpenRC services.
# docker run --rm -v "$PWD:/workspace:ro" alpine:3.22 sh /workspace/test-install-scripts-alpine.sh
[ -f /etc/alpine-release ] && [ "$(id -u)" = 0 ] || {
echo "Run this test as root in a disposable Alpine container." >&2
exit 1
}
ROOT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
INSTALL_SCRIPT=${1:-"$ROOT_DIR/install.sh"}
TEST_DIR=$(mktemp -d)
export TEST_DIR
cleanup() {
rc-service flux_agent stop >/dev/null 2>&1 || true
rc-update del flux_agent default >/dev/null 2>&1 || true
rm -f /etc/init.d/flux_agent
rm -rf "$TEST_DIR"
}
trap cleanup EXIT
# Docker supplies the network; an empty interfaces file lets OpenRC register
# that dependency without changing the container's network configuration.
apk add --no-cache openrc
mkdir -p /run/openrc /etc/network
touch /run/openrc/softlevel /etc/network/interfaces
rc-service networking start
# Fail immediately if any path accidentally calls systemd on Alpine.
mkdir -p "$TEST_DIR/bin"
cat > "$TEST_DIR/bin/systemctl" <<'EOF'
#!/bin/sh
touch "$TEST_DIR/systemctl-called"
exit 1
EOF
chmod +x "$TEST_DIR/bin/systemctl"
export PATH="$TEST_DIR/bin:$PATH"
cat > "$TEST_DIR/agent" <<'EOF'
#!/bin/sh
if [ "${1:-}" = -V ]; then
echo "Alpine installer test agent"
exit 0
fi
pwd > ../working-directory
exec sleep 300
EOF
# Keep the installer bootstrap, service detection and all lifecycle operations.
# Substitute only the network download and optional tcpkill dependency.
sed '/^# 执行主函数$/,$d' "$INSTALL_SCRIPT" > "$TEST_DIR/install.sh"
cat >> "$TEST_DIR/install.sh" <<'EOF'
INSTALL_DIR="$TEST_DIR/flux_agent"
ensure_download_url_initialized() {
ensure_alpine_runtime_dependencies || return 1
DOWNLOAD_URL="file://$TEST_DIR/agent"
}
check_and_install_tcpkill() { :; }
main
EOF
chmod +x "$TEST_DIR/install.sh"
cp "$TEST_DIR/install.sh" "$TEST_DIR/manage.sh"
PROXY_ENABLED=false "$TEST_DIR/install.sh" -a http://127.0.0.1:9 -s alpine-test
rc-service flux_agent status
test -L /etc/runlevels/default/flux_agent
test "$(cat "$TEST_DIR/working-directory")" = "$TEST_DIR/flux_agent"
test -s "$TEST_DIR/flux_agent/config.json"
rc-service flux_agent restart
rc-service flux_agent status
# Update must retain config and restart through OpenRC.
cp "$TEST_DIR/flux_agent/config.json" "$TEST_DIR/config-before-update.json"
cp "$TEST_DIR/manage.sh" "$TEST_DIR/update.sh"
printf '2\n' | PROXY_ENABLED=false "$TEST_DIR/update.sh"
cmp "$TEST_DIR/config-before-update.json" "$TEST_DIR/flux_agent/config.json"
rc-service flux_agent status
printf '3\ny\n' | "$TEST_DIR/manage.sh"
test ! -e /etc/init.d/flux_agent
test ! -e /etc/runlevels/default/flux_agent
test ! -e "$TEST_DIR/flux_agent"
test ! -e "$TEST_DIR/systemctl-called"
echo "Alpine installer lifecycle tests passed"
+7 -2
View File
@@ -28,6 +28,7 @@ import { isLoggedIn } from "@/utils/auth";
import { siteConfig, updateSiteConfig } from "@/config/site"; import { siteConfig, updateSiteConfig } from "@/config/site";
import { useH5Mode } from "@/hooks/useH5Mode"; import { useH5Mode } from "@/hooks/useH5Mode";
import { SESSION_UPDATED_EVENT } from "@/utils/session"; import { SESSION_UPDATED_EVENT } from "@/utils/session";
import { useThemeContext } from "@/themes/context";
const ProtectedRoute = ({ const ProtectedRoute = ({
children, children,
@@ -79,6 +80,7 @@ const LoginRoute = () => {
function App() { function App() {
const location = useLocation(); const location = useLocation();
const navigate = useNavigate(); const navigate = useNavigate();
const { effectiveMode } = useThemeContext();
// 全局登录状态监听,当检测到未登录且不在首页时,跳转到首页 // 全局登录状态监听,当检测到未登录且不在首页时,跳转到首页
useEffect(() => { useEffect(() => {
@@ -98,7 +100,10 @@ function App() {
// 处理自定义背景图片 // 处理自定义背景图片
useEffect(() => { useEffect(() => {
const updateBg = () => { const updateBg = () => {
const customBg = siteConfig.app_bg_image; const customBg =
(effectiveMode === "dark"
? siteConfig.app_bg_image_dark
: siteConfig.app_bg_image_light) || siteConfig.app_bg_image;
if (customBg) { if (customBg) {
if (customBg === "theme") { if (customBg === "theme") {
@@ -149,7 +154,7 @@ function App() {
return () => { return () => {
window.removeEventListener("site-config-updated", updateBg); window.removeEventListener("site-config-updated", updateBg);
}; };
}, []); }, [effectiveMode]);
// 立即设置页面标题(使用已从缓存读取的配置) // 立即设置页面标题(使用已从缓存读取的配置)
useEffect(() => { useEffect(() => {
+28
View File
@@ -14,6 +14,8 @@ const PUBLIC_BRAND_CONFIG_KEYS = [
"app_logo", "app_logo",
"app_favicon", "app_favicon",
"app_bg_image", "app_bg_image",
"app_bg_image_light",
"app_bg_image_dark",
"is_commercial", "is_commercial",
"hide_footer_brand", "hide_footer_brand",
] as const; ] as const;
@@ -114,6 +116,8 @@ const getInitialConfig = () => {
app_logo: "", app_logo: "",
app_favicon: "", app_favicon: "",
app_bg_image: "", app_bg_image: "",
app_bg_image_light: "",
app_bg_image_dark: "",
is_commercial: false, is_commercial: false,
hide_footer_brand: false, hide_footer_brand: false,
}; };
@@ -127,6 +131,10 @@ const getInitialConfig = () => {
localStorage.getItem(CACHE_PREFIX + "app_favicon") || ""; localStorage.getItem(CACHE_PREFIX + "app_favicon") || "";
const cachedAppBgImage = const cachedAppBgImage =
localStorage.getItem(CACHE_PREFIX + "app_bg_image") || ""; localStorage.getItem(CACHE_PREFIX + "app_bg_image") || "";
const cachedAppBgImageLight =
localStorage.getItem(CACHE_PREFIX + "app_bg_image_light") || "";
const cachedAppBgImageDark =
localStorage.getItem(CACHE_PREFIX + "app_bg_image_dark") || "";
const isCommercial = const isCommercial =
localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true"; localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true";
const hideFooterBrand = const hideFooterBrand =
@@ -141,6 +149,8 @@ const getInitialConfig = () => {
app_logo: isCommercial ? cachedAppLogo : "", app_logo: isCommercial ? cachedAppLogo : "",
app_favicon: isCommercial ? cachedAppFavicon : "", app_favicon: isCommercial ? cachedAppFavicon : "",
app_bg_image: cachedAppBgImage, app_bg_image: cachedAppBgImage,
app_bg_image_light: cachedAppBgImageLight,
app_bg_image_dark: cachedAppBgImageDark,
is_commercial: isCommercial, is_commercial: isCommercial,
hide_footer_brand: isCommercial && hideFooterBrand, hide_footer_brand: isCommercial && hideFooterBrand,
}; };
@@ -154,6 +164,8 @@ const getInitialConfig = () => {
app_logo: isCommercial ? cachedAppLogo : "", app_logo: isCommercial ? cachedAppLogo : "",
app_favicon: isCommercial ? cachedAppFavicon : "", app_favicon: isCommercial ? cachedAppFavicon : "",
app_bg_image: cachedAppBgImage, app_bg_image: cachedAppBgImage,
app_bg_image_light: cachedAppBgImageLight,
app_bg_image_dark: cachedAppBgImageDark,
is_commercial: isCommercial, is_commercial: isCommercial,
hide_footer_brand: isCommercial && hideFooterBrand, hide_footer_brand: isCommercial && hideFooterBrand,
}; };
@@ -382,6 +394,14 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
resolvedConfigMap, resolvedConfigMap,
"app_bg_image", "app_bg_image",
); );
const hasAppBgImageLight = Object.prototype.hasOwnProperty.call(
resolvedConfigMap,
"app_bg_image_light",
);
const hasAppBgImageDark = Object.prototype.hasOwnProperty.call(
resolvedConfigMap,
"app_bg_image_dark",
);
const resolvedCommercial = Object.prototype.hasOwnProperty.call( const resolvedCommercial = Object.prototype.hasOwnProperty.call(
resolvedConfigMap, resolvedConfigMap,
@@ -401,11 +421,19 @@ export const updateSiteConfig = async (configMap?: Record<string, string>) => {
const appBgImage = hasAppBgImage const appBgImage = hasAppBgImage
? String(resolvedConfigMap.app_bg_image || "").trim() ? String(resolvedConfigMap.app_bg_image || "").trim()
: (siteConfig.app_bg_image || "").trim(); : (siteConfig.app_bg_image || "").trim();
const appBgImageLight = hasAppBgImageLight
? String(resolvedConfigMap.app_bg_image_light || "").trim()
: (siteConfig.app_bg_image_light || "").trim();
const appBgImageDark = hasAppBgImageDark
? String(resolvedConfigMap.app_bg_image_dark || "").trim()
: (siteConfig.app_bg_image_dark || "").trim();
siteConfig.name = resolvedCommercial && appName ? appName : "FLVX"; siteConfig.name = resolvedCommercial && appName ? appName : "FLVX";
siteConfig.app_logo = resolvedCommercial ? appLogo : ""; siteConfig.app_logo = resolvedCommercial ? appLogo : "";
siteConfig.app_favicon = resolvedCommercial ? appFavicon : ""; siteConfig.app_favicon = resolvedCommercial ? appFavicon : "";
siteConfig.app_bg_image = appBgImage; siteConfig.app_bg_image = appBgImage;
siteConfig.app_bg_image_light = appBgImageLight;
siteConfig.app_bg_image_dark = appBgImageDark;
if ( if (
Object.prototype.hasOwnProperty.call(resolvedConfigMap, "is_commercial") Object.prototype.hasOwnProperty.call(resolvedConfigMap, "is_commercial")
) { ) {
+80 -37
View File
@@ -90,6 +90,8 @@ interface ConfigItem {
dependsValue?: string; // 依赖的配置项值 dependsValue?: string; // 依赖的配置项值
} }
type BgImageKey = "app_bg_image" | "app_bg_image_light" | "app_bg_image_dark";
const BRAND_PREVIEW_KEYS = ["app_logo", "app_favicon"] as const; const BRAND_PREVIEW_KEYS = ["app_logo", "app_favicon"] as const;
type BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number]; type BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number];
@@ -107,9 +109,9 @@ const toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => {
const CONFIG_ITEMS: ConfigItem[] = [ const CONFIG_ITEMS: ConfigItem[] = [
{ {
key: "app_bg_image", key: "app_bg_image",
label: "自定义背景", label: "背景壁纸",
description: description:
"上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)", "默认背景用于未单独设置壁纸的模式。可分别上传亮色和暗色壁纸,保存后随外观模式自动切换。",
type: "bg_image", type: "bg_image",
}, },
{ {
@@ -263,6 +265,9 @@ const getInitialConfigs = (): Record<string, string> => {
"panel_domain", "panel_domain",
"app_logo", "app_logo",
"app_favicon", "app_favicon",
"app_bg_image",
"app_bg_image_light",
"app_bg_image_dark",
"github_proxy_enabled", "github_proxy_enabled",
"github_proxy_url", "github_proxy_url",
"allow_local_remote_addr", "allow_local_remote_addr",
@@ -312,8 +317,12 @@ export default function ConfigPage() {
const logoFileInputRef = useRef<HTMLInputElement>(null); const logoFileInputRef = useRef<HTMLInputElement>(null);
const faviconFileInputRef = useRef<HTMLInputElement>(null); const faviconFileInputRef = useRef<HTMLInputElement>(null);
const bgImageFileInputRef = useRef<HTMLInputElement>(null); const bgImageFileInputRefs = useRef<
const [bgImageUploading, setBgImageUploading] = useState(false); Partial<Record<BgImageKey, HTMLInputElement>>
>({});
const [bgImageUploading, setBgImageUploading] = useState<BgImageKey | null>(
null,
);
const [announcement, setAnnouncement] = useState<AnnouncementData>({ const [announcement, setAnnouncement] = useState<AnnouncementData>({
content: "", content: "",
@@ -689,7 +698,14 @@ export default function ConfigPage() {
if ( if (
changedKeys.some((key) => changedKeys.some((key) =>
["app_name", "app_logo", "app_favicon"].includes(key), [
"app_name",
"app_logo",
"app_favicon",
"app_bg_image",
"app_bg_image_light",
"app_bg_image_dark",
].includes(key),
) )
) { ) {
await updateSiteConfig(configs); await updateSiteConfig(configs);
@@ -800,6 +816,7 @@ export default function ConfigPage() {
const handleBgImageUpload = async ( const handleBgImageUpload = async (
e: React.ChangeEvent<HTMLInputElement>, e: React.ChangeEvent<HTMLInputElement>,
key: BgImageKey,
) => { ) => {
const file = e.target.files?.[0]; const file = e.target.files?.[0];
@@ -811,7 +828,7 @@ export default function ConfigPage() {
return; return;
} }
setBgImageUploading(true); setBgImageUploading(key);
try { try {
const compressedImage = await new Promise<string>((resolve, reject) => { const compressedImage = await new Promise<string>((resolve, reject) => {
const reader = new FileReader(); const reader = new FileReader();
@@ -861,18 +878,19 @@ export default function ConfigPage() {
reader.readAsDataURL(file); reader.readAsDataURL(file);
}); });
handleConfigChange("app_bg_image", compressedImage); handleConfigChange(key, compressedImage);
toast.success("自定义背景上传成功"); toast.success("壁纸上传成功,保存配置后生效");
} catch (error) { } catch (error) {
toast.error(error instanceof Error ? error.message : "图片处理失败"); toast.error(error instanceof Error ? error.message : "图片处理失败");
} finally { } finally {
setBgImageUploading(false); setBgImageUploading(null);
e.target.value = ""; e.target.value = "";
} }
}; };
const renderBgImageUploader = () => { const renderBgImageUploader = (key: BgImageKey, label: string) => {
const bgImage = configs["app_bg_image"] || ""; const bgImage = configs[key] || "";
const isDefault = key === "app_bg_image";
const isImage = const isImage =
bgImage.startsWith("http") || bgImage.startsWith("http") ||
bgImage.startsWith("data:") || bgImage.startsWith("data:") ||
@@ -882,47 +900,64 @@ export default function ConfigPage() {
const isSolidColor = bgImage && !isImage && !isTheme; const isSolidColor = bgImage && !isImage && !isTheme;
return ( return (
<div className="flex flex-col gap-4 w-full"> <div className="flex flex-col gap-3 w-full rounded-xl border border-divider p-4">
<div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
{label}
</p>
{!isDefault && !bgImage && (
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
未单独设置,使用默认背景
</p>
)}
</div>
<div className="flex flex-wrap items-center gap-4"> <div className="flex flex-wrap items-center gap-4">
<input <input
ref={bgImageFileInputRef} ref={(node) => {
if (node) bgImageFileInputRefs.current[key] = node;
}}
accept="image/*" accept="image/*"
className="hidden" className="hidden"
type="file" type="file"
onChange={handleBgImageUpload} onChange={(event) => void handleBgImageUpload(event, key)}
/> />
<Button <Button
color="primary" color="primary"
isLoading={bgImageUploading} isDisabled={bgImageUploading !== null && bgImageUploading !== key}
isLoading={bgImageUploading === key}
variant="flat" variant="flat"
onPress={() => bgImageFileInputRef.current?.click()} onPress={() => bgImageFileInputRefs.current[key]?.click()}
> >
上传图片 上传图片
</Button> </Button>
<Button {isDefault && (
color="secondary" <Button
isDisabled={bgImageUploading || isTheme} color="secondary"
variant="flat" isDisabled={bgImageUploading !== null || isTheme}
onPress={() => handleConfigChange("app_bg_image", "theme")} variant="flat"
> onPress={() => handleConfigChange(key, "theme")}
自适应纯色 (跟随深色模式) >
</Button> 自适应纯色 (跟随深色模式)
<Button </Button>
color="default" )}
isDisabled={bgImageUploading || bgImage === "#ffffff"} {isDefault && (
variant="flat" <Button
onPress={() => handleConfigChange("app_bg_image", "#ffffff")} color="default"
> isDisabled={bgImageUploading !== null || bgImage === "#ffffff"}
白色纯色 variant="flat"
</Button> onPress={() => handleConfigChange(key, "#ffffff")}
>
白色纯色
</Button>
)}
{bgImage && ( {bgImage && (
<Button <Button
color="danger" color="danger"
isDisabled={bgImageUploading} isDisabled={bgImageUploading !== null}
variant="flat" variant="flat"
onPress={() => handleConfigChange("app_bg_image", "")} onPress={() => handleConfigChange(key, "")}
> >
恢复默认 {isDefault ? "恢复内置背景" : "清除专属壁纸"}
</Button> </Button>
)} )}
</div> </div>
@@ -930,7 +965,7 @@ export default function ConfigPage() {
{bgImage && isImage && ( {bgImage && isImage && (
<div className="relative rounded-xl overflow-hidden border border-divider"> <div className="relative rounded-xl overflow-hidden border border-divider">
<img <img
alt="背景预览" alt={`${label}预览`}
className="w-full max-h-48 object-cover opacity-80" className="w-full max-h-48 object-cover opacity-80"
src={bgImage} src={bgImage}
/> />
@@ -1118,7 +1153,15 @@ export default function ConfigPage() {
switch (item.type) { switch (item.type) {
case "bg_image": case "bg_image":
return renderBgImageUploader(); return (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<div className="lg:col-span-2">
{renderBgImageUploader("app_bg_image", "默认背景")}
</div>
{renderBgImageUploader("app_bg_image_light", "☀️ 亮色模式壁纸")}
{renderBgImageUploader("app_bg_image_dark", "🌙 暗色模式壁纸")}
</div>
);
case "input": { case "input": {
if (isBrandPreviewKey(item.key)) { if (isBrandPreviewKey(item.key)) {