mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-09-28 07:36:38 +08:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 62c56e0a79 | |||
| 2e845030de | |||
| 2269f2e2d5 | |||
| da7bef88f1 |
@@ -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
|
||||
@@ -22,7 +34,7 @@ jobs:
|
||||
node-version: '20.19.0'
|
||||
|
||||
- name: Install pnpm
|
||||
run: npm install -g pnpm
|
||||
run: npm install -g pnpm@10.28.1
|
||||
|
||||
- name: Install dependencies
|
||||
run: pnpm install --frozen-lockfile
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -46,7 +46,8 @@ func RenderTable(plan NodePlan) string {
|
||||
}
|
||||
targetHost := strings.Trim(strings.TrimSpace(rule.TargetHost), "[]")
|
||||
for _, protocol := range normalizedProtocols(rule.Protocols) {
|
||||
b.WriteString(fmt.Sprintf(" ct original proto-dst %d %s daddr %s %s dport %d counter comment %q\n",
|
||||
b.WriteString(fmt.Sprintf(" meta l4proto %s ct original proto-dst %d %s daddr %s %s dport %d counter comment %q\n",
|
||||
protocol,
|
||||
rule.InPort,
|
||||
family,
|
||||
targetHost,
|
||||
@@ -54,7 +55,8 @@ func RenderTable(plan NodePlan) string {
|
||||
rule.TargetPort,
|
||||
counterComment(rule.ForwardID, CounterDirectionToTarget, protocol),
|
||||
))
|
||||
b.WriteString(fmt.Sprintf(" ct original proto-dst %d %s saddr %s %s sport %d counter comment %q\n",
|
||||
b.WriteString(fmt.Sprintf(" meta l4proto %s ct original proto-dst %d %s saddr %s %s sport %d counter comment %q\n",
|
||||
protocol,
|
||||
rule.InPort,
|
||||
family,
|
||||
targetHost,
|
||||
|
||||
@@ -62,10 +62,10 @@ func TestRenderTableIncludesForwardAccountingCounters(t *testing.T) {
|
||||
wantLines := []string{
|
||||
`tcp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat tcp"`,
|
||||
`udp dport 12345 counter dnat ip to 198.51.100.20:443 comment "flvx forward:42 dnat udp"`,
|
||||
`ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`,
|
||||
`ct original proto-dst 12345 ip saddr 198.51.100.20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"`,
|
||||
`ct original proto-dst 12345 ip daddr 198.51.100.20 udp dport 443 counter comment "flvx forward:42 to-target udp"`,
|
||||
`ct original proto-dst 12345 ip saddr 198.51.100.20 udp sport 443 counter comment "flvx forward:42 from-target udp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12345 ip saddr 198.51.100.20 tcp sport 443 counter comment "flvx forward:42 from-target tcp"`,
|
||||
`meta l4proto udp ct original proto-dst 12345 ip daddr 198.51.100.20 udp dport 443 counter comment "flvx forward:42 to-target udp"`,
|
||||
`meta l4proto udp ct original proto-dst 12345 ip saddr 198.51.100.20 udp sport 443 counter comment "flvx forward:42 from-target udp"`,
|
||||
}
|
||||
for _, want := range wantLines {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -89,8 +89,8 @@ func TestRenderTableIncludesIPv6ForwardAccountingCounters(t *testing.T) {
|
||||
got := RenderTable(plan)
|
||||
wantLines := []string{
|
||||
`tcp dport 12346 counter dnat ip6 to [2001:db8::20]:8443 comment "flvx forward:43 dnat tcp"`,
|
||||
`ct original proto-dst 12346 ip6 daddr 2001:db8::20 tcp dport 8443 counter comment "flvx forward:43 to-target tcp"`,
|
||||
`ct original proto-dst 12346 ip6 saddr 2001:db8::20 tcp sport 8443 counter comment "flvx forward:43 from-target tcp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12346 ip6 daddr 2001:db8::20 tcp dport 8443 counter comment "flvx forward:43 to-target tcp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12346 ip6 saddr 2001:db8::20 tcp sport 8443 counter comment "flvx forward:43 from-target tcp"`,
|
||||
}
|
||||
for _, want := range wantLines {
|
||||
if !strings.Contains(got, want) {
|
||||
@@ -110,8 +110,8 @@ func TestRenderTableAccountingCountersIncludeOriginalPort(t *testing.T) {
|
||||
|
||||
got := RenderTable(plan)
|
||||
wantLines := []string{
|
||||
`ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`,
|
||||
`ct original proto-dst 12346 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:43 to-target tcp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12345 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:42 to-target tcp"`,
|
||||
`meta l4proto tcp ct original proto-dst 12346 ip daddr 198.51.100.20 tcp dport 443 counter comment "flvx forward:43 to-target tcp"`,
|
||||
}
|
||||
for _, want := range wantLines {
|
||||
if !strings.Contains(got, want) {
|
||||
|
||||
@@ -26,23 +26,58 @@ func NewSSHRunner() *SSHRunner {
|
||||
}
|
||||
|
||||
func (r *SSHRunner) Test(ctx context.Context, cfg SSHConfig) error {
|
||||
return r.run(ctx, cfg, "command -v nft >/dev/null 2>&1 && nft --version >/dev/null 2>&1")
|
||||
nft := nftBinary(cfg)
|
||||
tableName := fmt.Sprintf("flvx_capability_%d", time.Now().UnixNano())
|
||||
return r.run(ctx, cfg, buildCapabilityCheckCommand(nft, tableName))
|
||||
}
|
||||
|
||||
func buildCapabilityCheckCommand(nft, tableName string) string {
|
||||
script := RenderTable(NodePlan{
|
||||
Rules: []Rule{{
|
||||
ForwardID: 1,
|
||||
InPort: 12345,
|
||||
TargetHost: "192.0.2.1",
|
||||
TargetPort: 443,
|
||||
Protocols: []string{"tcp", "udp"},
|
||||
}},
|
||||
})
|
||||
script = strings.Replace(script, "table inet flvx {", "table inet "+tableName+" {", 1)
|
||||
return "set -eu\n" +
|
||||
"command -v nft >/dev/null 2>&1\n" +
|
||||
nft + " --version >/dev/null 2>&1\n" +
|
||||
"tmp=$(mktemp /tmp/flvx-nft-capability-XXXXXX.nft)\n" +
|
||||
"trap 'rm -f \"$tmp\"' EXIT\n" +
|
||||
"cat > \"$tmp\" <<'EOF'\n" + script + "\nEOF\n" +
|
||||
"if ! " + nft + " -c -f \"$tmp\"; then\n" +
|
||||
" echo 'nftables cannot validate the generated FLVX rules' >&2\n" +
|
||||
" exit 1\n" +
|
||||
"fi"
|
||||
}
|
||||
|
||||
func (r *SSHRunner) ApplyScript(ctx context.Context, cfg SSHConfig, script string) error {
|
||||
nft := nftBinary(cfg)
|
||||
command := "tmp=$(mktemp /tmp/flvx-nft-XXXXXX.nft) || exit 1\n" +
|
||||
command := buildApplyCommand(nftBinary(cfg), script)
|
||||
return r.run(ctx, cfg, command)
|
||||
}
|
||||
|
||||
func buildApplyCommand(nft, script string) string {
|
||||
return "set -eu\n" +
|
||||
"tmp=$(mktemp /tmp/flvx-nft-XXXXXX.nft)\n" +
|
||||
"batch=$(mktemp /tmp/flvx-nft-batch-XXXXXX.nft) || { rm -f \"$tmp\"; exit 1; }\n" +
|
||||
"cleanup() {\n" +
|
||||
" rm -f \"$tmp\"\n" +
|
||||
" rm -f \"$tmp\" \"$batch\"\n" +
|
||||
"}\n" +
|
||||
"trap cleanup EXIT\n" +
|
||||
"cat > \"$tmp\" <<'EOF'\n" + script + "\nEOF\n" +
|
||||
nft + " -c -f \"$tmp\"\n" +
|
||||
"if " + nft + " list table inet flvx >/dev/null 2>&1; then\n" +
|
||||
" " + nft + " delete table inet flvx\n" +
|
||||
" { printf '%s\\n' 'delete table inet flvx'; cat \"$tmp\"; } > \"$batch\"\n" +
|
||||
"else\n" +
|
||||
" cp \"$tmp\" \"$batch\"\n" +
|
||||
"fi\n" +
|
||||
nft + " -f \"$tmp\""
|
||||
return r.run(ctx, cfg, command)
|
||||
"if ! " + nft + " -c -f \"$batch\"; then\n" +
|
||||
" echo 'nftables rule validation failed; active rules were preserved' >&2\n" +
|
||||
" exit 1\n" +
|
||||
"fi\n" +
|
||||
nft + " -f \"$batch\""
|
||||
}
|
||||
|
||||
func (r *SSHRunner) ListTableJSON(ctx context.Context, cfg SSHConfig) ([]byte, error) {
|
||||
|
||||
@@ -5,10 +5,113 @@ import (
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/pem"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestBuildApplyCommandStopsAfterValidationFailure(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
applyMarker := filepath.Join(dir, "applied")
|
||||
nftPath := filepath.Join(dir, "nft")
|
||||
fake := `#!/bin/sh
|
||||
echo "$*" >> "` + logPath + `"
|
||||
if [ "$1" = "list" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-c" ]; then
|
||||
exit 1
|
||||
fi
|
||||
touch "` + applyMarker + `"
|
||||
`
|
||||
if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil {
|
||||
t.Fatalf("write fake nft: %v", err)
|
||||
}
|
||||
|
||||
command := buildApplyCommand(nftPath, "table inet flvx { }")
|
||||
result := exec.Command("sh", "-c", command)
|
||||
if err := result.Run(); err == nil {
|
||||
t.Fatal("expected validation failure")
|
||||
}
|
||||
if _, err := os.Stat(applyMarker); !os.IsNotExist(err) {
|
||||
t.Fatalf("apply ran after validation failure, stat err=%v", err)
|
||||
}
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fake nft calls: %v", err)
|
||||
}
|
||||
if strings.Count(string(calls), "-f ") != 1 {
|
||||
t.Fatalf("expected validation only, got calls:\n%s", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildCapabilityCheckCommandValidatesRenderedRulesWithoutApplying(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
logPath := filepath.Join(dir, "calls.log")
|
||||
nftPath := filepath.Join(dir, "nft")
|
||||
fake := `#!/bin/sh
|
||||
echo "$*" >> "` + logPath + `"
|
||||
if [ "$1" = "--version" ] || [ "$1" = "-c" ]; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
`
|
||||
if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil {
|
||||
t.Fatalf("write fake nft: %v", err)
|
||||
}
|
||||
|
||||
command := strings.Replace(buildCapabilityCheckCommand(nftPath, "flvx_capability_test"), "command -v nft", "command -v "+nftPath, 1)
|
||||
result := exec.Command("sh", "-c", command)
|
||||
if output, err := result.CombinedOutput(); err != nil {
|
||||
t.Fatalf("capability command failed: %v: %s", err, output)
|
||||
}
|
||||
calls, err := os.ReadFile(logPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read fake nft calls: %v", err)
|
||||
}
|
||||
if strings.Count(string(calls), "-c -f ") != 1 || strings.Contains(string(calls), "\n-f ") {
|
||||
t.Fatalf("expected one check-only invocation, got calls:\n%s", calls)
|
||||
}
|
||||
if !strings.Contains(command, "table inet flvx_capability_test") || !strings.Contains(command, "meta l4proto tcp ct original proto-dst") {
|
||||
t.Fatalf("capability check does not contain representative rendered rules:\n%s", command)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildApplyCommandUsesAtomicReplacementBatch(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
batchPath := filepath.Join(dir, "batch.nft")
|
||||
nftPath := filepath.Join(dir, "nft")
|
||||
fake := `#!/bin/sh
|
||||
if [ "$1" = "list" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if [ "$1" = "-f" ]; then
|
||||
cp "$2" "` + batchPath + `"
|
||||
fi
|
||||
exit 0
|
||||
`
|
||||
if err := os.WriteFile(nftPath, []byte(fake), 0o755); err != nil {
|
||||
t.Fatalf("write fake nft: %v", err)
|
||||
}
|
||||
|
||||
script := "table inet flvx {\n chain forward { }\n}"
|
||||
result := exec.Command("sh", "-c", buildApplyCommand(nftPath, script))
|
||||
if output, err := result.CombinedOutput(); err != nil {
|
||||
t.Fatalf("apply command failed: %v: %s", err, output)
|
||||
}
|
||||
batch, err := os.ReadFile(batchPath)
|
||||
if err != nil {
|
||||
t.Fatalf("read applied batch: %v", err)
|
||||
}
|
||||
want := "delete table inet flvx\n" + script + "\n"
|
||||
if string(batch) != want {
|
||||
t.Fatalf("atomic batch = %q, want %q", batch, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAuthMethodsDefaultToPrivateKey(t *testing.T) {
|
||||
privateKey := mustGeneratePrivateKey(t)
|
||||
methods, err := authMethods(SSHConfig{PrivateKey: privateKey})
|
||||
|
||||
@@ -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": {},
|
||||
|
||||
@@ -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)
|
||||
|
||||
+319
-48
@@ -16,6 +16,9 @@ PINNED_VERSION=""
|
||||
# 镜像加速配置(可由面板传入或交互式询问)
|
||||
PROXY_ENABLED="${PROXY_ENABLED:-}"
|
||||
PROXY_URL="${PROXY_URL:-}"
|
||||
DEFAULT_PANEL_BACKEND_CONTAINER="flux-panel-backend"
|
||||
DEFAULT_PANEL_POSTGRES_CONTAINER="flux-panel-postgres"
|
||||
PANEL_SCRIPT_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")"
|
||||
|
||||
# 镜像加速
|
||||
maybe_proxy_url() {
|
||||
@@ -304,8 +307,8 @@ get_env_var() {
|
||||
get_current_db_type() {
|
||||
local db_type database_url
|
||||
|
||||
db_type=$(get_env_var "DB_TYPE")
|
||||
database_url=$(get_env_var "DATABASE_URL")
|
||||
db_type=$(get_env_var "DB_TYPE" || true)
|
||||
database_url=$(get_env_var "DATABASE_URL" || true)
|
||||
|
||||
if [[ "$db_type" == "sqlite" ]]; then
|
||||
echo "sqlite"
|
||||
@@ -316,13 +319,289 @@ get_current_db_type() {
|
||||
fi
|
||||
}
|
||||
|
||||
get_container_compose_label() {
|
||||
local container="$1"
|
||||
local label="$2"
|
||||
local value
|
||||
|
||||
value=$(docker inspect -f "{{ index .Config.Labels \"$label\" }}" "$container" 2>/dev/null || true)
|
||||
if [[ "$value" == "<no value>" ]]; then
|
||||
value=""
|
||||
fi
|
||||
printf '%s' "$value"
|
||||
}
|
||||
|
||||
resolve_panel_deployment() {
|
||||
local backend_container="${PANEL_BACKEND_CONTAINER:-$DEFAULT_PANEL_BACKEND_CONTAINER}"
|
||||
local requested_dir="${PANEL_DEPLOY_DIR:-}"
|
||||
local label_dir label_project deploy_dir project_name
|
||||
|
||||
label_dir=$(get_container_compose_label "$backend_container" "com.docker.compose.project.working_dir")
|
||||
label_project=$(get_container_compose_label "$backend_container" "com.docker.compose.project")
|
||||
|
||||
if [[ -n "$requested_dir" ]]; then
|
||||
deploy_dir="$requested_dir"
|
||||
elif [[ -n "$label_dir" ]]; then
|
||||
deploy_dir="$label_dir"
|
||||
elif [[ -f ".env" && -f "docker-compose.yml" ]]; then
|
||||
deploy_dir=$(pwd -P)
|
||||
else
|
||||
echo "❌ 无法识别面板部署目录:未找到容器 Compose 标签,当前目录也没有完整部署配置"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ ! -d "$deploy_dir" ]]; then
|
||||
echo "❌ 面板部署目录不存在:$deploy_dir"
|
||||
return 1
|
||||
fi
|
||||
deploy_dir=$(cd "$deploy_dir" && pwd -P)
|
||||
if [[ -n "$label_dir" && -d "$label_dir" ]]; then
|
||||
label_dir=$(cd "$label_dir" && pwd -P)
|
||||
fi
|
||||
if [[ -n "$requested_dir" && -n "$label_dir" && "$deploy_dir" != "$label_dir" ]]; then
|
||||
echo "❌ 指定部署目录与运行中容器标签不一致:$deploy_dir != $label_dir"
|
||||
return 1
|
||||
fi
|
||||
|
||||
project_name="${COMPOSE_PROJECT_NAME:-}"
|
||||
if [[ -z "$project_name" && -n "$label_project" ]]; then
|
||||
project_name="$label_project"
|
||||
fi
|
||||
if [[ -n "$project_name" && ! "$project_name" =~ ^[a-z0-9][a-z0-9_-]*$ ]]; then
|
||||
echo "❌ Compose 项目名不合法:$project_name"
|
||||
return 1
|
||||
fi
|
||||
|
||||
PANEL_DEPLOY_DIR="$deploy_dir"
|
||||
PANEL_BACKEND_CONTAINER="$backend_container"
|
||||
PANEL_POSTGRES_CONTAINER="${PANEL_POSTGRES_CONTAINER:-$DEFAULT_PANEL_POSTGRES_CONTAINER}"
|
||||
PANEL_COMPOSE_PROJECT="$project_name"
|
||||
export PANEL_DEPLOY_DIR PANEL_BACKEND_CONTAINER PANEL_POSTGRES_CONTAINER PANEL_COMPOSE_PROJECT
|
||||
if [[ -n "$project_name" ]]; then
|
||||
COMPOSE_PROJECT_NAME="$project_name"
|
||||
export COMPOSE_PROJECT_NAME
|
||||
fi
|
||||
|
||||
cd "$PANEL_DEPLOY_DIR"
|
||||
echo "📁 部署目录:$PANEL_DEPLOY_DIR"
|
||||
if [[ -n "$PANEL_COMPOSE_PROJECT" ]]; then
|
||||
echo "📦 Compose 项目:$PANEL_COMPOSE_PROJECT"
|
||||
fi
|
||||
}
|
||||
|
||||
run_panel_compose() {
|
||||
$DOCKER_CMD "$@"
|
||||
}
|
||||
|
||||
validate_panel_update_environment() {
|
||||
local key value backend_port frontend_port configured_db_type db_type database_url postgres_password
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
echo "❌ 部署目录缺少 .env,更新终止"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -f "docker-compose.yml" ]]; then
|
||||
echo "❌ 部署目录缺少 docker-compose.yml,更新终止"
|
||||
return 1
|
||||
fi
|
||||
|
||||
for key in JWT_SECRET BACKEND_PORT FRONTEND_PORT; do
|
||||
value=$(get_env_var "$key" || true)
|
||||
if [[ -z "${value//[[:space:]]/}" ]]; then
|
||||
echo "❌ .env 中 $key 缺失或为空,更新终止"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
|
||||
backend_port=$(get_env_var "BACKEND_PORT" || true)
|
||||
frontend_port=$(get_env_var "FRONTEND_PORT" || true)
|
||||
if [[ ! "$backend_port" =~ ^[0-9]+$ || "$backend_port" -lt 1 || "$backend_port" -gt 65535 ]]; then
|
||||
echo "❌ BACKEND_PORT 不是有效端口:$backend_port"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! "$frontend_port" =~ ^[0-9]+$ || "$frontend_port" -lt 1 || "$frontend_port" -gt 65535 ]]; then
|
||||
echo "❌ FRONTEND_PORT 不是有效端口:$frontend_port"
|
||||
return 1
|
||||
fi
|
||||
|
||||
configured_db_type=$(get_env_var "DB_TYPE" || true)
|
||||
if [[ -n "$configured_db_type" && "$configured_db_type" != "sqlite" && "$configured_db_type" != "postgres" ]]; then
|
||||
echo "❌ DB_TYPE 仅支持 sqlite 或 postgres:$configured_db_type"
|
||||
return 1
|
||||
fi
|
||||
db_type=$(get_current_db_type)
|
||||
if [[ "$db_type" == "postgres" ]]; then
|
||||
database_url=$(get_env_var "DATABASE_URL" || true)
|
||||
postgres_password=$(get_env_var "POSTGRES_PASSWORD" || true)
|
||||
if [[ -z "${database_url//[[:space:]]/}" || -z "${postgres_password//[[:space:]]/}" ]]; then
|
||||
echo "❌ PostgreSQL 模式要求 DATABASE_URL 和 POSTGRES_PASSWORD 均非空"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if ! run_panel_compose -f docker-compose.yml config -q; then
|
||||
echo "❌ 当前 Compose 配置校验失败,更新终止"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
download_panel_update_compose() {
|
||||
local compose_url="$1"
|
||||
|
||||
UPDATE_COMPOSE_CANDIDATE=$(mktemp "$PANEL_DEPLOY_DIR/.docker-compose.yml.update.XXXXXX")
|
||||
if ! curl -fL -o "$UPDATE_COMPOSE_CANDIDATE" "$compose_url"; then
|
||||
rm -f "$UPDATE_COMPOSE_CANDIDATE"
|
||||
UPDATE_COMPOSE_CANDIDATE=""
|
||||
echo "❌ 下载最新 Compose 配置失败,更新终止"
|
||||
return 1
|
||||
fi
|
||||
if [[ ! -s "$UPDATE_COMPOSE_CANDIDATE" ]]; then
|
||||
rm -f "$UPDATE_COMPOSE_CANDIDATE"
|
||||
UPDATE_COMPOSE_CANDIDATE=""
|
||||
echo "❌ 下载的 Compose 配置为空,更新终止"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
validate_panel_update_compose() {
|
||||
if ! FLUX_VERSION="$LATEST_VERSION" run_panel_compose -f "$UPDATE_COMPOSE_CANDIDATE" config -q; then
|
||||
echo "❌ 新 Compose 配置校验失败,更新终止"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
backup_sqlite_for_update() (
|
||||
local destination="$1"
|
||||
local running paused="false"
|
||||
|
||||
cleanup_paused_backend() {
|
||||
if [[ "$paused" == "true" ]]; then
|
||||
docker unpause "$PANEL_BACKEND_CONTAINER" >/dev/null 2>&1 || true
|
||||
fi
|
||||
}
|
||||
trap cleanup_paused_backend EXIT
|
||||
|
||||
running=$(docker inspect -f '{{.State.Running}}' "$PANEL_BACKEND_CONTAINER" 2>/dev/null || true)
|
||||
if [[ "$running" == "true" ]]; then
|
||||
if ! docker pause "$PANEL_BACKEND_CONTAINER" >/dev/null; then
|
||||
echo "❌ 暂停后端以创建一致性 SQLite 备份失败"
|
||||
return 1
|
||||
fi
|
||||
paused="true"
|
||||
fi
|
||||
|
||||
mkdir -p "$destination"
|
||||
if ! docker cp "$PANEL_BACKEND_CONTAINER:/app/data/." "$destination"; then
|
||||
echo "❌ SQLite 数据备份失败"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$paused" == "true" ]] && ! docker unpause "$PANEL_BACKEND_CONTAINER" >/dev/null; then
|
||||
echo "❌ SQLite 备份完成,但恢复后端运行失败"
|
||||
return 1
|
||||
fi
|
||||
paused="false"
|
||||
)
|
||||
|
||||
backup_postgres_for_update() {
|
||||
local destination="$1"
|
||||
local postgres_db postgres_user
|
||||
|
||||
postgres_db=$(get_env_var "POSTGRES_DB" || true)
|
||||
postgres_user=$(get_env_var "POSTGRES_USER" || true)
|
||||
postgres_db=${postgres_db:-flux_panel}
|
||||
postgres_user=${postgres_user:-flux_panel}
|
||||
|
||||
if ! docker exec "$PANEL_POSTGRES_CONTAINER" pg_dump -U "$postgres_user" "$postgres_db" > "$destination"; then
|
||||
rm -f "$destination"
|
||||
echo "❌ PostgreSQL 数据备份失败"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
create_panel_update_backup() {
|
||||
local db_type="$1"
|
||||
local timestamp
|
||||
|
||||
timestamp=$(date '+%Y%m%d-%H%M%S')
|
||||
if ! (umask 077 && mkdir -p "$PANEL_DEPLOY_DIR/backups"); then
|
||||
echo "❌ 创建更新备份目录失败"
|
||||
return 1
|
||||
fi
|
||||
UPDATE_BACKUP_DIR=$(umask 077 && mktemp -d "$PANEL_DEPLOY_DIR/backups/panel-update-$timestamp.XXXXXX") || {
|
||||
echo "❌ 创建更新备份目录失败"
|
||||
return 1
|
||||
}
|
||||
if ! cp -p .env docker-compose.yml "$UPDATE_BACKUP_DIR/"; then
|
||||
echo "❌ 备份面板配置失败"
|
||||
return 1
|
||||
fi
|
||||
|
||||
if [[ "$db_type" == "postgres" ]]; then
|
||||
backup_postgres_for_update "$UPDATE_BACKUP_DIR/postgres.sql" || return 1
|
||||
else
|
||||
backup_sqlite_for_update "$UPDATE_BACKUP_DIR/sqlite" || return 1
|
||||
fi
|
||||
echo "💾 更新备份:$UPDATE_BACKUP_DIR"
|
||||
}
|
||||
|
||||
pull_panel_update_images() {
|
||||
local db_type="$1"
|
||||
|
||||
if [[ "$db_type" == "postgres" ]]; then
|
||||
FLUX_VERSION="$LATEST_VERSION" run_panel_compose -f "$UPDATE_COMPOSE_CANDIDATE" pull backend frontend postgres
|
||||
else
|
||||
FLUX_VERSION="$LATEST_VERSION" run_panel_compose -f "$UPDATE_COMPOSE_CANDIDATE" pull backend frontend
|
||||
fi
|
||||
}
|
||||
|
||||
activate_panel_update_files() {
|
||||
if ! chmod 0644 "$UPDATE_COMPOSE_CANDIDATE" || ! mv "$UPDATE_COMPOSE_CANDIDATE" docker-compose.yml; then
|
||||
echo "❌ 替换 Compose 配置失败"
|
||||
return 1
|
||||
fi
|
||||
UPDATE_COMPOSE_CANDIDATE=""
|
||||
if ! upsert_env_var ".env" "FLUX_VERSION" "$LATEST_VERSION"; then
|
||||
echo "❌ 更新版本配置失败"
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
start_panel_after_update() {
|
||||
local db_type="$1"
|
||||
|
||||
if [[ "$db_type" == "postgres" ]]; then
|
||||
run_panel_compose up -d postgres || return 1
|
||||
wait_for_postgres_healthy || return 1
|
||||
fi
|
||||
run_panel_compose up -d --force-recreate --remove-orphans backend frontend || return 1
|
||||
wait_for_backend_healthy
|
||||
}
|
||||
|
||||
rollback_panel_update() {
|
||||
local db_type="$1"
|
||||
|
||||
echo "↩️ 正在恢复更新前配置..."
|
||||
if ! cp -p "$UPDATE_BACKUP_DIR/.env" .env || ! cp -p "$UPDATE_BACKUP_DIR/docker-compose.yml" docker-compose.yml; then
|
||||
echo "❌ 配置回滚失败,请从 $UPDATE_BACKUP_DIR 手动恢复"
|
||||
return 1
|
||||
fi
|
||||
if [[ "$db_type" == "postgres" ]]; then
|
||||
run_panel_compose up -d postgres || return 1
|
||||
wait_for_postgres_healthy || return 1
|
||||
fi
|
||||
run_panel_compose up -d --force-recreate --remove-orphans backend frontend || return 1
|
||||
wait_for_backend_healthy
|
||||
}
|
||||
|
||||
wait_for_postgres_healthy() {
|
||||
local pg_health
|
||||
local postgres_container="${PANEL_POSTGRES_CONTAINER:-$DEFAULT_PANEL_POSTGRES_CONTAINER}"
|
||||
|
||||
echo "🔍 检查 PostgreSQL 服务状态..."
|
||||
for i in {1..90}; do
|
||||
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-postgres$"; then
|
||||
pg_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo "unknown")
|
||||
if docker ps --format "{{.Names}}" | grep -Fxq "$postgres_container"; then
|
||||
pg_health=$(docker inspect -f '{{.State.Health.Status}}' "$postgres_container" 2>/dev/null || echo "unknown")
|
||||
if [[ "$pg_health" == "healthy" ]]; then
|
||||
echo "✅ PostgreSQL 服务健康检查通过"
|
||||
return 0
|
||||
@@ -335,7 +614,7 @@ wait_for_postgres_healthy() {
|
||||
|
||||
if [ $i -eq 90 ]; then
|
||||
echo "❌ PostgreSQL 启动超时(90秒)"
|
||||
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-postgres 2>/dev/null || echo '容器不存在')"
|
||||
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' "$postgres_container" 2>/dev/null || echo '容器不存在')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -348,11 +627,12 @@ wait_for_postgres_healthy() {
|
||||
|
||||
wait_for_backend_healthy() {
|
||||
local backend_health
|
||||
local backend_container="${PANEL_BACKEND_CONTAINER:-$DEFAULT_PANEL_BACKEND_CONTAINER}"
|
||||
|
||||
echo "🔍 检查后端服务状态..."
|
||||
for i in {1..90}; do
|
||||
if docker ps --format "{{.Names}}" | grep -q "^flux-panel-backend$"; then
|
||||
backend_health=$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo "unknown")
|
||||
if docker ps --format "{{.Names}}" | grep -Fxq "$backend_container"; then
|
||||
backend_health=$(docker inspect -f '{{.State.Health.Status}}' "$backend_container" 2>/dev/null || echo "unknown")
|
||||
if [[ "$backend_health" == "healthy" ]]; then
|
||||
echo "✅ 后端服务健康检查通过"
|
||||
return 0
|
||||
@@ -365,7 +645,7 @@ wait_for_backend_healthy() {
|
||||
|
||||
if [ $i -eq 90 ]; then
|
||||
echo "❌ 后端服务启动超时(90秒)"
|
||||
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' flux-panel-backend 2>/dev/null || echo '容器不存在')"
|
||||
echo "🔍 当前状态:$(docker inspect -f '{{.State.Health.Status}}' "$backend_container" 2>/dev/null || echo '容器不存在')"
|
||||
return 1
|
||||
fi
|
||||
|
||||
@@ -380,9 +660,8 @@ wait_for_backend_healthy() {
|
||||
delete_self() {
|
||||
echo ""
|
||||
echo "🗑️ 操作已完成,正在清理脚本文件..."
|
||||
SCRIPT_PATH="$(readlink -f "$0" 2>/dev/null || realpath "$0" 2>/dev/null || echo "$0")"
|
||||
sleep 1
|
||||
rm -f "$SCRIPT_PATH" && echo "✅ 脚本文件已删除" || echo "❌ 删除脚本文件失败"
|
||||
rm -f "$PANEL_SCRIPT_PATH" && echo "✅ 脚本文件已删除" || echo "❌ 删除脚本文件失败"
|
||||
}
|
||||
|
||||
|
||||
@@ -486,12 +765,11 @@ EOF
|
||||
# 更新功能
|
||||
update_panel() {
|
||||
echo "🔄 开始更新面板..."
|
||||
ask_proxy_config
|
||||
check_docker
|
||||
resolve_panel_deployment || return 1
|
||||
ask_proxy_config
|
||||
validate_panel_update_environment || return 1
|
||||
|
||||
if [[ ! -f ".env" ]]; then
|
||||
echo "⚠️ 未找到 .env,默认按 SQLite 模式更新"
|
||||
fi
|
||||
CURRENT_DB_TYPE=$(get_current_db_type)
|
||||
echo "🗄️ 当前数据库类型:$CURRENT_DB_TYPE"
|
||||
|
||||
@@ -502,52 +780,45 @@ update_panel() {
|
||||
}
|
||||
echo "🆕 最新版本:$LATEST_VERSION"
|
||||
set_compose_urls_by_version "$LATEST_VERSION"
|
||||
upsert_env_var ".env" "FLUX_VERSION" "$LATEST_VERSION"
|
||||
|
||||
echo "🔽 下载最新配置文件..."
|
||||
DOCKER_COMPOSE_URL=$(get_docker_compose_url)
|
||||
echo "📡 选择配置文件:$(basename "$DOCKER_COMPOSE_URL")"
|
||||
curl -L -o docker-compose.yml "$DOCKER_COMPOSE_URL"
|
||||
echo "✅ 下载完成"
|
||||
|
||||
# 自动检测并配置 IPv6 支持
|
||||
if check_ipv6_support; then
|
||||
echo "🚀 系统支持 IPv6,自动启用 IPv6 配置..."
|
||||
configure_docker_ipv6
|
||||
download_panel_update_compose "$DOCKER_COMPOSE_URL" || return 1
|
||||
if ! validate_panel_update_compose; then
|
||||
rm -f "$UPDATE_COMPOSE_CANDIDATE"
|
||||
UPDATE_COMPOSE_CANDIDATE=""
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 先发送 SIGTERM 信号,让应用优雅关闭
|
||||
docker stop -t 30 flux-panel-backend 2>/dev/null || true
|
||||
docker stop -t 10 vite-frontend 2>/dev/null || true
|
||||
|
||||
# 等待 WAL 文件同步
|
||||
echo "⏳ 等待数据同步..."
|
||||
sleep 5
|
||||
|
||||
# 然后再完全停止
|
||||
$DOCKER_CMD down
|
||||
echo "💾 备份当前配置和数据库..."
|
||||
if ! create_panel_update_backup "$CURRENT_DB_TYPE"; then
|
||||
rm -f "$UPDATE_COMPOSE_CANDIDATE"
|
||||
UPDATE_COMPOSE_CANDIDATE=""
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "⬇️ 拉取最新镜像..."
|
||||
if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then
|
||||
$DOCKER_CMD pull backend frontend postgres
|
||||
else
|
||||
$DOCKER_CMD pull backend frontend
|
||||
if ! pull_panel_update_images "$CURRENT_DB_TYPE"; then
|
||||
rm -f "$UPDATE_COMPOSE_CANDIDATE"
|
||||
UPDATE_COMPOSE_CANDIDATE=""
|
||||
echo "❌ 镜像拉取失败,现有服务保持运行"
|
||||
return 1
|
||||
fi
|
||||
|
||||
echo "🚀 启动更新后的服务..."
|
||||
if [[ "$CURRENT_DB_TYPE" == "postgres" ]]; then
|
||||
$DOCKER_CMD up -d postgres
|
||||
wait_for_postgres_healthy
|
||||
$DOCKER_CMD up -d backend frontend
|
||||
else
|
||||
$DOCKER_CMD up -d backend frontend
|
||||
if ! activate_panel_update_files; then
|
||||
rollback_panel_update "$CURRENT_DB_TYPE" || true
|
||||
return 1
|
||||
fi
|
||||
|
||||
# 等待服务启动
|
||||
echo "⏳ 等待服务启动..."
|
||||
|
||||
if ! wait_for_backend_healthy; then
|
||||
echo "🛑 更新终止"
|
||||
echo "🚀 原地重建更新后的服务..."
|
||||
if ! start_panel_after_update "$CURRENT_DB_TYPE"; then
|
||||
echo "🛑 新版本启动失败"
|
||||
if rollback_panel_update "$CURRENT_DB_TYPE"; then
|
||||
echo "✅ 已恢复更新前版本"
|
||||
else
|
||||
echo "❌ 自动回滚失败,请从 $UPDATE_BACKUP_DIR 手动恢复"
|
||||
fi
|
||||
return 1
|
||||
fi
|
||||
|
||||
|
||||
@@ -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"
|
||||
@@ -558,6 +558,7 @@ test_update_panel_asks_for_proxy_config() (
|
||||
load_script_without_main "$ROOT_DIR/panel_install.sh"
|
||||
|
||||
local ask_called="0"
|
||||
local calls=""
|
||||
|
||||
ask_proxy_config() {
|
||||
ask_called="1"
|
||||
@@ -570,6 +571,15 @@ test_update_panel_asks_for_proxy_config() (
|
||||
DOCKER_CMD="true"
|
||||
}
|
||||
|
||||
resolve_panel_deployment() {
|
||||
calls+=" resolve"
|
||||
PANEL_DEPLOY_DIR=$(mktemp -d)
|
||||
}
|
||||
|
||||
validate_panel_update_environment() {
|
||||
calls+=" validate-current"
|
||||
}
|
||||
|
||||
get_current_db_type() {
|
||||
echo "sqlite"
|
||||
}
|
||||
@@ -578,13 +588,33 @@ test_update_panel_asks_for_proxy_config() (
|
||||
echo "v-test"
|
||||
}
|
||||
|
||||
upsert_env_var() { :; }
|
||||
download_panel_update_compose() {
|
||||
calls+=" download"
|
||||
UPDATE_COMPOSE_CANDIDATE="candidate"
|
||||
}
|
||||
|
||||
validate_panel_update_compose() {
|
||||
calls+=" validate-new"
|
||||
}
|
||||
|
||||
create_panel_update_backup() {
|
||||
calls+=" backup"
|
||||
UPDATE_BACKUP_DIR="backup"
|
||||
}
|
||||
|
||||
pull_panel_update_images() {
|
||||
calls+=" pull"
|
||||
}
|
||||
|
||||
activate_panel_update_files() {
|
||||
calls+=" activate"
|
||||
}
|
||||
|
||||
start_panel_after_update() {
|
||||
calls+=" start"
|
||||
}
|
||||
|
||||
check_ipv6_support() { return 1; }
|
||||
configure_docker_ipv6() { :; }
|
||||
docker() { return 0; }
|
||||
wait_for_backend_healthy() { return 0; }
|
||||
sleep() { :; }
|
||||
curl() { :; }
|
||||
|
||||
update_panel >/dev/null
|
||||
|
||||
@@ -593,6 +623,75 @@ test_update_panel_asks_for_proxy_config() (
|
||||
"https://github.com/${REPO}/releases/download/v-test/docker-compose-v4.yml" \
|
||||
"$DOCKER_COMPOSEV4_URL" \
|
||||
"update_panel should honor the prompted proxy choice"
|
||||
assert_equals \
|
||||
" resolve validate-current download validate-new backup pull activate start" \
|
||||
"$calls" \
|
||||
"update_panel should validate, back up, and pull before activating the update"
|
||||
)
|
||||
|
||||
test_resolve_panel_deployment_uses_container_labels() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/panel_install.sh"
|
||||
|
||||
local expected_dir
|
||||
expected_dir=$(mktemp -d)
|
||||
expected_dir=$(cd "$expected_dir" && pwd -P)
|
||||
printf 'JWT_SECRET=test\nBACKEND_PORT=6365\nFRONTEND_PORT=6366\nDB_TYPE=sqlite\n' > "$expected_dir/.env"
|
||||
printf 'services: {}\n' > "$expected_dir/docker-compose.yml"
|
||||
|
||||
unset PANEL_DEPLOY_DIR COMPOSE_PROJECT_NAME
|
||||
docker() {
|
||||
if [[ "$1" == "inspect" && "$2" == "-f" ]]; then
|
||||
case "$3" in
|
||||
*working_dir*) printf '%s\n' "$expected_dir" ;;
|
||||
*) printf '%s\n' "flvx-panel" ;;
|
||||
esac
|
||||
fi
|
||||
}
|
||||
|
||||
resolve_panel_deployment >/dev/null
|
||||
|
||||
assert_equals "$expected_dir" "$PANEL_DEPLOY_DIR" "update should discover the Compose working directory from container labels"
|
||||
assert_equals "flvx-panel" "$COMPOSE_PROJECT_NAME" "update should preserve the existing Compose project name"
|
||||
assert_equals "$expected_dir" "$(pwd -P)" "update should run from the discovered deployment directory"
|
||||
)
|
||||
|
||||
test_validate_panel_update_environment_rejects_empty_required_values() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/panel_install.sh"
|
||||
|
||||
local deploy_dir rc="0"
|
||||
deploy_dir=$(mktemp -d)
|
||||
printf 'JWT_SECRET=\nBACKEND_PORT=6365\nFRONTEND_PORT=6366\nDB_TYPE=sqlite\n' > "$deploy_dir/.env"
|
||||
printf 'services: {}\n' > "$deploy_dir/docker-compose.yml"
|
||||
cd "$deploy_dir"
|
||||
DOCKER_CMD="true"
|
||||
|
||||
validate_panel_update_environment >/dev/null || rc="$?"
|
||||
|
||||
assert_equals "1" "$rc" "update should reject an empty JWT secret before touching the running service"
|
||||
)
|
||||
|
||||
test_backup_sqlite_for_update_pauses_copies_and_unpauses() (
|
||||
set -euo pipefail
|
||||
load_script_without_main "$ROOT_DIR/panel_install.sh"
|
||||
|
||||
local destination calls log_path
|
||||
destination=$(mktemp -d)/sqlite
|
||||
log_path=$(mktemp)
|
||||
PANEL_BACKEND_CONTAINER="flux-panel-backend"
|
||||
docker() {
|
||||
if [[ "$1" == "inspect" ]]; then
|
||||
printf 'true\n'
|
||||
return 0
|
||||
fi
|
||||
printf ' %s' "$1" >> "$log_path"
|
||||
}
|
||||
|
||||
backup_sqlite_for_update "$destination" >/dev/null
|
||||
calls=$(cat "$log_path")
|
||||
|
||||
assert_equals " pause cp unpause" "$calls" "SQLite backup should pause writes, copy the database volume, then resume the backend"
|
||||
)
|
||||
|
||||
test_panel_install_script_accepts_proxy_url_env_without_prompt() (
|
||||
@@ -663,6 +762,9 @@ test_install_script_accepts_proxy_url_env_without_prompt
|
||||
test_panel_install_script_can_disable_proxy
|
||||
test_panel_install_script_recomputes_compose_urls_after_prompt
|
||||
test_update_panel_asks_for_proxy_config
|
||||
test_resolve_panel_deployment_uses_container_labels
|
||||
test_validate_panel_update_environment_rejects_empty_required_values
|
||||
test_backup_sqlite_for_update_pauses_copies_and_unpauses
|
||||
test_panel_install_script_uses_default_proxy
|
||||
test_panel_install_script_accepts_proxy_url_env_without_prompt
|
||||
test_panel_install_script_defaults_proxy_on_eof
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# 多阶段构建 - 构建阶段
|
||||
FROM node:22-alpine AS builder
|
||||
FROM --platform=$BUILDPLATFORM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package.json pnpm-lock.yaml* ./
|
||||
RUN corepack prepare pnpm@10 --activate && corepack enable pnpm && pnpm install --frozen-lockfile
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
RUN corepack enable pnpm && pnpm install --frozen-lockfile
|
||||
|
||||
COPY . .
|
||||
RUN pnpm run build
|
||||
@@ -18,4 +18,4 @@ COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"name": "flvx",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
@@ -81,12 +82,5 @@
|
||||
"vite": "npm:rolldown-vite@^7.3.1",
|
||||
"vite-plugin-pwa": "^1.1.0",
|
||||
"vite-tsconfig-paths": "^6.0.5"
|
||||
},
|
||||
"pnpm": {
|
||||
"overrides": {
|
||||
"@babel/plugin-transform-modules-systemjs": "7.29.4",
|
||||
"fast-uri": "3.1.2",
|
||||
"serialize-javascript": "7.0.5"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,2 +1,10 @@
|
||||
packages:
|
||||
- '.'
|
||||
|
||||
overrides:
|
||||
'@babel/plugin-transform-modules-systemjs': 7.29.4
|
||||
fast-uri: 3.1.2
|
||||
serialize-javascript: 7.0.5
|
||||
|
||||
allowBuilds:
|
||||
'@tailwindcss/oxide': true
|
||||
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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<string, string>) => {
|
||||
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<string, string>) => {
|
||||
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")
|
||||
) {
|
||||
|
||||
@@ -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<string, string> => {
|
||||
"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<HTMLInputElement>(null);
|
||||
const faviconFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const bgImageFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [bgImageUploading, setBgImageUploading] = useState(false);
|
||||
const bgImageFileInputRefs = useRef<
|
||||
Partial<Record<BgImageKey, HTMLInputElement>>
|
||||
>({});
|
||||
const [bgImageUploading, setBgImageUploading] = useState<BgImageKey | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const [announcement, setAnnouncement] = useState<AnnouncementData>({
|
||||
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<HTMLInputElement>,
|
||||
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<string>((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 (
|
||||
<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">
|
||||
<input
|
||||
ref={bgImageFileInputRef}
|
||||
ref={(node) => {
|
||||
if (node) bgImageFileInputRefs.current[key] = node;
|
||||
}}
|
||||
accept="image/*"
|
||||
className="hidden"
|
||||
type="file"
|
||||
onChange={handleBgImageUpload}
|
||||
onChange={(event) => void handleBgImageUpload(event, key)}
|
||||
/>
|
||||
<Button
|
||||
color="primary"
|
||||
isLoading={bgImageUploading}
|
||||
isDisabled={bgImageUploading !== null && bgImageUploading !== key}
|
||||
isLoading={bgImageUploading === key}
|
||||
variant="flat"
|
||||
onPress={() => bgImageFileInputRef.current?.click()}
|
||||
onPress={() => bgImageFileInputRefs.current[key]?.click()}
|
||||
>
|
||||
上传图片
|
||||
</Button>
|
||||
<Button
|
||||
color="secondary"
|
||||
isDisabled={bgImageUploading || isTheme}
|
||||
variant="flat"
|
||||
onPress={() => handleConfigChange("app_bg_image", "theme")}
|
||||
>
|
||||
自适应纯色 (跟随深色模式)
|
||||
</Button>
|
||||
<Button
|
||||
color="default"
|
||||
isDisabled={bgImageUploading || bgImage === "#ffffff"}
|
||||
variant="flat"
|
||||
onPress={() => handleConfigChange("app_bg_image", "#ffffff")}
|
||||
>
|
||||
白色纯色
|
||||
</Button>
|
||||
{isDefault && (
|
||||
<Button
|
||||
color="secondary"
|
||||
isDisabled={bgImageUploading !== null || isTheme}
|
||||
variant="flat"
|
||||
onPress={() => handleConfigChange(key, "theme")}
|
||||
>
|
||||
自适应纯色 (跟随深色模式)
|
||||
</Button>
|
||||
)}
|
||||
{isDefault && (
|
||||
<Button
|
||||
color="default"
|
||||
isDisabled={bgImageUploading !== null || bgImage === "#ffffff"}
|
||||
variant="flat"
|
||||
onPress={() => handleConfigChange(key, "#ffffff")}
|
||||
>
|
||||
白色纯色
|
||||
</Button>
|
||||
)}
|
||||
{bgImage && (
|
||||
<Button
|
||||
color="danger"
|
||||
isDisabled={bgImageUploading}
|
||||
isDisabled={bgImageUploading !== null}
|
||||
variant="flat"
|
||||
onPress={() => handleConfigChange("app_bg_image", "")}
|
||||
onPress={() => handleConfigChange(key, "")}
|
||||
>
|
||||
恢复默认
|
||||
{isDefault ? "恢复内置背景" : "清除专属壁纸"}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
@@ -930,7 +965,7 @@ export default function ConfigPage() {
|
||||
{bgImage && isImage && (
|
||||
<div className="relative rounded-xl overflow-hidden border border-divider">
|
||||
<img
|
||||
alt="背景预览"
|
||||
alt={`${label}预览`}
|
||||
className="w-full max-h-48 object-cover opacity-80"
|
||||
src={bgImage}
|
||||
/>
|
||||
@@ -1118,7 +1153,15 @@ export default function ConfigPage() {
|
||||
|
||||
switch (item.type) {
|
||||
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": {
|
||||
if (isBrandPreviewKey(item.key)) {
|
||||
|
||||
Reference in New Issue
Block a user