mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
feat: add mode-specific wallpapers and installer regression coverage
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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(() => {
|
||||||
|
|||||||
@@ -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")
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -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)) {
|
||||||
|
|||||||
Reference in New Issue
Block a user