From 62c56e0a79a6733f9de0055a3ffc143c9c13500e Mon Sep 17 00:00:00 2001 From: sagitchu Date: Thu, 24 Sep 2026 14:59:52 +0800 Subject: [PATCH] feat: add mode-specific wallpapers and installer regression coverage --- .github/workflows/ci-build.yml | 12 ++ .github/workflows/docker-build.yml | 3 + .../http/handler/config_access_test.go | 12 ++ .../internal/store/repo/config_policy.go | 2 + .../internal/store/repo/config_policy_test.go | 4 +- test-install-scripts-alpine.sh | 86 +++++++++++++ vite-frontend/src/App.tsx | 9 +- vite-frontend/src/config/site.ts | 28 +++++ vite-frontend/src/pages/config.tsx | 117 ++++++++++++------ 9 files changed, 233 insertions(+), 40 deletions(-) create mode 100644 test-install-scripts-alpine.sh diff --git a/.github/workflows/ci-build.yml b/.github/workflows/ci-build.yml index 4cc891d..d92ebe4 100644 --- a/.github/workflows/ci-build.yml +++ b/.github/workflows/ci-build.yml @@ -7,6 +7,18 @@ on: branches: ['**'] 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: name: Build Frontend runs-on: ubuntu-latest diff --git a/.github/workflows/docker-build.yml b/.github/workflows/docker-build.yml index 5373701..1abb921 100644 --- a/.github/workflows/docker-build.yml +++ b/.github/workflows/docker-build.yml @@ -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/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 env: GH_TOKEN: ${{ github.token }} diff --git a/go-backend/internal/http/handler/config_access_test.go b/go-backend/internal/http/handler/config_access_test.go index 8f804cc..691a618 100644 --- a/go-backend/internal/http/handler/config_access_test.go +++ b/go-backend/internal/http/handler/config_access_test.go @@ -19,6 +19,8 @@ func TestPublicConfigGetAllowsBrandKeys(t *testing.T) { seedConfigValue(t, r, "app_logo", "logo-data") seedConfigValue(t, r, "app_favicon", "favicon-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") 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) 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) { diff --git a/go-backend/internal/store/repo/config_policy.go b/go-backend/internal/store/repo/config_policy.go index 9792559..095450c 100644 --- a/go-backend/internal/store/repo/config_policy.go +++ b/go-backend/internal/store/repo/config_policy.go @@ -14,6 +14,8 @@ var publicConfigKeys = map[string]struct{}{ "app_logo": {}, "app_favicon": {}, "app_bg_image": {}, + "app_bg_image_light": {}, + "app_bg_image_dark": {}, "cloudflare_site_key": {}, "is_commercial": {}, "hide_footer_brand": {}, diff --git a/go-backend/internal/store/repo/config_policy_test.go b/go-backend/internal/store/repo/config_policy_test.go index 0e18d9b..e301dbf 100644 --- a/go-backend/internal/store/repo/config_policy_test.go +++ b/go-backend/internal/store/repo/config_policy_test.go @@ -12,6 +12,8 @@ func TestConfigPolicy(t *testing.T) { {name: "app_logo is public", key: "app_logo", 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_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: "is_commercial is public", key: "is_commercial", 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) { - 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 { if !IsPublicConfigKey(key) { t.Fatalf("expected %q to be public", key) diff --git a/test-install-scripts-alpine.sh b/test-install-scripts-alpine.sh new file mode 100644 index 0000000..8291018 --- /dev/null +++ b/test-install-scripts-alpine.sh @@ -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" diff --git a/vite-frontend/src/App.tsx b/vite-frontend/src/App.tsx index 210e70d..27c837b 100644 --- a/vite-frontend/src/App.tsx +++ b/vite-frontend/src/App.tsx @@ -28,6 +28,7 @@ import { isLoggedIn } from "@/utils/auth"; import { siteConfig, updateSiteConfig } from "@/config/site"; import { useH5Mode } from "@/hooks/useH5Mode"; import { SESSION_UPDATED_EVENT } from "@/utils/session"; +import { useThemeContext } from "@/themes/context"; const ProtectedRoute = ({ children, @@ -79,6 +80,7 @@ const LoginRoute = () => { function App() { const location = useLocation(); const navigate = useNavigate(); + const { effectiveMode } = useThemeContext(); // 全局登录状态监听,当检测到未登录且不在首页时,跳转到首页 useEffect(() => { @@ -98,7 +100,10 @@ function App() { // 处理自定义背景图片 useEffect(() => { 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 === "theme") { @@ -149,7 +154,7 @@ function App() { return () => { window.removeEventListener("site-config-updated", updateBg); }; - }, []); + }, [effectiveMode]); // 立即设置页面标题(使用已从缓存读取的配置) useEffect(() => { diff --git a/vite-frontend/src/config/site.ts b/vite-frontend/src/config/site.ts index c9b27bc..e631d55 100644 --- a/vite-frontend/src/config/site.ts +++ b/vite-frontend/src/config/site.ts @@ -14,6 +14,8 @@ const PUBLIC_BRAND_CONFIG_KEYS = [ "app_logo", "app_favicon", "app_bg_image", + "app_bg_image_light", + "app_bg_image_dark", "is_commercial", "hide_footer_brand", ] as const; @@ -114,6 +116,8 @@ const getInitialConfig = () => { app_logo: "", app_favicon: "", app_bg_image: "", + app_bg_image_light: "", + app_bg_image_dark: "", is_commercial: false, hide_footer_brand: false, }; @@ -127,6 +131,10 @@ const getInitialConfig = () => { localStorage.getItem(CACHE_PREFIX + "app_favicon") || ""; const cachedAppBgImage = 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 = localStorage.getItem(CACHE_PREFIX + "is_commercial") === "true"; const hideFooterBrand = @@ -141,6 +149,8 @@ const getInitialConfig = () => { app_logo: isCommercial ? cachedAppLogo : "", app_favicon: isCommercial ? cachedAppFavicon : "", app_bg_image: cachedAppBgImage, + app_bg_image_light: cachedAppBgImageLight, + app_bg_image_dark: cachedAppBgImageDark, is_commercial: isCommercial, hide_footer_brand: isCommercial && hideFooterBrand, }; @@ -154,6 +164,8 @@ const getInitialConfig = () => { app_logo: isCommercial ? cachedAppLogo : "", app_favicon: isCommercial ? cachedAppFavicon : "", app_bg_image: cachedAppBgImage, + app_bg_image_light: cachedAppBgImageLight, + app_bg_image_dark: cachedAppBgImageDark, is_commercial: isCommercial, hide_footer_brand: isCommercial && hideFooterBrand, }; @@ -382,6 +394,14 @@ export const updateSiteConfig = async (configMap?: Record) => { resolvedConfigMap, "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( resolvedConfigMap, @@ -401,11 +421,19 @@ export const updateSiteConfig = async (configMap?: Record) => { const appBgImage = hasAppBgImage ? String(resolvedConfigMap.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.app_logo = resolvedCommercial ? appLogo : ""; siteConfig.app_favicon = resolvedCommercial ? appFavicon : ""; siteConfig.app_bg_image = appBgImage; + siteConfig.app_bg_image_light = appBgImageLight; + siteConfig.app_bg_image_dark = appBgImageDark; if ( Object.prototype.hasOwnProperty.call(resolvedConfigMap, "is_commercial") ) { diff --git a/vite-frontend/src/pages/config.tsx b/vite-frontend/src/pages/config.tsx index 2e763f2..b4a3c86 100644 --- a/vite-frontend/src/pages/config.tsx +++ b/vite-frontend/src/pages/config.tsx @@ -90,6 +90,8 @@ interface ConfigItem { 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; type BrandPreviewKey = (typeof BRAND_PREVIEW_KEYS)[number]; @@ -107,9 +109,9 @@ const toBrandAssetKind = (key: BrandPreviewKey): BrandAssetKind => { const CONFIG_ITEMS: ConfigItem[] = [ { key: "app_bg_image", - label: "自定义背景", + label: "背景壁纸", description: - "上传自定义背景图片(建议使用深色/浅色均可看清的图片,或使用半透明模糊效果)", + "默认背景用于未单独设置壁纸的模式。可分别上传亮色和暗色壁纸,保存后随外观模式自动切换。", type: "bg_image", }, { @@ -263,6 +265,9 @@ const getInitialConfigs = (): Record => { "panel_domain", "app_logo", "app_favicon", + "app_bg_image", + "app_bg_image_light", + "app_bg_image_dark", "github_proxy_enabled", "github_proxy_url", "allow_local_remote_addr", @@ -312,8 +317,12 @@ export default function ConfigPage() { const logoFileInputRef = useRef(null); const faviconFileInputRef = useRef(null); - const bgImageFileInputRef = useRef(null); - const [bgImageUploading, setBgImageUploading] = useState(false); + const bgImageFileInputRefs = useRef< + Partial> + >({}); + const [bgImageUploading, setBgImageUploading] = useState( + null, + ); const [announcement, setAnnouncement] = useState({ content: "", @@ -689,7 +698,14 @@ export default function ConfigPage() { if ( 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); @@ -800,6 +816,7 @@ export default function ConfigPage() { const handleBgImageUpload = async ( e: React.ChangeEvent, + key: BgImageKey, ) => { const file = e.target.files?.[0]; @@ -811,7 +828,7 @@ export default function ConfigPage() { return; } - setBgImageUploading(true); + setBgImageUploading(key); try { const compressedImage = await new Promise((resolve, reject) => { const reader = new FileReader(); @@ -861,18 +878,19 @@ export default function ConfigPage() { reader.readAsDataURL(file); }); - handleConfigChange("app_bg_image", compressedImage); - toast.success("自定义背景上传成功"); + handleConfigChange(key, compressedImage); + toast.success("壁纸上传成功,保存配置后生效"); } catch (error) { toast.error(error instanceof Error ? error.message : "图片处理失败"); } finally { - setBgImageUploading(false); + setBgImageUploading(null); e.target.value = ""; } }; - const renderBgImageUploader = () => { - const bgImage = configs["app_bg_image"] || ""; + const renderBgImageUploader = (key: BgImageKey, label: string) => { + const bgImage = configs[key] || ""; + const isDefault = key === "app_bg_image"; const isImage = bgImage.startsWith("http") || bgImage.startsWith("data:") || @@ -882,47 +900,64 @@ export default function ConfigPage() { const isSolidColor = bgImage && !isImage && !isTheme; return ( -
+
+
+

+ {label} +

+ {!isDefault && !bgImage && ( +

+ 未单独设置,使用默认背景 +

+ )} +
{ + if (node) bgImageFileInputRefs.current[key] = node; + }} accept="image/*" className="hidden" type="file" - onChange={handleBgImageUpload} + onChange={(event) => void handleBgImageUpload(event, key)} /> - - + {isDefault && ( + + )} + {isDefault && ( + + )} {bgImage && ( )}
@@ -930,7 +965,7 @@ export default function ConfigPage() { {bgImage && isImage && (
背景预览 @@ -1118,7 +1153,15 @@ export default function ConfigPage() { switch (item.type) { case "bg_image": - return renderBgImageUploader(); + return ( +
+
+ {renderBgImageUploader("app_bg_image", "默认背景")} +
+ {renderBgImageUploader("app_bg_image_light", "☀️ 亮色模式壁纸")} + {renderBgImageUploader("app_bg_image_dark", "🌙 暗色模式壁纸")} +
+ ); case "input": { if (isBrandPreviewKey(item.key)) {