feat: align with upstream 1.5.9.9

This commit is contained in:
2026-09-12 18:21:11 +08:00
commit 34f0679117
805 changed files with 376275 additions and 0 deletions
@@ -0,0 +1,458 @@
From 171854222838fa3c5f01bef96ffc9b820bc4bf00 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 08:23:39 +0800
Subject: [PATCH 01/58] =?UTF-8?q?chore(deploy):=20=E4=BF=AE=E5=A4=8D=20Doc?=
=?UTF-8?q?ker=20=E5=90=AF=E5=8A=A8=E9=93=BE=20+=20=E8=A1=A5=20compose=20+?=
=?UTF-8?q?=20Gitea=20Actions?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 164 ++++++++++++++++++++++++++++++++
docker-compose.yml | 182 ++++++++++++++++++++++++++++--------
scripts/deploy.js | 30 +++---
3 files changed, 323 insertions(+), 53 deletions(-)
create mode 100644 .gitea/workflows/deploy.yml
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
new file mode 100644
index 0000000..ca3da1f
--- /dev/null
+++ b/.gitea/workflows/deploy.yml
@@ -0,0 +1,164 @@
+name: 构建并部署 VoiceHub
+run-name: ${{ gitea.actor }} 触发部署 #${{{{ gitea.run_number }}}}
+
+on:
+ push:
+ branches:
+ - main
+ - master
+ # 允许在 Gitea 页面手动点「运行工作流」
+ workflow_dispatch:
+ inputs:
+ deploy_target:
+ description: 是否在构建后自动部署到服务器
+ type: boolean
+ default: true
+ required: false
+
+# 同一时刻只跑一个部署,避免部署竞态
+concurrency:
+ group: deploy-production
+ cancel-in-progress: false
+
+env:
+ # ====== 按需修改(改成你的 Gitea 域名,不要 http:// 前缀) ======
+ GITEA_REGISTRY: gitea.example.com
+ # ====== 一般不改 ======
+ IMAGE_NAME: byw-voicehub
+ DEPLOY_PATH: /opt/byw-voicehub
+ NODE_VERSION: "22"
+
+jobs:
+ # ============================================================
+ # Job 1: 构建 & 推送 Docker 镜像到 Gitea 自带容器仓库
+ # ============================================================
+ build-image:
+ name: 构建 Docker 镜像
+ runs-on: ubuntu-latest
+ outputs:
+ image_tag: ${{ steps.meta.outputs.tag }}
+ image_full: ${{ steps.meta.outputs.full }}
+ steps:
+ - name: 📥 拉取代码
+ uses: actions/checkout@v4
+ with:
+ fetch-depth: 1
+
+ - name: 🔧 设置 Node.js
+ uses: actions/setup-node@v4
+ with:
+ node-version: ${{ env.NODE_VERSION }}
+ registry-url: https://registry.npmmirror.com # 国内加速,不需要可删
+
+ - name: 🧪 环境校验
+ run: |
+ set -euxo pipefail
+ node -v
+ npm -v
+ npm ci --no-audit --no-fund
+
+ - name: 🔐 登录 Gitea 容器仓库
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.GITEA_REGISTRY }}
+ username: ${{ gitea.repository_owner }}
+ password: ${{ secrets.REGISTRY_TOKEN || gitea.token }}
+
+ - name: 🏷️ 生成镜像标签
+ id: meta
+ run: |
+ set -euo pipefail
+ SHORT_SHA="${GITEA_SHA:0:7}"
+ DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}"
+ FULL="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:${DATE_TAG}"
+ LATEST="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:latest"
+ echo "tag=${DATE_TAG}" >> "$GITEA_OUTPUT"
+ echo "full=${FULL}" >> "$GITEA_OUTPUT"
+ echo "latest=${LATEST}" >> "$GITEA_OUTPUT"
+ echo "✅ 镜像 tag: ${DATE_TAG}"
+ echo "✅ 镜像完整: ${FULL}"
+
+ - name: 🐳 构建并推送镜像(BuildKit 多阶段 + 缓存)
+ uses: docker/build-push-action@v6
+ with:
+ context: .
+ file: ./Dockerfile
+ push: true
+ tags: |
+ ${{ steps.meta.outputs.full }}
+ ${{ steps.meta.outputs.latest }}
+ cache-from: type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache
+ cache-to: type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
+ build-args: |
+ BUILDKIT_INLINE_CACHE=1
+ labels: |
+ org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
+ org.opencontainers.image.revision=${{ gitea.sha }}
+
+ # ============================================================
+ # Job 2: SSH 到服务器拉取新镜像并重启
+ # ============================================================
+ deploy:
+ name: 部署到服务器
+ needs: build-image
+ if: ${{ !cancelled() && !failure() && (inputs.deploy_target == true || github.event_name == 'push') }}
+ runs-on: ubuntu-latest
+ environment: production
+ steps:
+ - name: 🌐 SSH 部署到生产服务器
+ uses: https://github.com/appleboy/ssh-action@v1.2.0
+ with:
+ host: ${{ secrets.DEPLOY_HOST }}
+ username: ${{ secrets.DEPLOY_USER }}
+ key: ${{ secrets.DEPLOY_SSH_KEY }}
+ port: ${{ secrets.DEPLOY_PORT || 22 }}
+ script_stop: true
+ command_timeout: 30m
+ script: |
+ set -euxo pipefail
+
+ # 1) 首次部署时自动创建目录 & 空的 docker-compose.yml/.env
+ mkdir -p "${DEPLOY_PATH}"
+ cd "${DEPLOY_PATH}"
+
+ # 2) 登录 Gitea 镜像仓库(用一个仅 pull 权限的 token 更安全)
+ echo "${{ secrets.REGISTRY_PULL_TOKEN || gitea.token }}" \
+ | docker login "${GITEA_REGISTRY}" -u "${{ secrets.REGISTRY_PULL_USER || gitea.repository_owner }}" --password-stdin 2>/dev/null || true
+
+ # 3) 拉最新镜像
+ IMAGE_LATEST="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:latest"
+ docker pull "$IMAGE_LATEST"
+
+ # 4) 写一个最小的 docker-compose.override.yml,指向刚拉的镜像
+ # (避免改动服务器上已配置好的 docker-compose.yml 和 .env)
+ cat > docker-compose.override.yml <<'YAML'
+ services:
+ voicehub:
+ image: __REPLACE_WITH_IMAGE__
+ YAML
+ sed -i "s|__REPLACE_WITH_IMAGE__|${IMAGE_LATEST}|g" docker-compose.override.yml
+
+ # 5) 启动(如果没有 docker-compose.yml 就自动从你的仓库默认模板里下载一份)
+ if [ ! -f docker-compose.yml ]; then
+ echo "ℹ️ 首次部署:写入默认 docker-compose.yml 模板(请稍后在服务器上编辑 .env)"
+ curl -fsSL "${{ gitea.server_url }}/${{ gitea.repository }}/raw/branch/main/docker-compose.yml" \
+ -o docker-compose.yml || echo "下载失败,跳过(你需要手动上传 docker-compose.yml)"
+ fi
+
+ # 6) 健康拉取 + 滚动重启(先拉好再 up,downtime 最小)
+ docker compose pull --quiet voicehub 2>/dev/null || true
+ docker compose up -d --remove-orphans
+
+ # 7) 等健康检查通过,最多等 120s
+ for i in $(seq 1 24); do
+ if docker compose ps --format json 2>/dev/null | grep -q '"Health":"healthy"'; then
+ echo "✅ 容器健康"
+ break
+ fi
+ sleep 5
+ done
+
+ # 8) 清理旧镜像(只留最近 2 个 tag,省磁盘)
+ docker image prune -af --filter "until=72h" || true
+
+ echo "🎉 部署完成!镜像=${{ needs.build-image.outputs.image_full }}"
diff --git a/docker-compose.yml b/docker-compose.yml
index c1ed74d..f89810e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,57 +1,159 @@
+name: BYW VoiceHub (1.7.0.0)
services:
+ # ========================================================================
+ # 应用本体 - Nuxt 4 (Node SSR)
+ # - 使用两阶段构建 Dockerfile,构建阶段内存友好
+ # - PREBUILT=true:告诉 scripts/deploy.js 跳过 npm install 和 nuxt build
+ # (因为第一阶段已经 build 好了,产物在 .output/ 里)
+ # ========================================================================
voicehub:
- build: .
+ image: byw-voicehub:1.7.0.0
+ build:
+ context: .
+ dockerfile: Dockerfile
+ args:
+ BUILDKIT_INLINE_CACHE: "1"
+ container_name: voicehub-app
+ restart: unless-stopped
ports:
- - '4456:3000'
- environment:
- - DATABASE_URL=postgresql://user:password@postgres:5432/voicehub
- - JWT_SECRET=your-jwt-secret-here
- - NODE_ENV=production
- - NODE_OPTIONS=--max-old-space-size=512
- - SKIP_BUILD=true
- mem_limit: 1200m
- mem_reservation: 256m
- cpus: '1.0'
- deploy:
- resources:
- limits:
- cpus: '1'
- memory: 1200M
- reservations:
- cpus: '0.25'
- memory: 256M
+ # 左边是宿主机端口(对外),右边是容器内端口(固定 3000)
+ # 如果要套 Nginx,就只监听 127.0.0.1:4456:3000 不直接暴露外网
+ - "4456:3000"
depends_on:
postgres:
condition: service_healthy
- restart: unless-stopped
-
- postgres:
- image: postgres:15-alpine
+ redis:
+ condition: service_healthy
+ env_file:
+ # 把密码、密钥都写在项目根的 .env 里,不要直接写进这个 yaml
+ - .env
environment:
- - POSTGRES_USER=user
- - POSTGRES_PASSWORD=password
- - POSTGRES_DB=voicehub
- volumes:
- - postgres_data:/var/lib/postgresql/data
- ports:
- - '5433:5432'
- mem_limit: 1200m
- mem_reservation: 256m
- cpus: '1.0'
+ # ===== 运行时基础(可以被 .env 覆盖) =====
+ NODE_ENV: production
+ PORT: 3000
+ HOST: 0.0.0.0
+ PREBUILT: "true"
+ SKIP_INSTALL: "true"
+ SKIP_BUILD: "true"
+ NITRO_PRESET: node-server
+
+ # ===== 数据库连接串(.env 写了就用 .env,没写就自动拼 compose 内部 postgres 服务) =====
+ DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-voicehub}:${POSTGRES_PASSWORD:-voicehub}@postgres:5432/${POSTGRES_DB:-voicehub}?sslmode=disable}
+
+ # ===== Redis 连接串(同上,自动拼内部 redis 服务) =====
+ REDIS_URL: ${REDIS_URL:-redis://${REDIS_PASSWORD:+:${REDIS_PASSWORD}@}redis:6379/0}
+
+ # ===== 会话/Token(必须在 .env 里设,这里给一个占位提醒) =====
+ JWT_SECRET: ${JWT_SECRET:-请在.env里设置JWT_SECRET用openssl rand -hex 32生成}
+
+ # ===== 应用默认(可在 .env 覆盖,不会覆盖 .env 里同名字段) =====
+ NUXT_PUBLIC_SITE_URL: ${NUXT_PUBLIC_SITE_URL:-http://127.0.0.1:4456}
+ NUXT_PUBLIC_SITE_TITLE: ${NUXT_PUBLIC_SITE_TITLE:-校园广播站点歌系统}
+ NUXT_PUBLIC_DEFAULT_THEME: ${NUXT_PUBLIC_DEFAULT_THEME:-dark}
+ NUXT_PUBLIC_ENABLED_THEMES: ${NUXT_PUBLIC_ENABLED_THEMES:-dark,light}
+
+ # ===== 日志/性能 =====
+ NODE_OPTIONS: ${NODE_OPTIONS:-"--max-old-space-size=768"}
+ # 2C4G 机器的推荐配置(.env 可覆盖);小机器 1C2G 也能跑
+ mem_limit: ${APP_MEM_LIMIT:-1400m}
+ mem_reservation: ${APP_MEM_RESERVATION:-384m}
+ cpus: ${APP_CPUS:-"1.5"}
deploy:
resources:
limits:
- cpus: '1'
- memory: 1200M
+ cpus: ${APP_CPUS_LIMIT:-"2"}
+ memory: ${APP_MEM_LIMIT:-1400M}
reservations:
- cpus: '0.25'
- memory: 256M
+ cpus: ${APP_CPUS_RESERVATION:-"0.5"}
+ memory: ${APP_MEM_RESERVATION:-384M}
+ healthcheck:
+ # 30s 后开始检查,每 15s 一次,连续 3 次失败就重启
+ test:
+ [
+ "CMD",
+ "node",
+ "-e",
+ "fetch('http://127.0.0.1:3000/').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"
+ ]
+ interval: 15s
+ timeout: 5s
+ retries: 3
+ start_period: 30s
+ networks:
+ - voicehub-net
+ volumes:
+ # (可选)把自定义 logo/上传资源挂到宿主机
+ - voicehub-uploads:/app/app/public/uploads:rw
+
+ # ========================================================================
+ # PostgreSQL 16 - 主数据库
+ # ========================================================================
+ postgres:
+ image: postgres:16-alpine
+ container_name: voicehub-postgres
+ restart: unless-stopped
+ environment:
+ POSTGRES_USER: ${POSTGRES_USER:-voicehub}
+ POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-请在.env里改这个密码}
+ POSTGRES_DB: ${POSTGRES_DB:-voicehub}
+ PGDATA: /var/lib/postgresql/data/pgdata
+ # .env 里没设 DATABASE_URL 时,自动拼一份给 app 用
+ # (如果 .env 里已经写了外部 Postgres,就以 .env 为准)
healthcheck:
- test: ['CMD-SHELL', 'pg_isready -U user -d voicehub']
+ test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-voicehub} -d ${POSTGRES_DB:-voicehub}"]
interval: 5s
timeout: 5s
- retries: 5
+ retries: 10
+ volumes:
+ - postgres-data:/var/lib/postgresql/data
+ # 如需从宿主机用 Navicat 连,取消下一行注释
+ # ports:
+ # - "127.0.0.1:5433:5432"
+ mem_limit: ${PG_MEM_LIMIT:-1200m}
+ mem_reservation: ${PG_MEM_RESERVATION:-256m}
+ cpus: ${PG_CPUS:-"1.0"}
+ networks:
+ - voicehub-net
+
+ # ========================================================================
+ # Redis 7 - 缓存 / 会话 / 限流 / 队列
+ # ========================================================================
+ redis:
+ image: redis:7-alpine
+ container_name: voicehub-redis
restart: unless-stopped
+ command: >
+ sh -c "
+ if [ -n \"$$REDIS_PASSWORD\" ]; then
+ redis-server --appendonly yes --requirepass \"$$REDIS_PASSWORD\"
+ else
+ redis-server --appendonly yes
+ fi
+ "
+ environment:
+ REDIS_PASSWORD: ${REDIS_PASSWORD:-}
+ healthcheck:
+ test: ["CMD", "redis-cli", "ping"]
+ interval: 5s
+ timeout: 3s
+ retries: 10
+ volumes:
+ - redis-data:/data
+ mem_limit: ${REDIS_MEM_LIMIT:-384m}
+ mem_reservation: ${REDIS_MEM_RESERVATION:-64m}
+ cpus: ${REDIS_CPUS:-"0.5"}
+ networks:
+ - voicehub-net
volumes:
- postgres_data:
+ postgres-data:
+ name: voicehub-postgres-data
+ redis-data:
+ name: voicehub-redis-data
+ voicehub-uploads:
+ name: voicehub-uploads
+
+networks:
+ voicehub-net:
+ name: voicehub-net
+ driver: bridge
diff --git a/scripts/deploy.js b/scripts/deploy.js
index c20219f..9963ca4 100644
--- a/scripts/deploy.js
+++ b/scripts/deploy.js
@@ -106,26 +106,26 @@ async function deploy() {
logStep('📦', '安装依赖...')
let installed = false
- // 优先尝试 pnpm install --frozen-lockfile
- if (fileExists('pnpm-lock.yaml')) {
- log('尝试使用 pnpm install --frozen-lockfile 安装...', 'cyan')
- if (safeExec('pnpm install --frozen-lockfile')) {
+ // 优先使用 npm(用户要求:禁止 pnpm)—— 优先 package-lock.json 冻结安装
+ if (fileExists('package-lock.json')) {
+ log('尝试使用 npm ci(基于 package-lock.json 冻结安装)...', 'cyan')
+ if (safeExec('npm ci --no-audit --no-fund')) {
installed = true
- logSuccess('依赖安装完成 (pnpm install --frozen-lockfile)')
+ logSuccess('依赖安装完成 (npm ci)')
} else {
- logWarning('pnpm install --frozen-lockfile 安装失败,准备回退到 pnpm install...')
+ logWarning('npm ci 失败,准备回退到 npm install...')
}
} else {
- logWarning('未检测到 pnpm-lock.yaml,跳过冻结锁文件安装,直接使用 pnpm install...')
+ logWarning('未检测到 package-lock.json,直接使用 npm install...')
}
- // 如果冻结锁文件安装没运行或失败,使用 pnpm install
+ // 兜底用 npm install
if (!installed) {
- log('正在使用 pnpm install 安装...', 'cyan')
- if (!safeExec('pnpm install')) {
+ log('正在使用 npm install 安装...', 'cyan')
+ if (!safeExec('npm install --no-audit --no-fund')) {
throw new Error('依赖安装失败')
}
- logSuccess('依赖安装完成 (pnpm install)')
+ logSuccess('依赖安装完成 (npm install)')
}
}
@@ -166,7 +166,10 @@ async function deploy() {
// 4. 创建管理员账户
if (fileExists('scripts/create-admin.js') && dbSyncSuccess) {
logStep('👤', '检查管理员账户...')
- safeExec('pnpm run create-admin')
+ // 优先 npm run create-admin,兜底直接用 node_modules/.bin/tsx 调用(用户禁用 pnpm)
+ if (!safeExec('npm run create-admin')) {
+ safeExec('./node_modules/.bin/tsx scripts/create-admin.js')
+ }
}
// 5. 构建应用
@@ -178,7 +181,8 @@ async function deploy() {
...process.env,
NODE_OPTIONS: process.env.NODE_OPTIONS || DEFAULT_NODE_OPTIONS
}
- if (!safeExec('pnpm exec nuxt build', { env: buildEnv })) {
+ // 用 npm run build(= nuxt build),避免用 pnpm exec
+ if (!safeExec('npm run build', { env: buildEnv })) {
throw new Error('应用构建失败')
}
logSuccess('应用构建完成')
--
2.45.1.windows.1
@@ -0,0 +1,811 @@
From 5d015a3529957b98b6b4719f1fad4f4d4c0a5fd4 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 08:44:11 +0800
Subject: [PATCH 02/58] =?UTF-8?q?ci:=20=E6=96=B0=E5=A2=9E=20Gitea=20Action?=
=?UTF-8?q?s=20=E9=83=A8=E7=BD=B2=E6=B5=81=E6=B0=B4=E7=BA=BF=20+=20Runner/?=
=?UTF-8?q?Deploy=20=E4=B8=80=E9=94=AE=E8=84=9A=E6=9C=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 321 ++++++++++++++++++++++++++---------
scripts/setup-deploy-user.sh | 159 +++++++++++++++++
scripts/setup-runner.sh | 211 +++++++++++++++++++++++
3 files changed, 609 insertions(+), 82 deletions(-)
create mode 100644 scripts/setup-deploy-user.sh
create mode 100644 scripts/setup-runner.sh
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index ca3da1f..e8d783b 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -1,164 +1,321 @@
-name: 构建并部署 VoiceHub
-run-name: ${{ gitea.actor }} 触发部署 #${{{{ gitea.run_number }}}}
+name: 构建 & 部署 BYW VoiceHub
+run-name: ${{ github.actor }} 触发部署 · Run ${{ github.run_number }}
on:
push:
- branches:
- - main
- - master
- # 允许在 Gitea 页面手动点「运行工作流」
+ branches: [main, master]
workflow_dispatch:
inputs:
deploy_target:
- description: 是否在构建后自动部署到服务器
+ description: 构建完成后是否自动部署到服务器
type: boolean
default: true
required: false
+ skip_build_cache:
+ description: 跳过 BuildKit 分层缓存,强制重新构建(排错用)
+ type: boolean
+ default: false
+ required: false
-# 同一时刻只跑一个部署,避免部署竞态
+# 同一时刻只允许一个部署在跑,避免冲突;如果新提交进来,取消前一个未完成的
concurrency:
group: deploy-production
- cancel-in-progress: false
+ cancel-in-progress: true
env:
- # ====== 按需修改(改成你的 Gitea 域名,不要 http:// 前缀) ======
- GITEA_REGISTRY: gitea.example.com
- # ====== 一般不改 ======
+ # ============================================================
+ # 👇👇👇 这里改成你自己的 Gitea 域名(不要 https:// 前缀,不要端口默认 443/80)
+ # 例:git.baoyiwai.com 或 gitea.example.com:3000
+ GITEA_HOST: gitea.example.com
+ # 👆👆👆
+ # ============================================================
IMAGE_NAME: byw-voicehub
DEPLOY_PATH: /opt/byw-voicehub
- NODE_VERSION: "22"
jobs:
# ============================================================
- # Job 1: 构建 & 推送 Docker 镜像到 Gitea 自带容器仓库
+ # Job 1: 校验构建环境 & 打包产物(npm ci + build,不依赖 Docker)
# ============================================================
- build-image:
- name: 构建 Docker 镜像
+ preflight:
+ name: Preflight 语法 & 构建检查
runs-on: ubuntu-latest
- outputs:
- image_tag: ${{ steps.meta.outputs.tag }}
- image_full: ${{ steps.meta.outputs.full }}
+ timeout-minutes: 15
steps:
- - name: 📥 拉取代码
+ - name: 📥 Checkout 代码
uses: actions/checkout@v4
with:
fetch-depth: 1
- - name: 🔧 设置 Node.js
+ - name: 🔧 安装 Node.js 22
uses: actions/setup-node@v4
with:
- node-version: ${{ env.NODE_VERSION }}
- registry-url: https://registry.npmmirror.com # 国内加速,不需要可删
+ node-version: "22"
+ cache: "npm"
- - name: 🧪 环境校验
+ - name: 🧪 安装依赖(npm ci,严格按锁文件)
run: |
set -euxo pipefail
- node -v
- npm -v
- npm ci --no-audit --no-fund
+ # 国内加速镜像(Runner 在国外可以删掉这两行)
+ npm config set registry https://registry.npmmirror.com || true
+ npm config set fund false
+ npm config set audit false
+ npm ci
- - name: 🔐 登录 Gitea 容器仓库
- uses: docker/login-action@v3
+ - name: 🔎 检查 package.json 没有 pnpm 残留
+ run: |
+ set -euo pipefail
+ if grep -qE '"packageManager"\s*:\s*"pnpm' package.json; then
+ echo "❌ package.json 里有 pnpm 包管理器声明,和用户要求冲突"
+ exit 1
+ fi
+ echo "✅ package.json 未发现 pnpm 声明"
+
+ - name: 🏗️ 本地 nuxt build(独立于 Docker 先验证代码是可构建的)
+ run: |
+ set -euxo pipefail
+ NODE_OPTIONS="--max-old-space-size=2048" npm run build
+ echo "✅ .output/ 生成成功:"
+ ls -la .output/ 2>/dev/null || true
+ du -sh .output 2>/dev/null || true
+
+ # ============================================================
+ # Job 2: 构建 Docker 镜像 → 推送到 Gitea 自带容器仓库
+ # ============================================================
+ build-image:
+ name: 构建并推送镜像
+ needs: preflight
+ runs-on: ubuntu-latest
+ timeout-minutes: 40
+ outputs:
+ image_tag: ${{ steps.meta.outputs.tag }}
+ image_full: ${{ steps.meta.outputs.full }}
+ image_latest: ${{ steps.meta.outputs.latest }}
+ steps:
+ - name: 📥 Checkout 代码
+ uses: actions/checkout@v4
with:
- registry: ${{ env.GITEA_REGISTRY }}
- username: ${{ gitea.repository_owner }}
- password: ${{ secrets.REGISTRY_TOKEN || gitea.token }}
+ fetch-depth: 1
- - name: 🏷️ 生成镜像标签
+ - name: 🏷️ 计算镜像 tag(日期 + 短 SHA)
id: meta
+ shell: bash
run: |
set -euo pipefail
- SHORT_SHA="${GITEA_SHA:0:7}"
+ SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
+ SHORT_SHA="${SHA:0:7}"
DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}"
- FULL="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:${DATE_TAG}"
- LATEST="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:latest"
- echo "tag=${DATE_TAG}" >> "$GITEA_OUTPUT"
- echo "full=${FULL}" >> "$GITEA_OUTPUT"
- echo "latest=${LATEST}" >> "$GITEA_OUTPUT"
- echo "✅ 镜像 tag: ${DATE_TAG}"
- echo "✅ 镜像完整: ${FULL}"
-
- - name: 🐳 构建并推送镜像(BuildKit 多阶段 + 缓存)
+ IMG_FULL="${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:${DATE_TAG}"
+ IMG_LATEST="${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:latest"
+ echo "tag=${DATE_TAG}" >> "$GITHUB_OUTPUT"
+ echo "full=${IMG_FULL}" >> "$GITHUB_OUTPUT"
+ echo "latest=${IMG_LATEST}" >> "$GITHUB_OUTPUT"
+ echo "======================================"
+ echo "🪪 镜像版本 tag : ${DATE_TAG}"
+ echo "🪪 镜像完整名 : ${IMG_FULL}"
+ echo "🪪 镜像 latest : ${IMG_LATEST}"
+ echo "======================================"
+
+ - name: 🔧 启用 QEMU(跨架构小概率用得到,保险)
+ uses: docker/setup-qemu-action@v3
+ with:
+ platforms: linux/amd64
+
+ - name: 🔧 启用 Docker Buildx
+ uses: docker/setup-buildx-action@v3
+ with:
+ install: true
+
+ - name: 🔐 登录 Gitea 容器仓库(Packages)
+ uses: docker/login-action@v3
+ with:
+ registry: ${{ env.GITEA_HOST }}
+ # Secrets 里 REGISTRY_USER 没填就默认用触发构建的用户
+ username: ${{ secrets.REGISTRY_USER || github.actor }}
+ # Secrets 里 REGISTRY_TOKEN 优先,没填就用 Gitea Actions 自动注入的仓库级 token
+ password: ${{ secrets.REGISTRY_TOKEN || github.token }}
+
+ - name: 🐳 构建 & 推送(BuildKit 分层缓存,skip_build_cache=true 时禁用)
+ id: build_push
uses: docker/build-push-action@v6
+ env:
+ CACHE_REF: ${{ env.GITEA_HOST }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache
with:
context: .
file: ./Dockerfile
+ platforms: linux/amd64
push: true
+ provenance: false
tags: |
${{ steps.meta.outputs.full }}
${{ steps.meta.outputs.latest }}
- cache-from: type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache
- cache-to: type=registry,ref=${{ env.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache,mode=max
build-args: |
BUILDKIT_INLINE_CACHE=1
+ NODE_ENV=production
+ cache-from: ${{ inputs.skip_build_cache == true && '""' || format('type=registry,ref={0}', env.CACHE_REF) }}
+ cache-to: ${{ inputs.skip_build_cache == true && '""' || format('type=registry,ref={0},mode=max', env.CACHE_REF) }}
labels: |
- org.opencontainers.image.source=${{ gitea.server_url }}/${{ gitea.repository }}
- org.opencontainers.image.revision=${{ gitea.sha }}
+ org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
+ org.opencontainers.image.revision=${{ github.sha }}
+ org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
+
+ - name: 🧮 镜像体积 & 层信息(日志里看一下,避免镜像越来越大)
+ run: |
+ set -euxo pipefail
+ docker pull "${{ steps.meta.outputs.full }}" || true
+ docker image inspect "${{ steps.meta.outputs.full }}" --format='{{.Size}}' 2>/dev/null \
+ | awk '{printf "✅ 镜像体积:%.2f MB\n", $1/1024/1024}' || true
+ docker history --no-trunc "${{ steps.meta.outputs.full }}" 2>/dev/null | head -20 || true
# ============================================================
- # Job 2: SSH 到服务器拉取新镜像并重启
+ # Job 3: SSH 到服务器 → 拉新镜像 → 滚动重启
# ============================================================
deploy:
- name: 部署到服务器
+ name: 部署到生产服务器
needs: build-image
- if: ${{ !cancelled() && !failure() && (inputs.deploy_target == true || github.event_name == 'push') }}
+ # push 事件默认自动部署;手动触发时看 checkbox
+ if: ${{ !cancelled() && !failure() && (github.event_name == 'push' || inputs.deploy_target == true) }}
runs-on: ubuntu-latest
- environment: production
+ timeout-minutes: 25
+ environment:
+ name: production
+ url: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
steps:
- - name: 🌐 SSH 部署到生产服务器
- uses: https://github.com/appleboy/ssh-action@v1.2.0
+ - name: ⚠️ 检查部署所需 Secrets 是否都配置了
+ shell: bash
+ run: |
+ set -euo pipefail
+ MISSING=()
+ [ -n "${{ secrets.DEPLOY_HOST }}" ] || MISSING+=("DEPLOY_HOST")
+ [ -n "${{ secrets.DEPLOY_USER }}" ] || MISSING+=("DEPLOY_USER")
+ [ -n "${{ secrets.DEPLOY_SSH_KEY }}" ] || MISSING+=("DEPLOY_SSH_KEY")
+ if [ ${#MISSING[@]} -gt 0 ]; then
+ echo "❌ 缺少以下 Repository Secrets:${MISSING[*]}"
+ echo "👉 去 Gitea 仓库 → Settings → Secrets and variables → Actions 里添加"
+ exit 1
+ fi
+ echo "✅ 部署 Secrets 配置完整"
+
+ - name: 🚀 一键滚动部署(SSH)
+ uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
+ port: ${{ secrets.DEPLOY_PORT || 22 }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
- port: ${{ secrets.DEPLOY_PORT || 22 }}
- script_stop: true
+ # 部署可能慢,给足 30 分钟
command_timeout: 30m
+ # 出错立刻停,不继续往下跑
+ script_stop: true
script: |
set -euxo pipefail
- # 1) 首次部署时自动创建目录 & 空的 docker-compose.yml/.env
- mkdir -p "${DEPLOY_PATH}"
- cd "${DEPLOY_PATH}"
+ # ---------- 0. 基本参数 ----------
+ IMAGE_NEW="${{ needs.build-image.outputs.image_full }}"
+ IMAGE_LATEST="${{ needs.build-image.outputs.image_latest }}"
+ DEPLOY_DIR="${DEPLOY_PATH}"
+ COMPOSE="docker compose"
+
+ echo "=============================================="
+ echo "🚢 本次部署镜像: ${IMAGE_NEW}"
+ echo "🗂️ 部署目录 : ${DEPLOY_DIR}"
+ echo "👤 执行用户 : $(whoami)"
+ echo "=============================================="
- # 2) 登录 Gitea 镜像仓库(用一个仅 pull 权限的 token 更安全)
- echo "${{ secrets.REGISTRY_PULL_TOKEN || gitea.token }}" \
- | docker login "${GITEA_REGISTRY}" -u "${{ secrets.REGISTRY_PULL_USER || gitea.repository_owner }}" --password-stdin 2>/dev/null || true
+ # ---------- 1. 确保部署目录存在 ----------
+ mkdir -p "${DEPLOY_DIR}"
+ cd "${DEPLOY_DIR}"
- # 3) 拉最新镜像
- IMAGE_LATEST="${GITEA_REGISTRY}/${GITEA_REPO_OWNER}/${IMAGE_NAME}:latest"
- docker pull "$IMAGE_LATEST"
+ # ---------- 2. 登录 Gitea 容器仓库(用 pull token,最小权限) ----------
+ if [ -n "${{ secrets.REGISTRY_PULL_TOKEN }}" ] && [ -n "${{ secrets.REGISTRY_PULL_USER }}" ]; then
+ echo "🔐 使用专用 pull 凭证登录 Gitea Container Registry"
+ echo "${{ secrets.REGISTRY_PULL_TOKEN }}" \
+ | docker login "${GITEA_HOST}" \
+ -u "${{ secrets.REGISTRY_PULL_USER }}" \
+ --password-stdin 2>/dev/null || true
+ fi
+
+ # ---------- 3. 先拉镜像(提前下好,后面 up 不会卡) ----------
+ echo "⬇️ 开始拉取新镜像 ${IMAGE_NEW} ..."
+ docker pull "${IMAGE_NEW}"
+ docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
+ echo "✅ 新镜像拉取完成,并已打 latest tag"
- # 4) 写一个最小的 docker-compose.override.yml,指向刚拉的镜像
- # (避免改动服务器上已配置好的 docker-compose.yml 和 .env)
- cat > docker-compose.override.yml <<'YAML'
+ # ---------- 4. 生成 / 更新 docker-compose.override.yml ----------
+ # 指向新的精确 tag(不是 latest,出问题能快速回滚)
+ cat > docker-compose.override.yml <<YAML
services:
voicehub:
- image: __REPLACE_WITH_IMAGE__
+ image: ${IMAGE_NEW}
YAML
- sed -i "s|__REPLACE_WITH_IMAGE__|${IMAGE_LATEST}|g" docker-compose.override.yml
+ echo "📝 docker-compose.override.yml 已写入,指定镜像=${IMAGE_NEW}"
- # 5) 启动(如果没有 docker-compose.yml 就自动从你的仓库默认模板里下载一份)
+ # ---------- 5. 没有 docker-compose.yml? 自动从仓库下一份模板 ----------
if [ ! -f docker-compose.yml ]; then
- echo "ℹ️ 首次部署:写入默认 docker-compose.yml 模板(请稍后在服务器上编辑 .env)"
- curl -fsSL "${{ gitea.server_url }}/${{ gitea.repository }}/raw/branch/main/docker-compose.yml" \
- -o docker-compose.yml || echo "下载失败,跳过(你需要手动上传 docker-compose.yml)"
+ echo "ℹ️ 未发现 docker-compose.yml,自动从仓库模板下载"
+ TEMPLATE_URL="${GITHUB_SERVER_URL:-https://${GITEA_HOST}}/${GITHUB_REPOSITORY:-${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}}/raw/branch/${GITHUB_REF_NAME:-main}/docker-compose.yml"
+ echo " ↓ URL=${TEMPLATE_URL}"
+ curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
+ || (
+ echo "⚠️ 自动下载模板失败,跳过(你需要手动上传 docker-compose.yml 和 .env)"
+ # 不 exit,因为用户可能已经有一套自定义部署
+ )
+ fi
+
+ # ---------- 6. 没有 .env? 给一份占位(方便首次部署发现问题) ----------
+ if [ ! -f .env ]; then
+ echo "================================================================="
+ echo "⚠️ WARNING:${DEPLOY_DIR}/.env 不存在!!!"
+ echo "👉 第一次部署请先在服务器上创建 .env(至少填 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL)"
+ echo "👉 参考仓库根的 .env.example 或部署文档"
+ echo "================================================================="
+ # 不退出,因为 compose 会用 environment 默认值尝试启动(只是大概率连不上 DB)
fi
- # 6) 健康拉取 + 滚动重启(先拉好再 up,downtime 最小)
- docker compose pull --quiet voicehub 2>/dev/null || true
- docker compose up -d --remove-orphans
+ # ---------- 7. 滚动启动(如果只有 voicehub 单服务,不会动 postgres/redis) ----------
+ echo "🔄 docker compose up -d(只重建变更过的 voicehub)..."
+ ${COMPOSE} up -d --remove-orphans
- # 7) 等健康检查通过,最多等 120s
- for i in $(seq 1 24); do
- if docker compose ps --format json 2>/dev/null | grep -q '"Health":"healthy"'; then
- echo "✅ 容器健康"
+ # ---------- 8. 等待应用变 healthy,最多 2 分钟 ----------
+ MAX_WAIT=24 # 24 × 5s = 2min
+ WAITED=0
+ echo "⏳ 等待容器 healthcheck 通过(最多 120s)..."
+ while [ ${WAITED} -lt ${MAX_WAIT} ]; do
+ STATUS="$(${COMPOSE} ps --format json 2>/dev/null || true)"
+ if echo "${STATUS}" | grep -q '"Health":"healthy"'; then
+ echo "✅ voicehub 容器健康检查通过"
break
fi
+ # 同时也检查是否已经 running 但健康检查还没跑完(防止 healthcheck 写太严误判)
+ if echo "${STATUS}" | grep -q '"State":"running"'; then
+ :
+ else
+ # 连 running 都不是 → 直接打印日志末尾 60 行帮忙排错
+ echo "⚠️ voicehub 还没 running,当前状态:"
+ ${COMPOSE} ps || true
+ fi
sleep 5
+ WAITED=$((WAITED + 1))
done
+ if [ ${WAITED} -ge ${MAX_WAIT} ]; then
+ echo "⚠️ 等待超时,下面是最近 120 行 voicehub 日志:"
+ ${COMPOSE} logs --tail=120 voicehub || true
+ exit 1
+ fi
- # 8) 清理旧镜像(只留最近 2 个 tag,省磁盘)
+ # ---------- 9. 清理旧镜像(保留最近 2 个 tag + latest) ----------
+ echo "🧹 清理 3 天前的旧镜像 / 悬空层,防止磁盘爆"
docker image prune -af --filter "until=72h" || true
+ docker builder prune -af || true
- echo "🎉 部署完成!镜像=${{ needs.build-image.outputs.image_full }}"
+ # ---------- 10. 回滚指引 ----------
+ echo ""
+ echo "================================================================="
+ echo "🎉 部署成功!访问: ${{ secrets.NUXT_PUBLIC_SITE_URL || '请查看你的站点 URL' }}"
+ echo "🔁 如需紧急回滚到上一个版本,在服务器执行:"
+ echo " cd ${DEPLOY_DIR}"
+ echo " # 1) 先看有哪些 tag:"
+ echo " docker images | grep ${IMAGE_NAME} | head -5"
+ echo " # 2) 选一个老 tag 填到 override:"
+ echo " echo 'services:\n voicehub:\n image: ${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:<旧TAG>' > docker-compose.override.yml"
+ echo " docker compose up -d"
+ echo "================================================================="
diff --git a/scripts/setup-deploy-user.sh b/scripts/setup-deploy-user.sh
new file mode 100644
index 0000000..38ac0b1
--- /dev/null
+++ b/scripts/setup-deploy-user.sh
@@ -0,0 +1,159 @@
+#!/usr/bin/env bash
+# ============================================================================
+# setup-deploy-user.sh
+# 在「生产服务器」上一键做好 CI 自动部署所需的系统配置:
+# 1) 新建 deploy 用户(低权限,禁止密码登录,只能 key 登录)
+# 2) 生成 ed25519 SSH 密钥对(私钥你粘去 Gitea Secrets DEPLOY_SSH_KEY)
+# 3) 加入 docker 组,允许 docker compose 操作
+# 4) 给 /opt/byw-voicehub 目录读写权限(setfacl)
+# 5) 打印私钥内容 & 公钥指纹,便于你检查和复制
+#
+# 用法(在生产服务器上,sudo 运行):
+# sudo bash scripts/setup-deploy-user.sh [--deploy-dir /opt/byw-voicehub]
+# ============================================================================
+set -euo pipefail
+
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[1;36m'; NC='\033[0m'
+info(){ echo -e "${GREEN}[INFO]${NC} $*"; }
+warn(){ echo -e "${YELLOW}[WARN]${NC} $*"; }
+err(){ echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
+ok(){ echo -e "${CYAN}[ OK ]${NC} $*"; }
+
+DEPLOY_DIR="/opt/byw-voicehub"
+DEPLOY_USER="deploy"
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --deploy-dir) DEPLOY_DIR="$2"; shift 2 ;;
+ --user) DEPLOY_USER="$2"; shift 2 ;;
+ -h|--help)
+ sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *) err "未知参数 $1(--help 查看帮助)" ;;
+ esac
+done
+
+[[ $EUID -eq 0 ]] || err "请用 sudo 运行本脚本"
+command -v docker >/dev/null 2>&1 || err "先装完 Docker 再跑本脚本(路径一的教程里有)"
+command -v setfacl >/dev/null 2>&1 || {
+ warn "没装 acl 工具,现在 apt install"
+ apt-get update && apt-get install -y acl
+}
+
+# -------- 1. 建用户 --------
+if ! id -u "${DEPLOY_USER}" >/dev/null 2>&1; then
+ useradd --create-home --shell /usr/bin/bash --disabled-password "${DEPLOY_USER}"
+ ok "新建用户:${DEPLOY_USER}(无密码,仅 SSH key 登录)"
+else
+ info "用户 ${DEPLOY_USER} 已存在,跳过创建"
+fi
+
+# -------- 2. 加入 docker 组 --------
+usermod -aG docker "${DEPLOY_USER}"
+ok "${DEPLOY_USER} 加入 docker 组(有权限执行 docker compose)"
+
+# -------- 3. 生成 SSH 密钥(无密码) --------
+SSH_DIR="/home/${DEPLOY_USER}/.ssh"
+mkdir -p "${SSH_DIR}"
+chmod 700 "${SSH_DIR}"
+chown "${DEPLOY_USER}:${DEPLOY_USER}" "${SSH_DIR}"
+
+KEY_PATH="${SSH_DIR}/id_ed25519"
+if [[ ! -f "${KEY_PATH}" ]]; then
+ sudo -u "${DEPLOY_USER}" ssh-keygen -t ed25519 \
+ -C "gitea-actions-deploy-$(hostname)" \
+ -f "${KEY_PATH}" \
+ -N "" >/dev/null
+ ok "SSH ed25519 密钥对生成:${KEY_PATH}"
+else
+ info "密钥对已存在,跳过生成:${KEY_PATH}"
+fi
+
+# 公钥加入 authorized_keys(允许持对应私钥的人以 deploy 用户 ssh 登录)
+AUTH_KEYS="${SSH_DIR}/authorized_keys"
+touch "${AUTH_KEYS}"
+PUBKEY_CONTENT="$(cat "${KEY_PATH}.pub")"
+if ! grep -qF "${PUBKEY_CONTENT}" "${AUTH_KEYS}" 2>/dev/null; then
+ echo "${PUBKEY_CONTENT}" >> "${AUTH_KEYS}"
+ ok "公钥已写入 authorized_keys"
+fi
+chmod 600 "${AUTH_KEYS}"
+chmod 644 "${KEY_PATH}.pub"
+chmod 600 "${KEY_PATH}"
+chown -R "${DEPLOY_USER}:${DEPLOY_USER}" "${SSH_DIR}"
+
+# -------- 4. sshd 加固(只允许公钥登录;别让 root 密码登录;deploy 禁止转发)--------
+SSHD_SNIPPET="/etc/ssh/sshd_config.d/99-deploy-user.conf"
+if [[ ! -f "${SSHD_SNIPPET}" ]]; then
+ mkdir -p /etc/ssh/sshd_config.d
+ cat > "${SSHD_SNIPPET}" <<SSHD
+# 针对 deploy 用户:仅允许公钥登录,禁用 TCP 转发等
+Match User ${DEPLOY_USER}
+ PasswordAuthentication no
+ PubkeyAuthentication yes
+ PermitTTY yes
+ AllowTcpForwarding no
+ X11Forwarding no
+SSHD
+ systemctl reload sshd 2>/dev/null || service ssh reload || true
+ ok "sshd 配置已加固(deploy 只允许公钥登录,并禁用端口转发)"
+fi
+
+# -------- 5. 创建部署目录并给 deploy 权限 --------
+mkdir -p "${DEPLOY_DIR}"
+# 既设组权限,又加 ACL,确保后续新建文件也有
+setfacl -R -m "u:${DEPLOY_USER}:rwX" "${DEPLOY_DIR}"
+setfacl -R -d -m "u:${DEPLOY_USER}:rwX" "${DEPLOY_DIR}"
+ok "部署目录 ${DEPLOY_DIR} 已给 ${DEPLOY_USER} 完整读写权限(+ 新建文件继承权限)"
+
+# 如果目录里有 compose/环境文件,顺便改一下属组,避免「root 建的文件 deploy 改不了」
+chgrp -R docker "${DEPLOY_DIR}" 2>/dev/null || true
+chmod -R g+rwX "${DEPLOY_DIR}" 2>/dev/null || true
+
+# -------- 6. 验证 docker 是否真能用(不报错)--------
+DOCKER_RC=0
+sudo -u "${DEPLOY_USER}" docker ps >/dev/null 2>&1 || DOCKER_RC=$?
+if [[ ${DOCKER_RC} -eq 0 ]]; then
+ ok "✅ ${DEPLOY_USER} 可以执行 docker 命令"
+else
+ warn "⚠️ ${DEPLOY_USER} 暂时跑不了 docker(可能需要重新登录一次刷新组),部署时不会有影响(但验证时会再触发一次)"
+fi
+
+# -------- 7. 打印信息 --------
+FINGERPRINT="$(ssh-keygen -l -f "${KEY_PATH}.pub" 2>/dev/null || true)"
+PUBKEY_CONTENT="$(cat "${KEY_PATH}.pub")"
+PRIVATE_CONTENT="$(cat "${KEY_PATH}")"
+
+clear
+echo -e "${GREEN}================================================================================${NC}"
+echo -e "${GREEN} 🎉 部署用户 & SSH 密钥 配置完成!${NC}"
+echo -e "${GREEN}================================================================================${NC}"
+echo -e " 👤 部署用户名 :${CYAN}${DEPLOY_USER}${NC}"
+echo -e " 📂 部署目录 :${CYAN}${DEPLOY_DIR}${NC}"
+echo -e " 🔑 私钥文件路径 :${CYAN}${KEY_PATH}${NC}"
+echo -e " 🧾 公钥指纹 :${FINGERPRINT}"
+echo -e " 🧾 公钥内容(方便核对):"
+echo -e " ${PUBKEY_CONTENT}"
+echo ""
+echo -e "${YELLOW}================================================================================${NC}"
+echo -e "${YELLOW} ⚠️ 下面这一整段是【私钥】,完整复制(从 BEGIN 到 END 包括两行)到:${NC}"
+echo -e "${YELLOW} Gitea 仓库 → Settings → Secrets and variables → Actions → New Secret${NC}"
+echo -e "${YELLOW} Name = DEPLOY_SSH_KEY${NC}"
+echo -e "${YELLOW} Value = 粘贴下面一整段(包括 -----BEGIN/END-----)${NC}"
+echo -e "${YELLOW}================================================================================${NC}"
+echo ""
+echo "${PRIVATE_CONTENT}"
+echo ""
+echo -e "${YELLOW}================================================================================${NC}"
+echo -e "${YELLOW} ⚠️ 上面就是 DEPLOY_SSH_KEY,不要在任何地方贴到别的文档或聊天。看完 Ctrl+L 清屏。${NC}"
+echo -e "${YELLOW}================================================================================${NC}"
+echo ""
+echo -e "${CYAN}👉 同时你还需要补以下几个 Repository Secrets(都在同一页添加):${NC}"
+echo -e " • DEPLOY_HOST = 你的服务器公网 IP 或域名"
+echo -e " • DEPLOY_PORT = SSH 端口(一般 22)"
+echo -e " • DEPLOY_USER = ${DEPLOY_USER}(就是这个用户名)"
+echo -e " • REGISTRY_TOKEN = 你的 Gitea 个人访问令牌(Package 读写权限)"
+echo -e " • REGISTRY_PULL_USER = 同 REGISTRY_USER(服务器上拉镜像用,一般和 REGISTRY_USER 一样)"
+echo -e " • REGISTRY_PULL_TOKEN= 上面令牌同一个或新建一个仅 pull 权限的更安全"
+echo -e " • NUXT_PUBLIC_SITE_URL = https://你的域名.com(用来显示部署成功后的跳转按钮)"
+echo ""
diff --git a/scripts/setup-runner.sh b/scripts/setup-runner.sh
new file mode 100644
index 0000000..bc424e7
--- /dev/null
+++ b/scripts/setup-runner.sh
@@ -0,0 +1,211 @@
+#!/usr/bin/env bash
+# ============================================================================
+# setup-runner.sh —— 一键安装并注册 Gitea Actions Runner(act_runner)
+#
+# 使用方法:
+# ① 先在你的 Gitea 仓库页面 → Settings → Actions → Runners → 「Create Runner」
+# 选「Linux + amd64」,它会给你一条注册命令,里面有一串 registration token,
+# 格式像: GFxxxxxxxxxxxxxxxxxxxx ,复制它。
+# ② 再在你要跑 Runner 的机器上执行:
+# curl -fsSL https://<你的-gitea>/<仓库>/-/raw/branch/main/scripts/setup-runner.sh \
+# | sudo bash -s -- --gitea https://<你的-gitea> --token GFxxxxxxxxxxxxxxxxxxxx
+#
+# 或者直接本地执行:
+# sudo bash scripts/setup-runner.sh \
+# --gitea https://git.baoyiwai.com \
+# --token GFxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+# ============================================================================
+set -euo pipefail
+
+# ---------- 颜色 ----------
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
+info() { echo -e "${GREEN}[INFO]${NC} $*"; }
+warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
+err() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
+
+# ---------- 解析参数 ----------
+GITEA_URL=""
+REG_TOKEN=""
+RUNNER_NAME="runner-$(hostname | tr '[:upper:]' '[:lower:]')"
+RUNNER_USER="gitrunner"
+INSTALL_DIR="/opt/act_runner"
+
+while [[ $# -gt 0 ]]; do
+ case "$1" in
+ --gitea) GITEA_URL="${2}"; shift 2 ;;
+ --token) REG_TOKEN="${2}"; shift 2 ;;
+ --name) RUNNER_NAME="${2}"; shift 2 ;;
+ --user) RUNNER_USER="${2}"; shift 2 ;;
+ --dir) INSTALL_DIR="${2}"; shift 2 ;;
+ -h|--help)
+ sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
+ exit 0
+ ;;
+ *) err "未知参数:$1(--help 查看帮助)" ;;
+ esac
+done
+
+[[ -n "${GITEA_URL}" ]] || err "必须传 --gitea https://你的-gitea-地址"
+[[ -n "${REG_TOKEN}" ]] || err "必须传 --token GF……(Gitea 页面里拿的注册 token)"
+[[ $EUID -eq 0 ]] || err "请用 sudo 运行本脚本(需要写 /opt / /etc/systemd/system)"
+
+# 去掉末尾 /
+GITEA_URL="${GITEA_URL%/}"
+
+# ---------- 0. 架构检测 ----------
+ARCH="$(uname -m)"
+case "${ARCH}" in
+ x86_64|amd64) PLATFORM="linux-amd64" ;;
+ aarch64|arm64) PLATFORM="linux-arm64" ;;
+ armv7l) PLATFORM="linux-arm" ;;
+ *) err "暂不支持的架构:${ARCH}(需要 x86_64 / aarch64 / armv7)" ;;
+esac
+info "检测到架构:${ARCH} → 下载版本:${PLATFORM}"
+
+# ---------- 1. 安装依赖:docker(Runner 要用 Docker-in-Docker 执行 buildx 等)----------
+if ! command -v docker >/dev/null 2>&1; then
+ info "检测到没装 Docker,现在用官方一键脚本安装(国内自动换清华源)..."
+ curl -fsSL https://get.docker.com -o /tmp/get-docker.sh || err "下载 docker-install 脚本失败"
+ if curl -sI https://mirrors.tuna.tsinghua.edu.cn >/dev/null; then
+ bash /tmp/get-docker.sh --mirror Aliyun || bash /tmp/get-docker.sh
+ else
+ bash /tmp/get-docker.sh
+ fi
+ systemctl enable --now docker
+ info "✅ Docker 安装完成:$(docker --version)"
+else
+ info "✅ Docker 已存在:$(docker --version)"
+fi
+
+# ---------- 2. 建运行用户 gitrunner,加入 docker 组 ----------
+if ! id -u "${RUNNER_USER}" >/dev/null 2>&1; then
+ useradd -r -s /usr/sbin/nologin -d "${INSTALL_DIR}" "${RUNNER_USER}"
+ info "✅ 新建系统用户 ${RUNNER_USER}"
+else
+ info "ℹ️ 用户 ${RUNNER_USER} 已存在,跳过创建"
+fi
+usermod -aG docker "${RUNNER_USER}"
+info "✅ 把 ${RUNNER_USER} 加入 docker 组(允许操作 Docker daemon)"
+
+mkdir -p "${INSTALL_DIR}"
+chown -R "${RUNNER_USER}:${RUNNER_USER}" "${INSTALL_DIR}"
+
+# ---------- 3. 下载最新 act_runner ----------
+info "⬇️ 正在拉取 act_runner 最新 release 元数据..."
+LATEST_JSON="$(curl -fsSL https://gitea.com/api/v1/repos/gitea/act_runner/releases/latest || true)"
+if [[ -z "${LATEST_JSON}" ]]; then
+ warn "gitea.com 不通,改用硬编码版本 0.2.10(你之后可手动升级)"
+ VERSION="0.2.10"
+else
+ VERSION="$(echo "${LATEST_JSON}" | grep -oE '"tag_name":\s*"[^"]+"' | head -1 | cut -d'"' -f4 | sed 's/^v//')"
+ [[ -n "${VERSION}" ]] || VERSION="0.2.10"
+fi
+BIN_URL="https://dl.gitea.com/act_runner/${VERSION}/act_runner-${VERSION}-${PLATFORM}"
+info "⬇️ 下载 act_runner v${VERSION}:${BIN_URL}"
+curl -fsSL "${BIN_URL}" -o /usr/local/bin/act_runner \
+ || err "下载失败。请手动打开 ${BIN_URL} 看是否 404,然后换个版本"
+chmod +x /usr/local/bin/act_runner
+info "✅ act_runner 已安装到 /usr/local/bin,版本:$(act_runner --version 2>&1 || true)"
+
+# ---------- 4. 注册 Runner ----------
+cd "${INSTALL_DIR}"
+info "🔗 开始注册 Runner 到 ${GITEA_URL}(名称:${RUNNER_NAME})"
+
+# 切到 gitrunner 用户注册,避免 root 跑(安全)
+rm -f .runner 2>/dev/null || true
+sudo -u "${RUNNER_USER}" env HOME="${INSTALL_DIR}" \
+ act_runner register \
+ --instance "${GITEA_URL}" \
+ --token "${REG_TOKEN}" \
+ --name "${RUNNER_NAME}" \
+ --labels "ubuntu-latest:docker://catthehacker/ubuntu:act-22.04" \
+ --no-interactive \
+ || err "注册失败!常见原因:\n 1) token 不对(复制时别带空格)\n 2) Runner 机器出不了网到 ${GITEA_URL}\n 3) Gitea 站点时间和这台机器差太远(证书验证失败)"
+
+chown "${RUNNER_USER}:${RUNNER_USER}" .runner 2>/dev/null || true
+
+# ---------- 5. 生成配置文件(建议开启 docker.host 特权)----------
+CONFIG_FILE="${INSTALL_DIR}/config.yaml"
+if [[ ! -f "${CONFIG_FILE}" ]]; then
+ cat > "${CONFIG_FILE}" <<'YAML'
+log:
+ level: info
+runner:
+ file: .runner
+ capacity: 2 # 同一台 Runner 最多同时跑 2 个 Job
+ envs:
+ GOPROXY: https://goproxy.cn,direct
+ NPM_CONFIG_REGISTRY: https://registry.npmmirror.com
+ timeout: 3h
+cache:
+ enabled: true
+ dir: /opt/act_runner/cache
+container:
+ network: "bridge"
+ privileged: true # build-push-action 用到 Docker daemon
+ options: |
+ --add-host "host.docker.internal:host-gateway"
+YAML
+ chown "${RUNNER_USER}:${RUNNER_USER}" "${CONFIG_FILE}"
+ mkdir -p "${INSTALL_DIR}/cache"
+ chown -R "${RUNNER_USER}:${RUNNER_USER}" "${INSTALL_DIR}/cache"
+ info "✅ 默认 config 写入 ${CONFIG_FILE}"
+fi
+
+# ---------- 6. 写 systemd 服务,开机自启 ----------
+UNIT_FILE="/etc/systemd/system/gitea-act-runner.service"
+cat > "${UNIT_FILE}" <<SYSTEMD
+[Unit]
+Description=Gitea Actions Runner (${RUNNER_NAME})
+After=network.target docker.service
+Requires=docker.service
+
+[Service]
+Type=simple
+User=${RUNNER_USER}
+Group=${RUNNER_USER}
+WorkingDirectory=${INSTALL_DIR}
+Environment=HOME=${INSTALL_DIR}
+ExecStart=/usr/local/bin/act_runner daemon --config ${CONFIG_FILE}
+Restart=always
+RestartSec=5
+KillMode=mixed
+TimeoutStopSec=30
+
+# 安全加固
+NoNewPrivileges=true
+PrivateTmp=true
+ProtectSystem=strict
+ReadWritePaths=${INSTALL_DIR} /var/run/docker.sock /tmp /var/tmp
+
+[Install]
+WantedBy=multi-user.target
+SYSTEMD
+
+systemctl daemon-reload
+systemctl enable --now gitea-act-runner
+info "✅ systemd 服务已写入并启动:${UNIT_FILE}"
+
+sleep 3 # 给 daemon 点时间注册
+info "📌 当前 Runner 状态:"
+systemctl --no-pager status gitea-act-runner | head -15 || true
+info "📌 最近 30 行日志(验证 OK):"
+journalctl --no-pager -u gitea-act-runner --since "2 minutes ago" -n 30 || true
+
+echo ""
+echo -e "${GREEN}===========================================================================${NC}"
+echo -e "${GREEN} 🎉 Gitea Actions Runner 安装 & 注册 & 开机自启 全部完成!${NC}"
+echo -e "${GREEN}===========================================================================${NC}"
+echo " ✅ Runner 名称 : ${RUNNER_NAME}"
+echo " ✅ 运行用户 : ${RUNNER_USER}"
+echo " ✅ 安装目录 : ${INSTALL_DIR}"
+echo " ✅ 绑定 Gitea : ${GITEA_URL}"
+echo ""
+echo " 👉 下一步验证(建议立刻做):"
+echo " 1. 浏览器打开:${GITEA_URL} → 仓库 → Settings → Actions → Runners"
+echo " 应该能看到一个 Runner,状态 = Idle"
+echo " 2. 回到终端,模拟跑一个 workflow 没问题就彻底大功告成!"
+echo " 👉 常用命令:"
+echo " systemctl {start|stop|restart|status} gitea-act-runner"
+echo " journalctl -u gitea-act-runner -f # 实时看日志"
+echo ""
--
2.45.1.windows.1
@@ -0,0 +1,157 @@
From 08a6a669771a8352bb19649cbe534958e455ddb9 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 08:56:16 +0800
Subject: [PATCH 03/58] =?UTF-8?q?ci:=20=E9=80=82=E9=85=8D=20Gitea=201.27+?=
=?UTF-8?q?=20=E7=9A=84=20Runner=20=E6=B3=A8=E5=86=8C=20Token=EF=BC=88RGT/?=
=?UTF-8?q?RGO/RG=20=E4=B8=89=E7=A7=8D=E5=89=8D=E7=BC=80=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/setup-runner.sh | 87 ++++++++++++++++++++++++++++++++++-------
1 file changed, 72 insertions(+), 15 deletions(-)
diff --git a/scripts/setup-runner.sh b/scripts/setup-runner.sh
index bc424e7..2599738 100644
--- a/scripts/setup-runner.sh
+++ b/scripts/setup-runner.sh
@@ -2,30 +2,58 @@
# ============================================================================
# setup-runner.sh —— 一键安装并注册 Gitea Actions Runner(act_runner)
#
-# 使用方法:
-# ① 先在你的 Gitea 仓库页面 → Settings → Actions → Runners → 「Create Runner」
-# 选「Linux + amd64」,它会给你一条注册命令,里面有一串 registration token,
-# 格式像: GFxxxxxxxxxxxxxxxxxxxx ,复制它。
-# ② 再在你要跑 Runner 的机器上执行:
-# curl -fsSL https://<你的-gitea>/<仓库>/-/raw/branch/main/scripts/setup-runner.sh \
-# | sudo bash -s -- --gitea https://<你的-gitea> --token GFxxxxxxxxxxxxxxxxxxxx
+# ✅ 兼容 Gitea 1.21 ~ 最新(含 1.27.x),新版没有 Platform 下拉菜单。
#
-# 或者直接本地执行:
-# sudo bash scripts/setup-runner.sh \
-# --gitea https://git.baoyiwai.com \
-# --token GFxxxxxxxxxxxxxxxxxxxxxxxxxxxx
+# 使用方法(两种模式任选其一,推荐 模式 A 最省事):
+#
+# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+# 模式 A:仓库级 Runner(只给这个 VoiceHub 仓库用,最常用、权限最小)
+# 路径:你的仓库 → Settings → Actions → Runners → 右上角
+# → 新版(1.21+)这里没有 Platform 选择,直接有一个输入框
+# 写着「Registration token」或「注册令牌」
+# → 点右边的 📋 复制按钮,得到一串以 R 开头的 token
+# 例: RGT8FZxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxV
+#
+# 机器上执行:
+# sudo bash scripts/setup-runner.sh \
+# --scope repo --gitea https://你的-gitea --token RGTxxxx...
+#
+# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+# 模式 B:组织级 Runner(同一个 Gitea 组织下所有仓库都能用)
+# 路径:组织主页 → Settings → Actions → Runners → 复制 Registration token
+# (Token 开头一般是 RGOxxxx)
+#
+# 执行:
+# sudo bash scripts/setup-runner.sh \
+# --scope org --gitea https://你的-gitea --token RGOxxxx...
+#
+# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
+# 模式 C:全局/站点级 Runner(Gitea 全站所有仓库都能用,需站点管理员)
+# 路径:站点管理后台(Site Administration)→ Actions → Runners
+# → 复制 Registration token(开头一般是 RGxxxx)
+#
+# 执行:
+# sudo bash scripts/setup-runner.sh \
+# --scope global --gitea https://你的-gitea --token RGxxxx...
+#
+# 其他可选参数:
+# --name "my-runner-01" 自定义 Runner 名字(默认是 runner-主机名)
+# --user gitrunner 运行用户(默认 gitrunner)
+# --dir /opt/act_runner 安装目录(默认 /opt/act_runner)
# ============================================================================
set -euo pipefail
# ---------- 颜色 ----------
-RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m'
+RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[1;36m'; NC='\033[0m'
info() { echo -e "${GREEN}[INFO]${NC} $*"; }
warn() { echo -e "${YELLOW}[WARN]${NC} $*"; }
err() { echo -e "${RED}[ERROR]${NC} $*" >&2; exit 1; }
+ok() { echo -e "${CYAN}[ OK ]${NC} $*"; }
# ---------- 解析参数 ----------
GITEA_URL=""
REG_TOKEN=""
+SCOPE="repo" # repo | org | global(默认 repo,绝大多数人的场景)
RUNNER_NAME="runner-$(hostname | tr '[:upper:]' '[:lower:]')"
RUNNER_USER="gitrunner"
INSTALL_DIR="/opt/act_runner"
@@ -34,11 +62,12 @@ while [[ $# -gt 0 ]]; do
case "$1" in
--gitea) GITEA_URL="${2}"; shift 2 ;;
--token) REG_TOKEN="${2}"; shift 2 ;;
+ --scope) SCOPE="${2}"; shift 2 ;;
--name) RUNNER_NAME="${2}"; shift 2 ;;
--user) RUNNER_USER="${2}"; shift 2 ;;
--dir) INSTALL_DIR="${2}"; shift 2 ;;
-h|--help)
- sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'
+ sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*) err "未知参数:$1(--help 查看帮助)" ;;
@@ -46,12 +75,25 @@ while [[ $# -gt 0 ]]; do
done
[[ -n "${GITEA_URL}" ]] || err "必须传 --gitea https://你的-gitea-地址"
-[[ -n "${REG_TOKEN}" ]] || err "必须传 --token GF……(Gitea 页面里拿的注册 token)"
+[[ -n "${REG_TOKEN}" ]] || err "必须传 --token(从 Gitea 页面的 Registration token 复制,Rxx 开头的一串)"
+case "${SCOPE}" in
+ repo|org|global) ;;
+ *) err "--scope 必须是 repo | org | global 其中之一" ;;
+esac
[[ $EUID -eq 0 ]] || err "请用 sudo 运行本脚本(需要写 /opt / /etc/systemd/system)"
# 去掉末尾 /
GITEA_URL="${GITEA_URL%/}"
+echo ""
+echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo -e "${CYAN} 🚀 开始部署 Gitea Actions Runner (兼容 1.21 ~ 1.27+)${NC}"
+echo -e "${CYAN} • 绑定 Gitea : ${GITEA_URL}${NC}"
+echo -e "${CYAN} • 作用域 : ${SCOPE}${NC}"
+echo -e "${CYAN} • Runner 名称 : ${RUNNER_NAME}${NC}"
+echo -e "${CYAN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+echo ""
+
# ---------- 0. 架构检测 ----------
ARCH="$(uname -m)"
case "${ARCH}" in
@@ -60,7 +102,22 @@ case "${ARCH}" in
armv7l) PLATFORM="linux-arm" ;;
*) err "暂不支持的架构:${ARCH}(需要 x86_64 / aarch64 / armv7)" ;;
esac
-info "检测到架构:${ARCH} → 下载版本:${PLATFORM}"
+ok "检测到架构:${ARCH} → 下载版本:${PLATFORM}"
+
+# ---------- 0.5 给新版 Gitea 用户的 Token 格式提示 ----------
+case "${REG_TOKEN}" in
+ RG*) ok "Token 前缀 RG:全局/站点级 Runner Token ✅" ;;
+ RGO*) ok "Token 前缀 RGO:组织级 Runner Token ✅" ;;
+ RGT*) ok "Token 前缀 RGT:仓库级 Runner Token ✅(最常用)" ;;
+ R[0-9A-Za-z]*)
+ warn "Token 不是标准 RG/RGO/RGT 前缀。新版本 Gitea 里 Registration token 应该是 Rxx 开头。"
+ warn " 如果你是从旧文档里拿的 GFxxxxx(旧版),在 1.27 里已经不再用这种格式了!"
+ warn " → 请回到仓库/组织/站点的 Actions → Runners 页面,点击 「创建 Runner」或「Registration Token」"
+ ;;
+ *)
+ warn "Token 前缀不匹配任何已知格式:$REG_TOKEN。请核对复制的内容是否少选。"
+ ;;
+esac
# ---------- 1. 安装依赖:docker(Runner 要用 Docker-in-Docker 执行 buildx 等)----------
if ! command -v docker >/dev/null 2>&1; then
--
2.45.1.windows.1
@@ -0,0 +1,61 @@
From ed66d2d79ff8f0de4e4eac9ee7716232be3746d2 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 09:01:23 +0800
Subject: [PATCH 04/58] =?UTF-8?q?fix:=20=E5=85=BC=E5=AE=B9=E4=B8=AD?=
=?UTF-8?q?=E6=96=87=20Gitea=201.27=20=E7=9A=84=E7=BA=AF=E5=AD=97=E6=AF=8D?=
=?UTF-8?q?=E6=95=B0=E5=AD=97=20Registration=20Token?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/setup-runner.sh | 28 ++++++++++++++++++++--------
1 file changed, 20 insertions(+), 8 deletions(-)
diff --git a/scripts/setup-runner.sh b/scripts/setup-runner.sh
index 2599738..2929fee 100644
--- a/scripts/setup-runner.sh
+++ b/scripts/setup-runner.sh
@@ -105,19 +105,31 @@ esac
ok "检测到架构:${ARCH} → 下载版本:${PLATFORM}"
# ---------- 0.5 给新版 Gitea 用户的 Token 格式提示 ----------
+# 不同版本 / 语言包的 Token 前缀不一样,兼容 4 种真实存在的格式:
+# • 英文 1.21+ 仓库级 RGTxxxx / 组织级 RGOxxxx / 全局 RGxxxx
+# • 中文语言包 / 某些二次分发版本:纯字母数字 20~64 位(如 LHLDAwsho7vVSukoEeKhft)
+TOKEN_LEN="${#REG_TOKEN}"
+ok "Token 长度 = ${TOKEN_LEN} 位"
case "${REG_TOKEN}" in
- RG*) ok "Token 前缀 RG:全局/站点级 Runner Token ✅" ;;
- RGO*) ok "Token 前缀 RGO:组织级 Runner Token ✅" ;;
- RGT*) ok "Token 前缀 RGT:仓库级 Runner Token ✅(最常用)" ;;
- R[0-9A-Za-z]*)
- warn "Token 不是标准 RG/RGO/RGT 前缀。新版本 Gitea 里 Registration token 应该是 Rxx 开头。"
- warn " 如果你是从旧文档里拿的 GFxxxxx(旧版),在 1.27 里已经不再用这种格式了!"
- warn " → 请回到仓库/组织/站点的 Actions → Runners 页面,点击 「创建 Runner」或「Registration Token」"
+ # 英文原版前缀
+ RG*) info "• Token 前缀 RG:全局/站点级(英文原版)✅" ;;
+ RGO*) info "• Token 前缀 RGO:组织级(英文原版)✅" ;;
+ RGT*) info "• Token 前缀 RGT:仓库级(英文原版)✅" ;;
+ # 纯字母数字(中文 UI / 二开版本常见)
+ [A-Za-z0-9]\{16,128\})
+ ok "• 纯字母数字 Token(中文 UI / 某些二开版本常见)✅,和截图里的「注册令牌」一致"
;;
+ # 其他
*)
- warn "Token 前缀不匹配任何已知格式:$REG_TOKEN。请核对复制的内容是否少选。"
+ warn "• Token 看起来不是 Gitea 常见格式。不过也可能是新版改动,继续尝试注册..."
+ warn " 如果注册报 401/403,建议回到页面点一次「重置注册令牌」重新复制。"
;;
esac
+# 基本字符集校验(防止复制时粘了空格或换行)
+if [[ ! "${REG_TOKEN}" =~ ^[A-Za-z0-9]+$ ]]; then
+ warn "⚠️ Token 里含有非字母数字的字符,很可能是复制时多带了空格/换行/标点!"
+ warn " 建议回到仓库页面点 📋 复制按钮(不要手选文字)。"
+fi
# ---------- 1. 安装依赖:docker(Runner 要用 Docker-in-Docker 执行 buildx 等)----------
if ! command -v docker >/dev/null 2>&1; then
--
2.45.1.windows.1
@@ -0,0 +1,186 @@
From a027816831e667cf5df65d3b62f92d8188d7173b Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 09:13:06 +0800
Subject: [PATCH 05/58] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Gitea=20Runne?=
=?UTF-8?q?r=20=E4=B8=8B=E8=BD=BD=20404=EF=BC=88v3+=20=E6=94=B9=E5=90=8D?=
=?UTF-8?q?=20gitea-runner=EF=BC=8C=E6=94=B9=E4=B8=BA=20assets=20=E5=8C=B9?=
=?UTF-8?q?=E9=85=8D=20+=20=E5=A4=9A=E6=BA=90=E5=9B=9E=E9=80=80=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/setup-runner.sh | 136 ++++++++++++++++++++++++++++++++++------
1 file changed, 118 insertions(+), 18 deletions(-)
diff --git a/scripts/setup-runner.sh b/scripts/setup-runner.sh
index 2929fee..5ee9b05 100644
--- a/scripts/setup-runner.sh
+++ b/scripts/setup-runner.sh
@@ -40,6 +40,7 @@
# --name "my-runner-01" 自定义 Runner 名字(默认是 runner-主机名)
# --user gitrunner 运行用户(默认 gitrunner)
# --dir /opt/act_runner 安装目录(默认 /opt/act_runner)
+# --version 3.3.2 【可选】固定安装某版本,不写默认用 Gitea 官方 latest
# ============================================================================
set -euo pipefail
@@ -57,15 +58,16 @@ SCOPE="repo" # repo | org | global(默认 repo,绝大多数人的场景)
RUNNER_NAME="runner-$(hostname | tr '[:upper:]' '[:lower:]')"
RUNNER_USER="gitrunner"
INSTALL_DIR="/opt/act_runner"
-
+PINNED_VERSION="" # 空 = latest,否则比如 "3.3.2" 固定版本
while [[ $# -gt 0 ]]; do
case "$1" in
- --gitea) GITEA_URL="${2}"; shift 2 ;;
- --token) REG_TOKEN="${2}"; shift 2 ;;
- --scope) SCOPE="${2}"; shift 2 ;;
- --name) RUNNER_NAME="${2}"; shift 2 ;;
- --user) RUNNER_USER="${2}"; shift 2 ;;
- --dir) INSTALL_DIR="${2}"; shift 2 ;;
+ --gitea) GITEA_URL="${2}"; shift 2 ;;
+ --token) REG_TOKEN="${2}"; shift 2 ;;
+ --scope) SCOPE="${2}"; shift 2 ;;
+ --name) RUNNER_NAME="${2}"; shift 2 ;;
+ --user) RUNNER_USER="${2}"; shift 2 ;;
+ --dir) INSTALL_DIR="${2}"; shift 2 ;;
+ --version) PINNED_VERSION="${2#v}"; shift 2 ;;
-h|--help)
sed -n '2,60p' "$0" | sed 's/^# \{0,1\}//'
exit 0
@@ -159,22 +161,120 @@ info "✅ 把 ${RUNNER_USER} 加入 docker 组(允许操作 Docker daemon)"
mkdir -p "${INSTALL_DIR}"
chown -R "${RUNNER_USER}:${RUNNER_USER}" "${INSTALL_DIR}"
-# ---------- 3. 下载最新 act_runner ----------
-info "⬇️ 正在拉取 act_runner 最新 release 元数据..."
-LATEST_JSON="$(curl -fsSL https://gitea.com/api/v1/repos/gitea/act_runner/releases/latest || true)"
+# ---------- 3. 下载最新 Gitea Runner(act_runner 0.x → 3.x 改名 gitea-runner)
+# 注意:官方从 v3 起,仓库从 gitea/act_runner 改到了 gitea/runner,且文件名前缀从 act_runner-* → gitea-runner-*。
+# 为避免后续版本再改命名导致 404,这里**直接用 API 返回的 assets[].browser_download_url**,不再硬编码拼文件名。
+# ----------
+# 3.1 决定 release URL(固定版本 or latest)
+if [[ -n "${PINNED_VERSION}" ]]; then
+ REL_URL="https://gitea.com/api/v1/repos/gitea/runner/releases/tags/v${PINNED_VERSION}"
+ info "⬇️ 你指定了固定版本:v${PINNED_VERSION}"
+else
+ REL_URL="https://gitea.com/api/v1/repos/gitea/runner/releases/latest"
+ info "⬇️ 正在获取 Gitea Runner 最新 release 元数据..."
+fi
+LATEST_JSON="$(curl -fsSL "${REL_URL}" 2>/dev/null || true)"
+
+# 3.2 如果 gitea.com 不通(纯内网),直接回退到一个我们已知可用的版本
if [[ -z "${LATEST_JSON}" ]]; then
- warn "gitea.com 不通,改用硬编码版本 0.2.10(你之后可手动升级)"
- VERSION="0.2.10"
+ if [[ -z "${PINNED_VERSION}" ]]; then
+ warn "gitea.com 访问不到(可能是内网环境),自动回退到已知稳定版本:v3.3.2"
+ PINNED_VERSION="3.3.2"
+ fi
+ TAG="v${PINNED_VERSION}"
+ FALLBACK_MODE=1
else
- VERSION="$(echo "${LATEST_JSON}" | grep -oE '"tag_name":\s*"[^"]+"' | head -1 | cut -d'"' -f4 | sed 's/^v//')"
- [[ -n "${VERSION}" ]] || VERSION="0.2.10"
+ TAG="$(echo "${LATEST_JSON}" | grep -oE '"tag_name":\s*"[^"]+"' | head -1 | cut -d'"' -f4)"
+ FALLBACK_MODE=0
+fi
+[[ -n "${TAG}" ]] || TAG="v3.3.2"
+VERSION="${TAG#v}"
+
+# 3.3 优先从 assets 匹配正确 platform 的 browser_download_url
+BIN_URL=""
+ASSET_NAME=""
+if [[ "${FALLBACK_MODE}" -eq 0 && -n "${LATEST_JSON}" ]]; then
+ # 用 awk 过滤:
+ # - 匹配 PLATFORM(例如 linux-amd64)
+ # - 排除 .xz / .sha256 / .exe 等校验和/压缩包
+ # - 两种前缀都兼容:gitea-runner-X.X.X- (新版) 或 act_runner-X.X.X- (旧版)
+ while IFS='|' read -r name url; do
+ [[ -z "${name}" ]] && continue
+ case "${name}" in
+ *"${PLATFORM}"* | *"${PLATFORM//-/_}"*) ;;
+ *) continue ;;
+ esac
+ case "${name}" in
+ *.xz|*.sha256|*.exe|*.tar.gz|*.zip|*checksums*) continue ;;
+ esac
+ ASSET_NAME="${name}"
+ BIN_URL="${url}"
+ break
+ done < <(echo "${LATEST_JSON}" | awk '
+ /"assets": *\[/ {inA=1; next}
+ inA && /"browser_download_url"/ { gsub(/[",]/,"",$2); url=$2 }
+ inA && /"name"/ {
+ gsub(/[",]/,"",$2)
+ if (url!="") { print $2 "|" url; url="" }
+ }
+ inA && /^\]/ {inA=0}
+ ')
+fi
+
+# 3.4 如果 assets 匹配失败(老版本 JSON 格式特殊),手动拼 4 个候选 URL 顺序尝试
+if [[ -z "${BIN_URL}" ]]; then
+ info "assets 自动匹配为空,改用手动候选列表(兼容新旧命名)..."
+ CANDIDATES=(
+ # 新版(v3+)gitea-runner
+ "https://gitea.com/gitea/runner/releases/download/v${VERSION}/gitea-runner-${VERSION}-${PLATFORM}"
+ # github 镜像(有时 gitea.com 慢或挂)
+ "https://github.com/go-gitea/runner/releases/download/v${VERSION}/gitea-runner-${VERSION}-${PLATFORM}"
+ # 旧版 act_runner(v0.2.x 遗留命名)
+ "https://dl.gitea.com/act_runner/${VERSION}/act_runner-${VERSION}-${PLATFORM}"
+ # 旧版 gitea.com
+ "https://gitea.com/gitea/act_runner/releases/download/v${VERSION}/act_runner-${VERSION}-${PLATFORM}"
+ )
+ for C in "${CANDIDATES[@]}"; do
+ info " → 尝试:${C}"
+ if curl -fsSI "${C}" >/dev/null 2>&1; then
+ BIN_URL="${C}"
+ ASSET_NAME="$(basename "${C}")"
+ break
+ fi
+ done
fi
-BIN_URL="https://dl.gitea.com/act_runner/${VERSION}/act_runner-${VERSION}-${PLATFORM}"
-info "⬇️ 下载 act_runner v${VERSION}:${BIN_URL}"
+
+# 3.5 还没拿到 URL → 打印手动下载指引(一定让用户能救回来)
+if [[ -z "${BIN_URL}" ]]; then
+ {
+ echo ""
+ echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+ echo -e "${RED} ❌ 自动下载 Gitea Runner 失败${NC}"
+ echo -e "${RED}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${NC}"
+ echo " 你的架构:${PLATFORM} , 目标版本:${TAG}"
+ echo ""
+ echo " 请在能出网的机器上,手动打开下面 2 个 URL 之一,下载对应的二进制:"
+ echo " • https://gitea.com/gitea/runner/releases/tag/${TAG}"
+ echo " • https://github.com/go-gitea/runner/releases/tag/${TAG}"
+ echo " 找文件名类似:gitea-runner-${VERSION}-${PLATFORM}(不要下 .xz / .sha256)"
+ echo ""
+ echo " 下载完成后 scp 到本服务器并执行:"
+ echo " sudo cp ./gitea-runner-* /usr/local/bin/act_runner"
+ echo " sudo chmod +x /usr/local/bin/act_runner"
+ echo " # 然后重新跑本脚本(已有的用户/组/config 不会重复生成)"
+ echo ""
+ } >&2
+ exit 1
+fi
+
+# 3.6 真正下载
+info "⬇️ 下载 Gitea Runner ${TAG}:${ASSET_NAME}"
+info " URL:${BIN_URL}"
curl -fsSL "${BIN_URL}" -o /usr/local/bin/act_runner \
- || err "下载失败。请手动打开 ${BIN_URL} 看是否 404,然后换个版本"
+ || err "下载失败!可能是 DNS/网络/证书问题。可以手动把上面 URL 的文件下载下来 scp 到服务器放到 /usr/local/bin/act_runner,再重新跑本脚本。"
chmod +x /usr/local/bin/act_runner
-info "✅ act_runner 已安装到 /usr/local/bin,版本:$(act_runner --version 2>&1 || true)"
+INSTALLED_VER="$(act_runner --version 2>&1 || echo "${TAG}")"
+ok "✅ 已安装到 /usr/local/bin/act_runner,版本:${INSTALLED_VER}"
# ---------- 4. 注册 Runner ----------
cd "${INSTALL_DIR}"
--
2.45.1.windows.1
@@ -0,0 +1,42 @@
From 7421906fb9b2ad10ea26e88b0df29ffe9af43b11 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 09:35:33 +0800
Subject: [PATCH 06/58] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Ubuntu=2024.0?=
=?UTF-8?q?4=20=E4=B8=8B=20setup-deploy-user.sh=20useradd=20=E5=8F=82?=
=?UTF-8?q?=E6=95=B0=E9=94=99=E8=AF=AF=EF=BC=88--disabled-password=20?=
=?UTF-8?q?=E6=98=AF=20adduser=20=E7=9A=84=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
scripts/setup-deploy-user.sh | 13 +++++++++++--
1 file changed, 11 insertions(+), 2 deletions(-)
diff --git a/scripts/setup-deploy-user.sh b/scripts/setup-deploy-user.sh
index 38ac0b1..dc9a48b 100644
--- a/scripts/setup-deploy-user.sh
+++ b/scripts/setup-deploy-user.sh
@@ -42,8 +42,17 @@ command -v setfacl >/dev/null 2>&1 || {
# -------- 1. 建用户 --------
if ! id -u "${DEPLOY_USER}" >/dev/null 2>&1; then
- useradd --create-home --shell /usr/bin/bash --disabled-password "${DEPLOY_USER}"
- ok "新建用户:${DEPLOY_USER}(无密码,仅 SSH key 登录)"
+ # 注意:Ubuntu 22.04/24.04 的 useradd 没有 --disabled-password 选项
+ # (那是 adduser 命令的参数,之前脚本写错了命令)。
+ # 这里用 useradd 原生参数:
+ # -m = 创建 home
+ # -s = 指定登录 shell
+ # -p '*' = 密码设置为「*」(锁定,禁止用密码登录,只能 SSH 密钥)
+ # 这样和之前「只允许 SSH 密钥登录、无密码」的意图完全一致。
+ useradd -m -s /usr/bin/bash -p '*' "${DEPLOY_USER}"
+ # Ubuntu/Debian 额外保险:锁定密码字段,避免 shadow 被改
+ usermod -L "${DEPLOY_USER}" 2>/dev/null || true
+ ok "新建用户:${DEPLOY_USER}(密码已锁定,仅 SSH key 登录)"
else
info "用户 ${DEPLOY_USER} 已存在,跳过创建"
fi
--
2.45.1.windows.1
@@ -0,0 +1,53 @@
From 47d63a48b0d4e8d3f4001cabf4049d9b6ef1a13a Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 09:41:34 +0800
Subject: [PATCH 07/58] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=20Docker=20Comp?=
=?UTF-8?q?ose=20v2=20=E4=B8=A5=E6=A0=BC=E7=B1=BB=E5=9E=8B=E4=B8=8B=20cpus?=
=?UTF-8?q?=20=E5=8F=8C=E5=BC=95=E5=8F=B7=E5=AF=BC=E8=87=B4=20ParseFloat?=
=?UTF-8?q?=20=E6=8A=A5=E9=94=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docker-compose.yml | 10 ++++++----
1 file changed, 6 insertions(+), 4 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index f89810e..3eb9540 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -55,16 +55,18 @@ services:
# ===== 日志/性能 =====
NODE_OPTIONS: ${NODE_OPTIONS:-"--max-old-space-size=768"}
# 2C4G 机器的推荐配置(.env 可覆盖);小机器 1C2G 也能跑
+ # ⚠️ 注意:cpus 默认值不能加双引号!新版 Docker Compose v2 严格类型:
+ # 加引号会解析为字符串 '"1.5"' → ParseFloat 失败报错
mem_limit: ${APP_MEM_LIMIT:-1400m}
mem_reservation: ${APP_MEM_RESERVATION:-384m}
- cpus: ${APP_CPUS:-"1.5"}
+ cpus: ${APP_CPUS:-1.5}
deploy:
resources:
limits:
- cpus: ${APP_CPUS_LIMIT:-"2"}
+ cpus: ${APP_CPUS_LIMIT:-2}
memory: ${APP_MEM_LIMIT:-1400M}
reservations:
- cpus: ${APP_CPUS_RESERVATION:-"0.5"}
+ cpus: ${APP_CPUS_RESERVATION:-0.5}
memory: ${APP_MEM_RESERVATION:-384M}
healthcheck:
# 30s 后开始检查,每 15s 一次,连续 3 次失败就重启
@@ -141,7 +143,7 @@ services:
- redis-data:/data
mem_limit: ${REDIS_MEM_LIMIT:-384m}
mem_reservation: ${REDIS_MEM_RESERVATION:-64m}
- cpus: ${REDIS_CPUS:-"0.5"}
+ cpus: ${REDIS_CPUS:-0.5}
networks:
- voicehub-net
--
2.45.1.windows.1
@@ -0,0 +1,46 @@
From 3ff6c9dd3eea70cbf5722cc50b490f9520359dac Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 09:44:58 +0800
Subject: [PATCH 08/58] =?UTF-8?q?fix:=20docker-compose=20=E9=A1=B6?=
=?UTF-8?q?=E7=BA=A7=20name=20=E5=B0=8F=E5=86=99=E5=90=88=E6=B3=95=20+=20.?=
=?UTF-8?q?env.example=20=E8=A1=A5=E5=85=85=20REDIS=5FPASSWORD?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 5 +++++
docker-compose.yml | 4 +++-
2 files changed, 8 insertions(+), 1 deletion(-)
diff --git a/.env.example b/.env.example
index b85a6e2..8ea85f4 100644
--- a/.env.example
+++ b/.env.example
@@ -72,6 +72,11 @@ NODE_ENV=development
# 格式:redis://用户名:密码@主机:端口
# REDIS_URL="redis://default:password@host:port"
+# Docker Compose 自带的 Redis 容器(compose 自带 redis 服务,仅在启用自带 redis 时用)
+# - 留空(或空字符串):不设置 Redis 密码,内网部署常用(默认)
+# - 填写强密码:Redis 会启用 requirepass,连接时 REDIS_URL 里也要带上相同密码
+REDIS_PASSWORD=
+
# Redis 键命名空间,多个 VoiceHub 环境共用 Redis 时应分别设置
# REDIS_KEY_PREFIX="voicehub:v2:"
diff --git a/docker-compose.yml b/docker-compose.yml
index 3eb9540..de070a1 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -1,4 +1,6 @@
-name: BYW VoiceHub (1.7.0.0)
+# ⚠️ Docker Compose v2 顶级 name 必须匹配正则 ^[a-z0-9][a-z0-9_-]*$
+# 不能有大写、空格、括号、中文,否则会报 name Does not match pattern 错误
+name: byw-voicehub
services:
# ========================================================================
# 应用本体 - Nuxt 4 (Node SSR)
--
2.45.1.windows.1
@@ -0,0 +1,39 @@
From f999d58eef24da10c53b2a4f45dc0a88fe2f4e89 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 09:46:55 +0800
Subject: [PATCH 09/58] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=20voicehub=20?=
=?UTF-8?q?=E9=A1=B6=E5=B1=82=20cpus/mem=5Flimit=EF=BC=8C=E9=81=BF?=
=?UTF-8?q?=E5=85=8D=E4=B8=8E=20deploy.resources=20=E9=87=8D=E5=A4=8D?=
=?UTF-8?q?=E5=AE=9A=E4=B9=89=E6=8A=A5=E9=94=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docker-compose.yml | 9 ++++-----
1 file changed, 4 insertions(+), 5 deletions(-)
diff --git a/docker-compose.yml b/docker-compose.yml
index de070a1..abb81bc 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -56,12 +56,11 @@ services:
# ===== 日志/性能 =====
NODE_OPTIONS: ${NODE_OPTIONS:-"--max-old-space-size=768"}
+
# 2C4G 机器的推荐配置(.env 可覆盖);小机器 1C2G 也能跑
- # ⚠️ 注意:cpus 默认值不能加双引号!新版 Docker Compose v2 严格类型:
- # 加引号会解析为字符串 '"1.5"' → ParseFloat 失败报错
- mem_limit: ${APP_MEM_LIMIT:-1400m}
- mem_reservation: ${APP_MEM_RESERVATION:-384m}
- cpus: ${APP_CPUS:-1.5}
+ # ⚠️ 关键:用 deploy.resources.{limits|reservations} 这一套,
+ # 不要同时在顶层写 cpus/mem_limit/mem_reservation!
+ # Docker Compose v2 禁止两套资源字段并存,会报 invalid compose project。
deploy:
resources:
limits:
--
2.45.1.windows.1
@@ -0,0 +1,42 @@
From 082b5c854c31c3dc454f9e4d8622ae4c91698bfa Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 10:21:48 +0800
Subject: [PATCH 10/58] =?UTF-8?q?fix(ci):=20deploy.yml=20=E6=94=B9?=
=?UTF-8?q?=E4=B8=BA=E7=94=A8=E6=88=B7=E7=9C=9F=E5=AE=9E=20GITEA=5FHOST=20?=
=?UTF-8?q?=E5=92=8C=20DEPLOY=5FPATH=EF=BC=88/data/opt/byw-voicehub?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 11 ++++++-----
1 file changed, 6 insertions(+), 5 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index e8d783b..21a8f14 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -24,13 +24,14 @@ concurrency:
env:
# ============================================================
- # 👇👇👇 这里改成你自己的 Gitea 域名(不要 https:// 前缀,不要端口默认 443/80)
- # 例:git.baoyiwai.com 或 gitea.example.com:3000
- GITEA_HOST: gitea.example.com
- # 👆👆👆
+ # ⚠️ 用户真实配置(部署 BYW VoiceHub 专用,不要改回 example)
+ # ============================================================
+ # Gitea 实例域名(不含 https://,如果是非标准端口要加 :端口号)
+ GITEA_HOST: gitea.fse-media.group
+ # 生产服务器 B 上的实际部署目录(之前 /opt/byw-voicehub 已废弃,以用户实际为准)
+ DEPLOY_PATH: /data/opt/byw-voicehub
# ============================================================
IMAGE_NAME: byw-voicehub
- DEPLOY_PATH: /opt/byw-voicehub
jobs:
# ============================================================
--
2.45.1.windows.1
@@ -0,0 +1,139 @@
From 7b3294a3804a73d7048afe91a516ebdc55fedbd4 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 10:30:51 +0800
Subject: [PATCH 11/58] =?UTF-8?q?fix(ci):=20Preflight=20=E7=9B=B4=E6=8E=A5?=
=?UTF-8?q?=E8=B7=91=20docker://node:22-bookworm-slim=20=E8=B7=B3=E8=BF=87?=
=?UTF-8?q?=20setup-node=20=E4=B8=8B=E8=BD=BD=EF=BC=88=E5=9B=BD=E5=86=85?=
=?UTF-8?q?=20GitHub=20release=20=E8=B6=85=E6=97=B6=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 42 +++++++++++++++++++++----------------
1 file changed, 24 insertions(+), 18 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 21a8f14..8f44e10 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -39,7 +39,9 @@ jobs:
# ============================================================
preflight:
name: Preflight 语法 & 构建检查
- runs-on: ubuntu-latest
+ # 国内访问 GitHub actions/node-versions release 经常超时(443 ETIMEDOUT)
+ # 所以直接用官方 node:22 镜像跑这个 Job,绕过 setup-node 下载 step
+ runs-on: [ubuntu-latest:docker://node:22-bookworm-slim]
timeout-minutes: 15
steps:
- name: 📥 Checkout 代码
@@ -47,11 +49,15 @@ jobs:
with:
fetch-depth: 1
- - name: 🔧 安装 Node.js 22
- uses: actions/setup-node@v4
- with:
- node-version: "22"
- cache: "npm"
+ # ── 我们已经用 runs-on: docker://node:22 了,所以这里不需要 actions/setup-node ──
+ # 避免国内 Runner 从 github.com 下载 Node tar.gz 出现 ETIMEDOUT
+ - name: 🔎 环境自检(node/npm 版本)
+ shell: bash
+ run: |
+ set -euxo pipefail
+ node -v
+ npm -v
+ which node npm
- name: 🧪 安装依赖(npm ci,严格按锁文件)
run: |
@@ -92,12 +98,12 @@ jobs:
image_full: ${{ steps.meta.outputs.full }}
image_latest: ${{ steps.meta.outputs.latest }}
steps:
- - name: 📥 Checkout 代码
+ - name: Checkout 代码
uses: actions/checkout@v4
with:
fetch-depth: 1
- - name: 🏷️ 计算镜像 tag(日期 + 短 SHA)
+ - name: 计算镜像 tag(日期 + 短 SHA)
id: meta
shell: bash
run: |
@@ -116,17 +122,17 @@ jobs:
echo "🪪 镜像 latest : ${IMG_LATEST}"
echo "======================================"
- - name: 🔧 启用 QEMU(跨架构小概率用得到,保险)
+ - name: 启用 QEMU(跨架构小概率用得到,保险)
uses: docker/setup-qemu-action@v3
with:
platforms: linux/amd64
- - name: 🔧 启用 Docker Buildx
+ - name: 启用 Docker Buildx
uses: docker/setup-buildx-action@v3
with:
install: true
- - name: 🔐 登录 Gitea 容器仓库(Packages)
+ - name: 登录 Gitea 容器仓库(Packages)
uses: docker/login-action@v3
with:
registry: ${{ env.GITEA_HOST }}
@@ -135,7 +141,7 @@ jobs:
# Secrets 里 REGISTRY_TOKEN 优先,没填就用 Gitea Actions 自动注入的仓库级 token
password: ${{ secrets.REGISTRY_TOKEN || github.token }}
- - name: 🐳 构建 & 推送(BuildKit 分层缓存,skip_build_cache=true 时禁用)
+ - name: 构建 & 推送(BuildKit 分层缓存,skip_build_cache=true 时禁用)
id: build_push
uses: docker/build-push-action@v6
env:
@@ -159,7 +165,7 @@ jobs:
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
- - name: 🧮 镜像体积 & 层信息(日志里看一下,避免镜像越来越大)
+ - name: 镜像体积 & 层信息(日志里看一下,避免镜像越来越大)
run: |
set -euxo pipefail
docker pull "${{ steps.meta.outputs.full }}" || true
@@ -181,7 +187,7 @@ jobs:
name: production
url: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
steps:
- - name: ⚠️ 检查部署所需 Secrets 是否都配置了
+ - name: 检查部署所需 Secrets 是否都配置了
shell: bash
run: |
set -euo pipefail
@@ -196,7 +202,7 @@ jobs:
fi
echo "✅ 部署 Secrets 配置完整"
- - name: 🚀 一键滚动部署(SSH)
+ - name: 一键滚动部署(SSH)
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
@@ -217,9 +223,9 @@ jobs:
COMPOSE="docker compose"
echo "=============================================="
- echo "🚢 本次部署镜像: ${IMAGE_NEW}"
- echo "🗂️ 部署目录 : ${DEPLOY_DIR}"
- echo "👤 执行用户 : $(whoami)"
+ echo "本次部署镜像: ${IMAGE_NEW}"
+ echo "部署目录 : ${DEPLOY_DIR}"
+ echo "执行用户 : $(whoami)"
echo "=============================================="
# ---------- 1. 确保部署目录存在 ----------
--
2.45.1.windows.1
@@ -0,0 +1,59 @@
From 97af401d894e5b00b6b05b58982dc628603e31c5 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 10:32:55 +0800
Subject: [PATCH 12/58] =?UTF-8?q?fix(ci):=20Preflight=20=E6=94=B9=E4=B8=BA?=
=?UTF-8?q?=20Gitea=20=E6=A0=87=E5=87=86=20container:image=20=E5=86=99?=
=?UTF-8?q?=E6=B3=95=EF=BC=88runs-on=20=E5=8F=AA=E5=A1=AB=20ubuntu-latest?=
=?UTF-8?q?=EF=BC=8C=E9=81=BF=E5=85=8D=20no=20runner=20matched=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 17 +++++++++++------
1 file changed, 11 insertions(+), 6 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 8f44e10..4e5260c 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -39,25 +39,30 @@ jobs:
# ============================================================
preflight:
name: Preflight 语法 & 构建检查
- # 国内访问 GitHub actions/node-versions release 经常超时(443 ETIMEDOUT)
- # 所以直接用官方 node:22 镜像跑这个 Job,绕过 setup-node 下载 step
- runs-on: [ubuntu-latest:docker://node:22-bookworm-slim]
+ # ⚠️ Gitea/act_runner 的写法:runs-on 只填注册时的真实标签(我们叫 ubuntu-latest);
+ # 如果要强制用某个 Docker 镜像跑这个 Job,单独写一个 container: 段。
+ # 不能写 "ubuntu-latest:docker://xxx",这是 GitHub Actions 专有语法,Gitea 不认!
+ runs-on: ubuntu-latest
timeout-minutes: 15
+ # 直接用官方 node:22 镜像当 Job 容器(里面自带 node 22 + npm),
+ # 完全绕过 setup-node 从 GitHub release 下载 node tar.gz 的国内超时老坑
+ container:
+ image: node:22-bookworm-slim
steps:
- name: 📥 Checkout 代码
uses: actions/checkout@v4
with:
fetch-depth: 1
- # ── 我们已经用 runs-on: docker://node:22 了,所以这里不需要 actions/setup-node ──
- # 避免国内 Runner 从 github.com 下载 Node tar.gz 出现 ETIMEDOUT
- - name: 🔎 环境自检(node/npm 版本)
+ # ── 已经在 container.image=node:22 里跑了,自带 node 22,不需要 actions/setup-node ──
+ - name: 🔎 环境自检(确认容器里有 node/npm,版本对得上)
shell: bash
run: |
set -euxo pipefail
node -v
npm -v
which node npm
+ cat /etc/os-release 2>/dev/null | head -4 || true
- name: 🧪 安装依赖(npm ci,严格按锁文件)
run: |
--
2.45.1.windows.1
+367
View File
@@ -0,0 +1,367 @@
From 10d363ca2b3f6a7cae380cada62c150933b4fef3 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 10:35:56 +0800
Subject: [PATCH 13/58] =?UTF-8?q?style(ci):=20=E7=A7=BB=E9=99=A4=E5=B7=A5?=
=?UTF-8?q?=E4=BD=9C=E6=B5=81=E6=AD=A5=E9=AA=A4=E4=B8=AD=E7=9A=84=20emoji?=
=?UTF-8?q?=EF=BC=8C=E6=94=B9=E7=94=A8=E4=B8=93=E4=B8=9A=E9=A3=8E=E6=A0=BC?=
=?UTF-8?q?=E5=91=BD=E5=90=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 144 ++++++++++++------------------------
1 file changed, 49 insertions(+), 95 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 4e5260c..591afa7 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -1,4 +1,4 @@
-name: 构建 & 部署 BYW VoiceHub
+name: 构建并部署 BYW VoiceHub
run-name: ${{ github.actor }} 触发部署 · Run ${{ github.run_number }}
on:
@@ -17,45 +17,29 @@ on:
default: false
required: false
-# 同一时刻只允许一个部署在跑,避免冲突;如果新提交进来,取消前一个未完成的
concurrency:
group: deploy-production
cancel-in-progress: true
env:
- # ============================================================
- # ⚠️ 用户真实配置(部署 BYW VoiceHub 专用,不要改回 example)
- # ============================================================
- # Gitea 实例域名(不含 https://,如果是非标准端口要加 :端口号)
GITEA_HOST: gitea.fse-media.group
- # 生产服务器 B 上的实际部署目录(之前 /opt/byw-voicehub 已废弃,以用户实际为准)
DEPLOY_PATH: /data/opt/byw-voicehub
- # ============================================================
IMAGE_NAME: byw-voicehub
jobs:
- # ============================================================
- # Job 1: 校验构建环境 & 打包产物(npm ci + build,不依赖 Docker)
- # ============================================================
preflight:
- name: Preflight 语法 & 构建检查
- # ⚠️ Gitea/act_runner 的写法:runs-on 只填注册时的真实标签(我们叫 ubuntu-latest);
- # 如果要强制用某个 Docker 镜像跑这个 Job,单独写一个 container: 段。
- # 不能写 "ubuntu-latest:docker://xxx",这是 GitHub Actions 专有语法,Gitea 不认!
+ name: Preflight 语法与构建检查
runs-on: ubuntu-latest
timeout-minutes: 15
- # 直接用官方 node:22 镜像当 Job 容器(里面自带 node 22 + npm),
- # 完全绕过 setup-node 从 GitHub release 下载 node tar.gz 的国内超时老坑
container:
image: node:22-bookworm-slim
steps:
- - name: 📥 Checkout 代码
+ - name: 检出代码
uses: actions/checkout@v4
with:
fetch-depth: 1
- # ── 已经在 container.image=node:22 里跑了,自带 node 22,不需要 actions/setup-node ──
- - name: 🔎 环境自检(确认容器里有 node/npm,版本对得上)
+ - name: 运行时环境自检
shell: bash
run: |
set -euxo pipefail
@@ -64,37 +48,33 @@ jobs:
which node npm
cat /etc/os-release 2>/dev/null | head -4 || true
- - name: 🧪 安装依赖(npm ci,严格按锁文件)
+ - name: 安装依赖(npm ci)
run: |
set -euxo pipefail
- # 国内加速镜像(Runner 在国外可以删掉这两行)
npm config set registry https://registry.npmmirror.com || true
npm config set fund false
npm config set audit false
npm ci
- - name: 🔎 检查 package.json 没有 pnpm 残留
+ - name: 检查 package.json 无 pnpm 残留
run: |
set -euo pipefail
if grep -qE '"packageManager"\s*:\s*"pnpm' package.json; then
- echo "❌ package.json 里有 pnpm 包管理器声明,和用户要求冲突"
+ echo "package.json 中发现 pnpm 包管理器声明,与要求冲突,构建中止"
exit 1
fi
- echo "✅ package.json 未发现 pnpm 声明"
+ echo "package.json 未发现 pnpm 声明"
- - name: 🏗️ 本地 nuxt build(独立于 Docker 先验证代码是可构建的)
+ - name: 执行 nuxt build
run: |
set -euxo pipefail
NODE_OPTIONS="--max-old-space-size=2048" npm run build
- echo "✅ .output/ 生成成功:"
+ echo ".output/ 生成结果:"
ls -la .output/ 2>/dev/null || true
du -sh .output 2>/dev/null || true
- # ============================================================
- # Job 2: 构建 Docker 镜像 → 推送到 Gitea 自带容器仓库
- # ============================================================
build-image:
- name: 构建并推送镜像
+ name: 构建并推送容器镜像
needs: preflight
runs-on: ubuntu-latest
timeout-minutes: 40
@@ -103,12 +83,12 @@ jobs:
image_full: ${{ steps.meta.outputs.full }}
image_latest: ${{ steps.meta.outputs.latest }}
steps:
- - name: Checkout 代码
+ - name: 检出代码
uses: actions/checkout@v4
with:
fetch-depth: 1
- - name: 计算镜像 tag(日期 + 短 SHA)
+ - name: 计算镜像标签
id: meta
shell: bash
run: |
@@ -122,12 +102,12 @@ jobs:
echo "full=${IMG_FULL}" >> "$GITHUB_OUTPUT"
echo "latest=${IMG_LATEST}" >> "$GITHUB_OUTPUT"
echo "======================================"
- echo "🪪 镜像版本 tag : ${DATE_TAG}"
- echo "🪪 镜像完整名 : ${IMG_FULL}"
- echo "🪪 镜像 latest : ${IMG_LATEST}"
+ echo "Image tag : ${DATE_TAG}"
+ echo "Full image : ${IMG_FULL}"
+ echo "Latest image : ${IMG_LATEST}"
echo "======================================"
- - name: 启用 QEMU(跨架构小概率用得到,保险)
+ - name: 启用 QEMU
uses: docker/setup-qemu-action@v3
with:
platforms: linux/amd64
@@ -137,16 +117,14 @@ jobs:
with:
install: true
- - name: 登录 Gitea 容器仓库(Packages)
+ - name: 登录 Gitea 容器仓库
uses: docker/login-action@v3
with:
registry: ${{ env.GITEA_HOST }}
- # Secrets 里 REGISTRY_USER 没填就默认用触发构建的用户
username: ${{ secrets.REGISTRY_USER || github.actor }}
- # Secrets 里 REGISTRY_TOKEN 优先,没填就用 Gitea Actions 自动注入的仓库级 token
password: ${{ secrets.REGISTRY_TOKEN || github.token }}
- - name: 构建 & 推送(BuildKit 分层缓存,skip_build_cache=true 时禁用)
+ - name: 构建并推送镜像
id: build_push
uses: docker/build-push-action@v6
env:
@@ -170,21 +148,17 @@ jobs:
org.opencontainers.image.revision=${{ github.sha }}
org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
- - name: 镜像体积 & 层信息(日志里看一下,避免镜像越来越大)
+ - name: 镜像体积与层信息
run: |
set -euxo pipefail
docker pull "${{ steps.meta.outputs.full }}" || true
docker image inspect "${{ steps.meta.outputs.full }}" --format='{{.Size}}' 2>/dev/null \
- | awk '{printf "✅ 镜像体积:%.2f MB\n", $1/1024/1024}' || true
+ | awk '{printf "Image size: %.2f MB\n", $1/1024/1024}' || true
docker history --no-trunc "${{ steps.meta.outputs.full }}" 2>/dev/null | head -20 || true
- # ============================================================
- # Job 3: SSH 到服务器 → 拉新镜像 → 滚动重启
- # ============================================================
deploy:
name: 部署到生产服务器
needs: build-image
- # push 事件默认自动部署;手动触发时看 checkbox
if: ${{ !cancelled() && !failure() && (github.event_name == 'push' || inputs.deploy_target == true) }}
runs-on: ubuntu-latest
timeout-minutes: 25
@@ -192,7 +166,7 @@ jobs:
name: production
url: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
steps:
- - name: 检查部署所需 Secrets 是否都配置了
+ - name: 校验部署所需密钥
shell: bash
run: |
set -euo pipefail
@@ -201,133 +175,113 @@ jobs:
[ -n "${{ secrets.DEPLOY_USER }}" ] || MISSING+=("DEPLOY_USER")
[ -n "${{ secrets.DEPLOY_SSH_KEY }}" ] || MISSING+=("DEPLOY_SSH_KEY")
if [ ${#MISSING[@]} -gt 0 ]; then
- echo "❌ 缺少以下 Repository Secrets:${MISSING[*]}"
- echo "👉 去 Gitea 仓库 → Settings → Secrets and variables → Actions 里添加"
+ echo "缺少以下 Repository Secrets:${MISSING[*]}"
+ echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加"
exit 1
fi
- echo "✅ 部署 Secrets 配置完整"
+ echo "部署密钥配置完整"
- - name: 一键滚动部署(SSH)
+ - name: SSH 滚动部署
uses: appleboy/ssh-action@v1.2.0
with:
host: ${{ secrets.DEPLOY_HOST }}
port: ${{ secrets.DEPLOY_PORT || 22 }}
username: ${{ secrets.DEPLOY_USER }}
key: ${{ secrets.DEPLOY_SSH_KEY }}
- # 部署可能慢,给足 30 分钟
command_timeout: 30m
- # 出错立刻停,不继续往下跑
script_stop: true
script: |
set -euxo pipefail
- # ---------- 0. 基本参数 ----------
IMAGE_NEW="${{ needs.build-image.outputs.image_full }}"
IMAGE_LATEST="${{ needs.build-image.outputs.image_latest }}"
DEPLOY_DIR="${DEPLOY_PATH}"
COMPOSE="docker compose"
echo "=============================================="
- echo "本次部署镜像: ${IMAGE_NEW}"
- echo "部署目录 : ${DEPLOY_DIR}"
- echo "执行用户 : $(whoami)"
+ echo "Deploy image : ${IMAGE_NEW}"
+ echo "Deploy dir : ${DEPLOY_DIR}"
+ echo "Executor : $(whoami)"
echo "=============================================="
- # ---------- 1. 确保部署目录存在 ----------
mkdir -p "${DEPLOY_DIR}"
cd "${DEPLOY_DIR}"
- # ---------- 2. 登录 Gitea 容器仓库(用 pull token,最小权限) ----------
if [ -n "${{ secrets.REGISTRY_PULL_TOKEN }}" ] && [ -n "${{ secrets.REGISTRY_PULL_USER }}" ]; then
- echo "🔐 使用专用 pull 凭证登录 Gitea Container Registry"
+ echo "使用专用 pull 凭证登录 Gitea Container Registry"
echo "${{ secrets.REGISTRY_PULL_TOKEN }}" \
| docker login "${GITEA_HOST}" \
-u "${{ secrets.REGISTRY_PULL_USER }}" \
--password-stdin 2>/dev/null || true
fi
- # ---------- 3. 先拉镜像(提前下好,后面 up 不会卡) ----------
- echo "⬇️ 开始拉取新镜像 ${IMAGE_NEW} ..."
+ echo "Pulling new image ${IMAGE_NEW} ..."
docker pull "${IMAGE_NEW}"
docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
- echo "✅ 新镜像拉取完成,并已打 latest tag"
+ echo "New image pulled and tagged as latest"
- # ---------- 4. 生成 / 更新 docker-compose.override.yml ----------
- # 指向新的精确 tag(不是 latest,出问题能快速回滚)
cat > docker-compose.override.yml <<YAML
services:
voicehub:
image: ${IMAGE_NEW}
YAML
- echo "📝 docker-compose.override.yml 已写入,指定镜像=${IMAGE_NEW}"
+ echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
- # ---------- 5. 没有 docker-compose.yml? 自动从仓库下一份模板 ----------
if [ ! -f docker-compose.yml ]; then
- echo "ℹ️ 未发现 docker-compose.yml,自动从仓库模板下载"
+ echo "docker-compose.yml 不存在,自动从仓库模板下载"
TEMPLATE_URL="${GITHUB_SERVER_URL:-https://${GITEA_HOST}}/${GITHUB_REPOSITORY:-${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}}/raw/branch/${GITHUB_REF_NAME:-main}/docker-compose.yml"
- echo " ↓ URL=${TEMPLATE_URL}"
+ echo "URL=${TEMPLATE_URL}"
curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
|| (
- echo "⚠️ 自动下载模板失败,跳过(你需要手动上传 docker-compose.yml 和 .env)"
- # 不 exit,因为用户可能已经有一套自定义部署
+ echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env"
)
fi
- # ---------- 6. 没有 .env? 给一份占位(方便首次部署发现问题) ----------
if [ ! -f .env ]; then
echo "================================================================="
- echo "⚠️ WARNING:${DEPLOY_DIR}/.env 不存在!!!"
- echo "👉 第一次部署请先在服务器上创建 .env(至少填 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL)"
- echo "👉 参考仓库根的 .env.example 或部署文档"
+ echo "WARNING:${DEPLOY_DIR}/.env 不存在"
+ echo "首次部署请在服务器上创建 .env(至少填写 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL)"
+ echo "参考仓库根目录的 .env.example"
echo "================================================================="
- # 不退出,因为 compose 会用 environment 默认值尝试启动(只是大概率连不上 DB)
fi
- # ---------- 7. 滚动启动(如果只有 voicehub 单服务,不会动 postgres/redis) ----------
- echo "🔄 docker compose up -d(只重建变更过的 voicehub)..."
+ echo "Executing docker compose up -d (voicehub only)..."
${COMPOSE} up -d --remove-orphans
- # ---------- 8. 等待应用变 healthy,最多 2 分钟 ----------
- MAX_WAIT=24 # 24 × 5s = 2min
+ MAX_WAIT=24
WAITED=0
- echo "⏳ 等待容器 healthcheck 通过(最多 120s)..."
+ echo "Waiting for container healthcheck (max 120s)..."
while [ ${WAITED} -lt ${MAX_WAIT} ]; do
STATUS="$(${COMPOSE} ps --format json 2>/dev/null || true)"
if echo "${STATUS}" | grep -q '"Health":"healthy"'; then
- echo "✅ voicehub 容器健康检查通过"
+ echo "voicehub healthcheck passed"
break
fi
- # 同时也检查是否已经 running 但健康检查还没跑完(防止 healthcheck 写太严误判)
if echo "${STATUS}" | grep -q '"State":"running"'; then
:
else
- # 连 running 都不是 → 直接打印日志末尾 60 行帮忙排错
- echo "⚠️ voicehub 还没 running,当前状态:"
+ echo "voicehub not running, current status:"
${COMPOSE} ps || true
fi
sleep 5
WAITED=$((WAITED + 1))
done
if [ ${WAITED} -ge ${MAX_WAIT} ]; then
- echo "⚠️ 等待超时,下面是最近 120 行 voicehub 日志:"
+ echo "Healthcheck timeout. Last 120 lines of voicehub logs:"
${COMPOSE} logs --tail=120 voicehub || true
exit 1
fi
- # ---------- 9. 清理旧镜像(保留最近 2 个 tag + latest) ----------
- echo "🧹 清理 3 天前的旧镜像 / 悬空层,防止磁盘爆"
+ echo "Pruning images older than 72 hours..."
docker image prune -af --filter "until=72h" || true
docker builder prune -af || true
- # ---------- 10. 回滚指引 ----------
echo ""
echo "================================================================="
- echo "🎉 部署成功!访问: ${{ secrets.NUXT_PUBLIC_SITE_URL || '请查看你的站点 URL' }}"
- echo "🔁 如需紧急回滚到上一个版本,在服务器执行:"
+ echo "Deploy succeeded. Visit: ${{ secrets.NUXT_PUBLIC_SITE_URL || '请查看你的站点 URL' }}"
+ echo "Rollback instructions (on server):"
echo " cd ${DEPLOY_DIR}"
- echo " # 1) 先看有哪些 tag:"
echo " docker images | grep ${IMAGE_NAME} | head -5"
- echo " # 2) 选一个老 tag 填到 override:"
- echo " echo 'services:\n voicehub:\n image: ${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:<旧TAG>' > docker-compose.override.yml"
+ echo " echo 'services:\n voicehub:\n image: ${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:<OLD_TAG>' > docker-compose.override.yml"
echo " docker compose up -d"
echo "================================================================="
--
2.45.1.windows.1
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,191 @@
From f4aa9884e288969a7f92284714b83a3e805684db Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 11:14:30 +0800
Subject: [PATCH 15/58] =?UTF-8?q?fix:=20=E5=88=A0=E9=99=A4=204=20=E4=B8=AA?=
=?UTF-8?q?=E5=B7=B2=E6=B8=85=E7=90=86=20Modal=20=E7=9A=84=E6=AE=8B?=
=?UTF-8?q?=E7=95=99=20import/ref/template=EF=BC=88ScheduleList=20+=20Requ?=
=?UTF-8?q?estForm=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/components/Songs/RequestForm.vue | 14 --------------
app/components/Songs/ScheduleList.vue | 25 -------------------------
2 files changed, 39 deletions(-)
diff --git a/app/components/Songs/RequestForm.vue b/app/components/Songs/RequestForm.vue
index 12909ef..cadb028 100644
--- a/app/components/Songs/RequestForm.vue
+++ b/app/components/Songs/RequestForm.vue
@@ -849,10 +849,6 @@ import Icon from '../UI/Icon.vue'
import { convertToHttps, validateUrl } from '~/utils/url'
import ImportSongsModal from './ImportSongsModal.vue'
-import NeteaseLoginModal from './NeteaseLoginModal.vue'
-import PodcastEpisodesModal from './PodcastEpisodesModal.vue'
-import BilibiliEpisodesModal from './BilibiliEpisodesModal.vue'
-import RecentSongsModal from './RecentSongsModal.vue'
import PlaylistSelectionModal from './PlaylistSelectionModal.vue'
import UserSearchModal from '../Common/UserSearchModal.vue'
import NeteaseUploadDialog from './NeteaseUploadDialog.vue'
@@ -980,12 +976,10 @@ const neteaseCookie = ref('')
const searchType = ref(1) // 1: 单曲, 1009: 播客/电台
// 播客弹窗相关
-const showPodcastModal = ref(false)
const selectedPodcastId = ref('')
const selectedPodcastName = ref('')
const podcastCookie = ref('')
-const showRecentSongsModal = ref(false)
const showPlaylistModal = ref(false)
const showUserSearchModal = ref(false)
const collaborators = ref([])
@@ -1020,7 +1014,6 @@ const searchError = ref('')
// 手动输入相关
const showManualModal = ref(false)
-const showBilibiliEpisodesModal = ref(false)
const selectedBilibiliVideo = ref(null)
const bilibiliEpisodes = ref([])
const manualArtist = ref('')
@@ -1584,7 +1577,6 @@ const openSimilarEpisodesModal = (group) => {
selectedBilibiliVideo.value = videoData
bilibiliEpisodes.value = videoData.pages
- showBilibiliEpisodesModal.value = true
}
// 处理剧集投票
@@ -1684,7 +1676,6 @@ const handleLikeFromSearch = async (song, originalResult = null) => {
if (originalResult && isBilibiliMultiP(originalResult)) {
selectedBilibiliVideo.value = originalResult
bilibiliEpisodes.value = originalResult.pages
- showBilibiliEpisodesModal.value = true
return
}
@@ -2127,7 +2118,6 @@ const submitSong = async (result, options = {}) => {
selectedPodcastId.value = result.id || result.musicId
selectedPodcastName.value = result.title || result.song || result.name
podcastCookie.value = neteaseCookie.value
- showPodcastModal.value = true
return
}
@@ -2141,7 +2131,6 @@ const submitSong = async (result, options = {}) => {
console.log('打开 Bilibili 剧集列表:', result)
selectedBilibiliVideo.value = result
bilibiliEpisodes.value = result.pages
- showBilibiliEpisodesModal.value = true
return
}
@@ -2381,7 +2370,6 @@ const handleBilibiliEpisodeSelect = async (episode) => {
})
if (success) {
- showBilibiliEpisodesModal.value = false
if (bilibiliModalRef.value && bilibiliModalRef.value.resetSubmissionState) {
bilibiliModalRef.value.resetSubmissionState()
}
@@ -2418,7 +2406,6 @@ const playlistModalRef = ref(null)
const handlePodcastSubmit = async (song) => {
const success = await submitSong(song, { isPodcastEpisode: true })
if (success) {
- showPodcastModal.value = false
} else {
// 如果失败,重置模态框内的提交状态
if (podcastModalRef.value && podcastModalRef.value.resetSubmissionState) {
@@ -2437,7 +2424,6 @@ const handlePodcastPlay = async (song) => {
const handleRecentSongSubmit = async (song) => {
const success = await submitSong(song, { isPodcastEpisode: false, isDirectSubmit: true })
if (success) {
- showRecentSongsModal.value = false
} else {
// 如果失败,重置模态框内的提交状态
if (recentSongsModalRef.value && recentSongsModalRef.value.resetSubmissionState) {
diff --git a/app/components/Songs/ScheduleList.vue b/app/components/Songs/ScheduleList.vue
index 0a5b247..ffb3b5e 100644
--- a/app/components/Songs/ScheduleList.vue
+++ b/app/components/Songs/ScheduleList.vue
@@ -331,13 +331,6 @@
<Icon name="music" :size="40" class="text-zinc-500 opacity-20" />
</div>
<p class="text-zinc-400 font-medium mb-8">需要登录网易云音乐账号才能管理歌单</p>
- <button
- class="px-10 py-4 rounded-2xl bg-blue-600 hover:bg-blue-500 text-white font-black transition-all active:scale-95 shadow-xl shadow-blue-900/20"
- type="button"
- @click="openLoginFromPlaylist"
- >
- 立即登录
- </button>
</div>
<div v-else class="space-y-8">
@@ -578,15 +571,6 @@
@confirm="handleConfirmAction"
/>
</Teleport>
-
- <Teleport to="body">
- <NeteaseLoginModal
- v-if="showLoginModal"
- :show="showLoginModal"
- @close="showLoginModal = false"
- @login-success="handleLoginSuccess"
- />
- </Teleport>
</template>
<script setup>
@@ -600,7 +584,6 @@ import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import { convertToHttps } from '~/utils/url'
import { getMusicUrl as resolveMusicUrl } from '~/utils/musicUrl'
-import NeteaseLoginModal from './NeteaseLoginModal.vue'
const props = defineProps({
schedules: {
type: Array,
@@ -646,7 +629,6 @@ const playlistActionLoading = ref(false)
const selectedSongIds = ref([])
const newPlaylistName = ref('')
const newPlaylistPrivacy = ref(false)
-const showLoginModal = ref(false)
const confirmDialog = ref({
show: false,
@@ -1094,10 +1076,8 @@ const handleLoginSuccess = (data) => {
if (typeof window !== 'undefined') {
localStorage.setItem('netease_cookie', data.cookie)
localStorage.setItem('netease_user', JSON.stringify(data.user))
- // 更新全局网易云登录状态,通知其他组件(如AudioPlayer)
updateGlobalNeteaseStatus()
}
- showLoginModal.value = false
if (showPlaylistModal.value) {
reloadPlaylists()
}
@@ -1105,7 +1085,6 @@ const handleLoginSuccess = (data) => {
const handleAddToPlaylistClick = () => {
if (!isNeteaseLoggedIn.value) {
- showLoginModal.value = true
return
}
if (!neteaseSongs.value.length) {
@@ -1123,10 +1102,6 @@ const closePlaylistModal = () => {
showPlaylistModal.value = false
}
-const openLoginFromPlaylist = () => {
- showLoginModal.value = true
-}
-
const reloadPlaylists = async () => {
if (!isNeteaseLoggedIn.value || !neteaseCookie.value || !neteaseUser.value) return
const uid = neteaseUser.value.userId || neteaseUser.value.id
--
2.45.1.windows.1
@@ -0,0 +1,481 @@
From cfd90865c858d7ed10996e19178af279dbfe2d77 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 11:20:52 +0800
Subject: [PATCH 16/58] =?UTF-8?q?refactor(ci):=20=E7=A7=BB=E9=99=A4?=
=?UTF-8?q?=E6=89=80=E6=9C=89=E7=AC=AC=E4=B8=89=E6=96=B9=20uses=20action?=
=?UTF-8?q?=EF=BC=8C=E6=94=B9=E7=94=A8=E7=BA=AF=20run=20=E5=91=BD=E4=BB=A4?=
=?UTF-8?q?=EF=BC=88=E8=A7=84=E9=81=BF=20GitHub=20=E4=B8=8B=E8=BD=BD?=
=?UTF-8?q?=E8=B6=85=E6=97=B6=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 404 ++++++++++++++++++++++--------------
1 file changed, 247 insertions(+), 157 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index d5355cf..386f982 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -34,19 +34,26 @@ jobs:
container:
image: node:22-bookworm-slim
steps:
- - name: 检出代码
- uses: actions/checkout@v4
- with:
- fetch-depth: 1
-
- - name: 运行时环境自检
- shell: bash
+ - name: 安装基础运行依赖(git/ca-certificates)
run: |
set -euxo pipefail
+ apt-get update -qq
+ DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends \
+ git ca-certificates curl
+ rm -rf /var/lib/apt/lists/*
+ git --version
node -v
npm -v
- which node npm
- cat /etc/os-release 2>/dev/null | head -4 || true
+
+ - name: 检出代码
+ run: |
+ set -euxo pipefail
+ REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
+ BRANCH="${GITHUB_REF_NAME:-main}"
+ echo "Clone from: ${REPO_URL} (${BRANCH})"
+ rm -rf * .[^.]* 2>/dev/null || true
+ git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
+ git rev-parse --short HEAD
- name: 安装依赖(npm ci)
run: |
@@ -84,13 +91,19 @@ jobs:
image_latest: ${{ steps.meta.outputs.latest }}
steps:
- name: 检出代码
- uses: actions/checkout@v4
- with:
- fetch-depth: 1
+ run: |
+ set -euxo pipefail
+ REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
+ BRANCH="${GITHUB_REF_NAME:-main}"
+ echo "Clone from: ${REPO_URL} (${BRANCH})"
+ rm -rf * .[^.]* 2>/dev/null || true
+ git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
+ git rev-parse --short HEAD
+ docker version
+ docker buildx version || echo "buildx 未安装,将使用 docker build --push"
- name: 计算镜像标签
id: meta
- shell: bash
run: |
set -euo pipefail
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
@@ -107,54 +120,83 @@ jobs:
echo "Latest image : ${IMG_LATEST}"
echo "======================================"
- - name: 启用 QEMU
- uses: docker/setup-qemu-action@v3
- with:
- platforms: linux/amd64
-
- - name: 启用 Docker Buildx
- uses: docker/setup-buildx-action@v3
- with:
- install: true
+ - name: 初始化 Docker Buildx builder
+ run: |
+ set -euxo pipefail
+ BUILDER_NAME="multi"
+ # 如果已存在同名 builder,直接用;否则创建(默认 driver docker-container,支持 --cache-to registry)
+ if ! docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then
+ docker buildx create --name "${BUILDER_NAME}" --driver docker-container --use \
+ --buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host'
+ else
+ docker buildx use "${BUILDER_NAME}"
+ fi
+ docker buildx inspect --bootstrap
- name: 登录 Gitea 容器仓库
- uses: docker/login-action@v3
- with:
- registry: ${{ env.GITEA_HOST }}
- username: ${{ secrets.REGISTRY_USER || github.actor }}
- password: ${{ secrets.REGISTRY_TOKEN || github.token }}
+ env:
+ REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
+ REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
+ run: |
+ set -euo pipefail
+ if [ -z "${REGISTRY_TOKEN}" ]; then
+ echo "ERROR: REGISTRY_TOKEN 为空。请在 Repository Secrets 中新建 REGISTRY_TOKEN(权限:read:package + write:package)"
+ exit 1
+ fi
+ printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" \
+ -u "${REGISTRY_USER}" --password-stdin
+ echo "登录成功:${REGISTRY_USER} @ ${GITEA_HOST}"
- name: 构建并推送镜像
- id: build_push
- uses: docker/build-push-action@v6
env:
CACHE_REF: ${{ env.GITEA_HOST }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache
- with:
- context: .
- file: ./Dockerfile
- platforms: linux/amd64
- push: true
- provenance: false
- tags: |
- ${{ steps.meta.outputs.full }}
- ${{ steps.meta.outputs.latest }}
- build-args: |
- BUILDKIT_INLINE_CACHE=1
- NODE_ENV=production
- cache-from: ${{ inputs.skip_build_cache == true && '""' || format('type=registry,ref={0}', env.CACHE_REF) }}
- cache-to: ${{ inputs.skip_build_cache == true && '""' || format('type=registry,ref={0},mode=max', env.CACHE_REF) }}
- labels: |
- org.opencontainers.image.source=${{ github.server_url }}/${{ github.repository }}
- org.opencontainers.image.revision=${{ github.sha }}
- org.opencontainers.image.created=${{ github.event.head_commit.timestamp }}
+ SKIP_CACHE: ${{ inputs.skip_build_cache }}
+ IMG_FULL: ${{ steps.meta.outputs.full }}
+ IMG_LATEST: ${{ steps.meta.outputs.latest }}
+ OCS_SOURCE: ${{ github.server_url }}/${{ github.repository }}
+ OCS_REVISION: ${{ github.sha }}
+ OCS_CREATED: ${{ github.event.head_commit.timestamp }}
+ run: |
+ set -euxo pipefail
+
+ BUILD_ARGS=(
+ --build-arg BUILDKIT_INLINE_CACHE=1
+ --build-arg NODE_ENV=production
+ )
+ CACHE_ARGS=()
+ if [ "${SKIP_CACHE}" != "true" ]; then
+ CACHE_ARGS=(
+ --cache-from "type=registry,ref=${CACHE_REF}"
+ --cache-to "type=registry,ref=${CACHE_REF},mode=max"
+ )
+ else
+ echo "已启用 skip_build_cache,不使用分层缓存,强制重新构建"
+ BUILD_ARGS+=( --no-cache )
+ fi
+
+ docker buildx build \
+ --context . \
+ --file ./Dockerfile \
+ --platform linux/amd64 \
+ --push \
+ --provenance=false \
+ "${BUILD_ARGS[@]}" \
+ "${CACHE_ARGS[@]}" \
+ --tag "${IMG_FULL}" \
+ --tag "${IMG_LATEST}" \
+ --label "org.opencontainers.image.source=${OCS_SOURCE}" \
+ --label "org.opencontainers.image.revision=${OCS_REVISION}" \
+ --label "org.opencontainers.image.created=${OCS_CREATED}"
- name: 镜像体积与层信息
+ env:
+ IMG_FULL: ${{ steps.meta.outputs.full }}
run: |
set -euxo pipefail
- docker pull "${{ steps.meta.outputs.full }}" || true
- docker image inspect "${{ steps.meta.outputs.full }}" --format='{{.Size}}' 2>/dev/null \
+ docker pull "${IMG_FULL}" || true
+ docker image inspect "${IMG_FULL}" --format='{{.Size}}' 2>/dev/null \
| awk '{printf "Image size: %.2f MB\n", $1/1024/1024}' || true
- docker history --no-trunc "${{ steps.meta.outputs.full }}" 2>/dev/null | head -20 || true
+ docker history --no-trunc "${IMG_FULL}" 2>/dev/null | head -20 || true
deploy:
name: 部署到生产服务器
@@ -167,121 +209,169 @@ jobs:
url: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
steps:
- name: 校验部署所需密钥
- shell: bash
+ env:
+ DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
+ DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
+ DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
run: |
set -euo pipefail
MISSING=()
- [ -n "${{ secrets.DEPLOY_HOST }}" ] || MISSING+=("DEPLOY_HOST")
- [ -n "${{ secrets.DEPLOY_USER }}" ] || MISSING+=("DEPLOY_USER")
- [ -n "${{ secrets.DEPLOY_SSH_KEY }}" ] || MISSING+=("DEPLOY_SSH_KEY")
+ [ -n "${DEPLOY_HOST}" ] || MISSING+=("DEPLOY_HOST")
+ [ -n "${DEPLOY_USER}" ] || MISSING+=("DEPLOY_USER")
+ [ -n "${DEPLOY_SSH_KEY}" ] || MISSING+=("DEPLOY_SSH_KEY")
if [ ${#MISSING[@]} -gt 0 ]; then
echo "缺少以下 Repository Secrets:${MISSING[*]}"
- echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加"
+ echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加(注意是 Repository Secrets,不是 Deploy Keys)"
exit 1
fi
- echo "部署密钥配置完整"
+ echo "部署密钥配置完整(DEPLOY_HOST=${DEPLOY_HOST},DEPLOY_USER=${DEPLOY_USER})"
- name: SSH 滚动部署
- uses: appleboy/ssh-action@v1.2.0
- with:
- host: ${{ secrets.DEPLOY_HOST }}
- port: ${{ secrets.DEPLOY_PORT || 22 }}
- username: ${{ secrets.DEPLOY_USER }}
- key: ${{ secrets.DEPLOY_SSH_KEY }}
- command_timeout: 30m
- script_stop: true
- script: |
- set -euxo pipefail
-
- IMAGE_NEW="${{ needs.build-image.outputs.image_full }}"
- IMAGE_LATEST="${{ needs.build-image.outputs.image_latest }}"
- DEPLOY_DIR="${DEPLOY_PATH}"
- COMPOSE="docker compose"
-
- echo "=============================================="
- echo "Deploy image : ${IMAGE_NEW}"
- echo "Deploy dir : ${DEPLOY_DIR}"
- echo "Executor : $(whoami)"
- echo "=============================================="
-
- mkdir -p "${DEPLOY_DIR}"
- cd "${DEPLOY_DIR}"
-
- if [ -n "${{ secrets.REGISTRY_PULL_TOKEN }}" ] && [ -n "${{ secrets.REGISTRY_PULL_USER }}" ]; then
- echo "使用专用 pull 凭证登录 Gitea Container Registry"
- echo "${{ secrets.REGISTRY_PULL_TOKEN }}" \
- | docker login "${GITEA_HOST}" \
- -u "${{ secrets.REGISTRY_PULL_USER }}" \
- --password-stdin 2>/dev/null || true
- fi
-
- echo "Pulling new image ${IMAGE_NEW} ..."
- docker pull "${IMAGE_NEW}"
- docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
- echo "New image pulled and tagged as latest"
-
- cat > docker-compose.override.yml <<YAML
- services:
- voicehub:
- image: ${IMAGE_NEW}
- YAML
- echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
-
- if [ ! -f docker-compose.yml ]; then
- echo "docker-compose.yml 不存在,自动从仓库模板下载"
- TEMPLATE_URL="${GITHUB_SERVER_URL:-https://${GITEA_HOST}}/${GITHUB_REPOSITORY:-${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}}/raw/branch/${GITHUB_REF_NAME:-main}/docker-compose.yml"
- echo "URL=${TEMPLATE_URL}"
- curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
- || (
- echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env"
- )
- fi
-
- if [ ! -f .env ]; then
- echo "================================================================="
- echo "WARNING:${DEPLOY_DIR}/.env 不存在"
- echo "首次部署请在服务器上创建 .env(至少填写 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL)"
- echo "参考仓库根目录的 .env.example"
- echo "================================================================="
- fi
-
- echo "Executing docker compose up -d (voicehub only)..."
- ${COMPOSE} up -d --remove-orphans
-
- MAX_WAIT=24
- WAITED=0
- echo "Waiting for container healthcheck (max 120s)..."
- while [ ${WAITED} -lt ${MAX_WAIT} ]; do
- STATUS="$(${COMPOSE} ps --format json 2>/dev/null || true)"
- if echo "${STATUS}" | grep -q '"Health":"healthy"'; then
- echo "voicehub healthcheck passed"
- break
+ env:
+ DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }}
+ DEPLOY_PORT: ${{ secrets.DEPLOY_PORT || 22 }}
+ DEPLOY_USER: ${{ secrets.DEPLOY_USER }}
+ DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
+ REGISTRY_PULL_USER: ${{ secrets.REGISTRY_PULL_USER }}
+ REGISTRY_PULL_TOKEN: ${{ secrets.REGISTRY_PULL_TOKEN }}
+ NUXT_PUBLIC_SITE_URL: ${{ secrets.NUXT_PUBLIC_SITE_URL }}
+ IMAGE_NEW: ${{ needs.build-image.outputs.image_full }}
+ IMAGE_LATEST: ${{ needs.build-image.outputs.image_latest }}
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_REPOSITORY: ${{ github.repository }}
+ GITHUB_REF_NAME: ${{ github.ref_name }}
+ run: |
+ set -euo pipefail
+
+ # 写私钥到临时文件(避免 stdin 转义问题)
+ SSH_KEY_FILE="$(mktemp -t deploy_ssh_key.XXXXXX)"
+ printf '%s\n' "${DEPLOY_SSH_KEY}" > "${SSH_KEY_FILE}"
+ chmod 600 "${SSH_KEY_FILE}"
+
+ SSH_OPTS=(
+ -i "${SSH_KEY_FILE}"
+ -o StrictHostKeyChecking=no
+ -o UserKnownHostsFile=/dev/null
+ -o ConnectTimeout=10
+ -o ServerAliveInterval=30
+ -o ServerAliveCountMax=8
+ -p "${DEPLOY_PORT}"
+ )
+
+ echo "=============================================="
+ echo "Deploy target: ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PORT}"
+ echo "Deploy image : ${IMAGE_NEW}"
+ echo "Deploy dir : ${DEPLOY_PATH}"
+ echo "=============================================="
+
+ # 本地预检查:ssh 先连上跑 whoami 确认连通,避免 20 分钟后才发现连不上
+ ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version"
+
+ # 把变量作为 heredoc 脚本的输入传过去,远端展开我们需要的值
+ ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
+ IMAGE_NEW="${IMAGE_NEW}" \
+ IMAGE_LATEST="${IMAGE_LATEST}" \
+ DEPLOY_PATH="${DEPLOY_PATH}" \
+ GITEA_HOST="${GITEA_HOST}" \
+ REGISTRY_PULL_USER="${REGISTRY_PULL_USER}" \
+ REGISTRY_PULL_TOKEN="${REGISTRY_PULL_TOKEN}" \
+ IMAGE_NAME="${IMAGE_NAME}" \
+ NUXT_PUBLIC_SITE_URL="${NUXT_PUBLIC_SITE_URL}" \
+ GITHUB_SERVER_URL="${GITHUB_SERVER_URL}" \
+ GITHUB_REPOSITORY="${GITHUB_REPOSITORY}" \
+ GITHUB_REF_NAME="${GITHUB_REF_NAME}" \
+ bash <<'REMOTE_SCRIPT'
+ set -euxo pipefail
+
+ DEPLOY_DIR="${DEPLOY_PATH}"
+ COMPOSE="docker compose"
+
+ echo "=============================================="
+ echo "Executor : $(whoami)"
+ echo "Deploy image : ${IMAGE_NEW}"
+ echo "Deploy dir : ${DEPLOY_DIR}"
+ echo "=============================================="
+
+ mkdir -p "${DEPLOY_DIR}"
+ cd "${DEPLOY_DIR}"
+
+ if [ -n "${REGISTRY_PULL_TOKEN}" ] && [ -n "${REGISTRY_PULL_USER}" ]; then
+ echo "使用专用 pull 凭证登录 Gitea Container Registry"
+ printf '%s' "${REGISTRY_PULL_TOKEN}" \
+ | docker login "${GITEA_HOST}" \
+ -u "${REGISTRY_PULL_USER}" \
+ --password-stdin 2>/dev/null || true
fi
- if echo "${STATUS}" | grep -q '"State":"running"'; then
- :
- else
- echo "voicehub not running, current status:"
- ${COMPOSE} ps || true
+
+ echo "Pulling new image ${IMAGE_NEW} ..."
+ docker pull "${IMAGE_NEW}"
+ docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
+ echo "New image pulled and tagged as latest"
+
+ cat > docker-compose.override.yml <<YAML
+ services:
+ voicehub:
+ image: ${IMAGE_NEW}
+ YAML
+ echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
+
+ if [ ! -f docker-compose.yml ]; then
+ echo "docker-compose.yml 不存在,自动从仓库模板下载"
+ TEMPLATE_URL="${GITHUB_SERVER_URL:-https://${GITEA_HOST}}/${GITHUB_REPOSITORY}/raw/branch/${GITHUB_REF_NAME:-main}/docker-compose.yml"
+ echo "URL=${TEMPLATE_URL}"
+ curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
+ || echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env(参考仓库根目录 .env.example)"
+ fi
+
+ if [ ! -f .env ]; then
+ echo "================================================================="
+ echo "WARNING:${DEPLOY_DIR}/.env 不存在"
+ echo "首次部署请在服务器上创建 .env(至少填写 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL)"
+ echo "参考仓库根目录的 .env.example"
+ echo "================================================================="
+ fi
+
+ echo "Executing docker compose up -d ..."
+ ${COMPOSE} up -d --remove-orphans
+
+ MAX_WAIT=24
+ WAITED=0
+ echo "Waiting for container healthcheck (max 120s)..."
+ while [ ${WAITED} -lt ${MAX_WAIT} ]; do
+ STATUS="$(${COMPOSE} ps --format json 2>/dev/null || true)"
+ if echo "${STATUS}" | grep -q '"Health":"healthy"'; then
+ echo "voicehub healthcheck passed"
+ break
+ fi
+ if echo "${STATUS}" | grep -q '"State":"running"'; then
+ :
+ else
+ echo "voicehub not running, current status:"
+ ${COMPOSE} ps || true
+ fi
+ sleep 5
+ WAITED=$((WAITED + 1))
+ done
+ if [ ${WAITED} -ge ${MAX_WAIT} ]; then
+ echo "Healthcheck timeout. Last 120 lines of voicehub logs:"
+ ${COMPOSE} logs --tail=120 voicehub || true
+ exit 1
fi
- sleep 5
- WAITED=$((WAITED + 1))
- done
- if [ ${WAITED} -ge ${MAX_WAIT} ]; then
- echo "Healthcheck timeout. Last 120 lines of voicehub logs:"
- ${COMPOSE} logs --tail=120 voicehub || true
- exit 1
- fi
-
- echo "Pruning images older than 72 hours..."
- docker image prune -af --filter "until=72h" || true
- docker builder prune -af || true
-
- echo ""
- echo "================================================================="
- echo "Deploy succeeded. Visit: ${{ secrets.NUXT_PUBLIC_SITE_URL || '请查看你的站点 URL' }}"
- echo "Rollback instructions (on server):"
- echo " cd ${DEPLOY_DIR}"
- echo " docker images | grep ${IMAGE_NAME} | head -5"
- echo " echo 'services:\n voicehub:\n image: ${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:<OLD_TAG>' > docker-compose.override.yml"
- echo " docker compose up -d"
- echo "================================================================="
+
+ echo "Pruning images older than 72 hours..."
+ docker image prune -af --filter "until=72h" || true
+ docker builder prune -af || true
+
+ echo ""
+ echo "================================================================="
+ echo "Deploy succeeded. Visit: ${NUXT_PUBLIC_SITE_URL:-请自行查看你的站点 URL}"
+ echo "Rollback instructions (on server):"
+ echo " cd ${DEPLOY_DIR}"
+ echo " docker images | grep ${IMAGE_NAME} | head -5"
+ echo " printf 'services:\\n voicehub:\\n image: <OLD_TAG_FROM_ABOVE>\\n' > docker-compose.override.yml"
+ echo " docker compose up -d"
+ echo "================================================================="
+ REMOTE_SCRIPT
+
+ # 清理私钥临时文件
+ rm -f "${SSH_KEY_FILE}"
--
2.45.1.windows.1
@@ -0,0 +1,167 @@
From 10117fc7e2bcdd3797efcde7493341dba65a7204 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 11:32:31 +0800
Subject: [PATCH 17/58] =?UTF-8?q?perf(ci):=20=E6=89=80=E6=9C=89=20Job=20De?=
=?UTF-8?q?bian/Ubuntu=20=E6=8D=A2=E6=B8=85=E5=8D=8E=E6=BA=90=20+=20GitHub?=
=?UTF-8?q?=20=E8=AE=BF=E9=97=AE=E7=BB=9F=E4=B8=80=E8=B5=B0=20v4.gh-proxy.?=
=?UTF-8?q?org=20=E5=89=8D=E7=BC=80=E4=BB=A3=E7=90=86=20+=20Buildx=20regis?=
=?UTF-8?q?try-mirrors?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 92 +++++++++++++++++++++++++++++++++----
1 file changed, 82 insertions(+), 10 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 386f982..519f8d5 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -25,6 +25,11 @@ env:
GITEA_HOST: gitea.fse-media.group
DEPLOY_PATH: /data/opt/byw-voicehub
IMAGE_NAME: byw-voicehub
+ GH_PROXY_PREFIX: https://v4.gh-proxy.org/
+ APT_MIRROR_DEBIAN: https://mirrors.tuna.tsinghua.edu.cn/debian
+ APT_MIRROR_DEBIAN_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/debian-security
+ HTTP_PROXY_EXAMPLE: ""
+ HTTPS_PROXY_EXAMPLE: ""
jobs:
preflight:
@@ -34,13 +39,31 @@ jobs:
container:
image: node:22-bookworm-slim
steps:
- - name: 安装基础运行依赖(git/ca-certificates)
+ - name: 安装基础运行依赖(git/ca-certificates,已换清华 TUNA 源)
run: |
set -euxo pipefail
+ # Debian 12 (bookworm) 换清华 TUNA 国内镜像(避免 deb.debian.org 国外源速度 <200KB/s)
+ SOURCES_FILE="/etc/apt/sources.list.d/debian.sources"
+ if [ -f "${SOURCES_FILE}" ]; then
+ sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN}|g" "${SOURCES_FILE}"
+ sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY}|g" "${SOURCES_FILE}"
+ sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN}|g" "${SOURCES_FILE}"
+ sed -i "s|https://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY}|g" "${SOURCES_FILE}"
+ else
+ # 兜底旧格式 /etc/apt/sources.list
+ if [ -f /etc/apt/sources.list ]; then
+ sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN}|g" /etc/apt/sources.list
+ sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN}|g" /etc/apt/sources.list
+ sed -i "s|http://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY}|g" /etc/apt/sources.list
+ sed -i "s|https://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY}|g" /etc/apt/sources.list
+ fi
+ fi
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends \
git ca-certificates curl
rm -rf /var/lib/apt/lists/*
+ # 全局 git 代理:任何 git clone/fetch/pull https://github.com/... 自动走 gh-proxy.com 前缀
+ git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
git --version
node -v
npm -v
@@ -90,6 +113,34 @@ jobs:
image_full: ${{ steps.meta.outputs.full }}
image_latest: ${{ steps.meta.outputs.latest }}
steps:
+ - name: 系统环境准备(Ubuntu 22.04 默认镜像源换清华 + 全局 GitHub gh-proxy)
+ run: |
+ set -euxo pipefail
+ # Ubuntu 22.04 默认是 http://archive.ubuntu.com/ubuntu 和 http://security.ubuntu.com/ubuntu
+ if command -v apt-get >/dev/null 2>&1 && [ -f /etc/apt/sources.list ]; then
+ sudo sed -i \
+ -e 's|http://archive.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' \
+ -e 's|http://security.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu-security|g' \
+ -e 's|https://archive.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' \
+ /etc/apt/sources.list
+ fi
+ if command -v apt-get >/dev/null 2>&1 && ls /etc/apt/sources.list.d/*.sources 1>/dev/null 2>&1; then
+ for f in /etc/apt/sources.list.d/*.sources; do
+ sudo sed -i \
+ -e 's|http://archive.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' \
+ -e 's|http://security.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu-security|g' \
+ "${f}" 2>/dev/null || true
+ done
+ fi
+ # 全局 git:所有 https://github.com/ 自动走 gh-proxy 前缀
+ sudo git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
+ # npm:强制用 npmmirror(与 preflight 对齐)
+ npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || true
+ echo "环境准备完成"
+ cat /etc/os-release 2>/dev/null | head -4 || true
+ docker --version
+ docker buildx version || echo "buildx 未安装"
+
- name: 检出代码
run: |
set -euxo pipefail
@@ -120,17 +171,34 @@ jobs:
echo "Latest image : ${IMG_LATEST}"
echo "======================================"
- - name: 初始化 Docker Buildx builder
+ - name: 初始化 Docker Buildx builder(带国内 registry-mirrors + gh-proxy 环境)
run: |
set -euxo pipefail
BUILDER_NAME="multi"
- # 如果已存在同名 builder,直接用;否则创建(默认 driver docker-container,支持 --cache-to registry)
- if ! docker buildx inspect "${BUILDER_NAME}" >/dev/null 2>&1; then
- docker buildx create --name "${BUILDER_NAME}" --driver docker-container --use \
- --buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host'
- else
- docker buildx use "${BUILDER_NAME}"
- fi
+ BKK_CFG="$(mktemp -t buildkitd_XXXXXX.toml)"
+ # 用 printf 逐行写 buildkitd 配置,避免 YAML parser 把 heredoc 内部的 [...] 误判为 YAML key
+ {
+ printf '[worker.oci]\n max-parallelism = 2\n\n'
+ printf '[registry."docker.io"]\n mirrors = ["docker.m.daocloud.io","dockerproxy.com","hub-mirror.c.163.com","docker.nju.edu.cn","docker.mirrors.ustc.edu.cn"]\n\n'
+ printf '[registry."ghcr.io"]\n mirrors = ["ghcr.nju.edu.cn","ghcr.dockerproxy.com"]\n\n'
+ printf '[registry."k8s.gcr.io"]\n mirrors = ["k8s-gcr.m.daocloud.io","registry.cn-hangzhou.aliyuncs.com/google_containers"]\n'
+ } > "${BKK_CFG}"
+ docker buildx rm "${BUILDER_NAME}" 2>/dev/null || true
+ docker buildx create \
+ --name "${BUILDER_NAME}" \
+ --driver docker-container \
+ --use \
+ --config "${BKK_CFG}" \
+ --buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' \
+ --driver-opt network=host \
+ --driver-opt "env=HTTP_PROXY=${HTTP_PROXY_EXAMPLE}" \
+ --driver-opt "env=HTTPS_PROXY=${HTTPS_PROXY_EXAMPLE}"
+ rm -f "${BKK_CFG}"
+ export GIT_CONFIG_COUNT=2
+ export GIT_CONFIG_KEY_0="url.${GH_PROXY_PREFIX}https://github.com/.insteadOf"
+ export GIT_CONFIG_VALUE_0="https://github.com/"
+ export GIT_CONFIG_KEY_1="url.${GH_PROXY_PREFIX}https://gist.github.com/.insteadOf"
+ export GIT_CONFIG_VALUE_1="https://gist.github.com/"
docker buildx inspect --bootstrap
- name: 登录 Gitea 容器仓库
@@ -273,6 +341,7 @@ jobs:
IMAGE_LATEST="${IMAGE_LATEST}" \
DEPLOY_PATH="${DEPLOY_PATH}" \
GITEA_HOST="${GITEA_HOST}" \
+ GH_PROXY_PREFIX="${GH_PROXY_PREFIX}" \
REGISTRY_PULL_USER="${REGISTRY_PULL_USER}" \
REGISTRY_PULL_TOKEN="${REGISTRY_PULL_TOKEN}" \
IMAGE_NAME="${IMAGE_NAME}" \
@@ -316,8 +385,11 @@ jobs:
echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
if [ ! -f docker-compose.yml ]; then
- echo "docker-compose.yml 不存在,自动从仓库模板下载"
+ echo "docker-compose.yml 不存在,自动从仓库模板下载(如访问 GitHub 会自动加 gh-proxy 前缀)"
TEMPLATE_URL="${GITHUB_SERVER_URL:-https://${GITEA_HOST}}/${GITHUB_REPOSITORY}/raw/branch/${GITHUB_REF_NAME:-main}/docker-compose.yml"
+ case "${TEMPLATE_URL}" in
+ *"://github.com/"*) TEMPLATE_URL="${GH_PROXY_PREFIX}${TEMPLATE_URL}" ;;
+ esac
echo "URL=${TEMPLATE_URL}"
curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
|| echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env(参考仓库根目录 .env.example)"
--
2.45.1.windows.1
@@ -0,0 +1,133 @@
From 167b60a368a5a46d483dd6fa3c796b74e5173126 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 11:40:32 +0800
Subject: [PATCH 18/58] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20Preflight?=
=?UTF-8?q?/=E6=9E=84=E5=BB=BA=20Job=20=E6=BA=90=E6=AD=BB=E5=BE=AA?=
=?UTF-8?q?=E7=8E=AF=EF=BC=88ca-certificates=20=E4=B8=8E=20HTTPS=20?=
=?UTF-8?q?=E9=95=9C=E5=83=8F=E6=BA=90=E9=B8=A1=E7=94=9F=E8=9B=8B=E9=97=AE?=
=?UTF-8?q?=E9=A2=98=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- env 新增 HTTP/HTTPS 双套镜像源变量(Debian bookworm + Ubuntu 22.04)
- Preflight(Debian slim 容器):Step 0 先用 HTTP 清华源 apt-get 装 ca-certificates
+ update-ca-certificates;Step 1 有证书后再切 HTTPS 清华源装 git/curl
- build-image(Ubuntu 宿主 Runner):apt-get 用默认 HTTP archive.ubuntu.com 先装
ca-certificates;之后再 sed 换 HTTPS 清华源,避免同样的证书死循环
- 本地 yaml.safe_load 验证通过(YAML_OK);grep uses: 0 行;grep GH_PROXY_PREFIX 7 处
全部走用户指定的 https://v4.gh-proxy.org/ 前缀
---
.gitea/workflows/deploy.yml | 66 +++++++++++++++++++++++++++----------
1 file changed, 48 insertions(+), 18 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 519f8d5..91ba3e0 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -28,6 +28,12 @@ env:
GH_PROXY_PREFIX: https://v4.gh-proxy.org/
APT_MIRROR_DEBIAN: https://mirrors.tuna.tsinghua.edu.cn/debian
APT_MIRROR_DEBIAN_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/debian-security
+ APT_MIRROR_DEBIAN_HTTP: http://mirrors.tuna.tsinghua.edu.cn/debian
+ APT_MIRROR_DEBIAN_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/debian-security
+ APT_MIRROR_UBUNTU: https://mirrors.tuna.tsinghua.edu.cn/ubuntu
+ APT_MIRROR_UBUNTU_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
+ APT_MIRROR_UBUNTU_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu
+ APT_MIRROR_UBUNTU_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
HTTP_PROXY_EXAMPLE: ""
HTTPS_PROXY_EXAMPLE: ""
@@ -42,27 +48,39 @@ jobs:
- name: 安装基础运行依赖(git/ca-certificates,已换清华 TUNA 源)
run: |
set -euxo pipefail
- # Debian 12 (bookworm) 换清华 TUNA 国内镜像(避免 deb.debian.org 国外源速度 <200KB/s)
+ # ========== Step 0:先用 HTTP 清华源装 ca-certificates(解鸡生蛋:HTTPS 需要证书,证书得先用 HTTP 装) ==========
SOURCES_FILE="/etc/apt/sources.list.d/debian.sources"
if [ -f "${SOURCES_FILE}" ]; then
- sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN}|g" "${SOURCES_FILE}"
- sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY}|g" "${SOURCES_FILE}"
- sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN}|g" "${SOURCES_FILE}"
- sed -i "s|https://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY}|g" "${SOURCES_FILE}"
+ sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" "${SOURCES_FILE}"
+ sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" "${SOURCES_FILE}"
+ sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" "${SOURCES_FILE}"
+ sed -i "s|https://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" "${SOURCES_FILE}"
else
- # 兜底旧格式 /etc/apt/sources.list
if [ -f /etc/apt/sources.list ]; then
- sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN}|g" /etc/apt/sources.list
- sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN}|g" /etc/apt/sources.list
- sed -i "s|http://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY}|g" /etc/apt/sources.list
- sed -i "s|https://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY}|g" /etc/apt/sources.list
+ sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" /etc/apt/sources.list
+ sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" /etc/apt/sources.list
+ sed -i "s|http://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" /etc/apt/sources.list
+ sed -i "s|https://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" /etc/apt/sources.list
fi
fi
apt-get update -qq
- DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends \
- git ca-certificates curl
+ DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates
+ update-ca-certificates 2>/dev/null || true
+
+ # ========== Step 1:现在有了证书,换成 HTTPS 清华源(更安全)+ 装 git/curl ==========
+ if [ -f "${SOURCES_FILE}" ]; then
+ sed -i "s|${APT_MIRROR_DEBIAN_HTTP}|${APT_MIRROR_DEBIAN}|g" "${SOURCES_FILE}"
+ sed -i "s|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|${APT_MIRROR_DEBIAN_SECURITY}|g" "${SOURCES_FILE}"
+ else
+ if [ -f /etc/apt/sources.list ]; then
+ sed -i "s|${APT_MIRROR_DEBIAN_HTTP}|${APT_MIRROR_DEBIAN}|g" /etc/apt/sources.list
+ sed -i "s|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|${APT_MIRROR_DEBIAN_SECURITY}|g" /etc/apt/sources.list
+ fi
+ fi
+ apt-get update -qq
+ DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends git curl
rm -rf /var/lib/apt/lists/*
- # 全局 git 代理:任何 git clone/fetch/pull https://github.com/... 自动走 gh-proxy.com 前缀
+ # 全局 git 代理:任何 git clone/fetch/pull https://github.com/... 自动走 gh-proxy 前缀
git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
git --version
node -v
@@ -116,19 +134,31 @@ jobs:
- name: 系统环境准备(Ubuntu 22.04 默认镜像源换清华 + 全局 GitHub gh-proxy)
run: |
set -euxo pipefail
+ # ========== Step 0:先装 ca-certificates(默认 HTTP archive.ubuntu.com 不需要证书,解鸡生蛋) ==========
+ if command -v apt-get >/dev/null 2>&1; then
+ sudo apt-get update -qq 2>/dev/null || apt-get update -qq 2>/dev/null || true
+ sudo DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates 2>/dev/null || \
+ DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates 2>/dev/null || true
+ sudo update-ca-certificates 2>/dev/null || update-ca-certificates 2>/dev/null || true
+ fi
+
+ # ========== Step 1:有了证书后换 HTTPS 清华源 ==========
# Ubuntu 22.04 默认是 http://archive.ubuntu.com/ubuntu 和 http://security.ubuntu.com/ubuntu
if command -v apt-get >/dev/null 2>&1 && [ -f /etc/apt/sources.list ]; then
sudo sed -i \
- -e 's|http://archive.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' \
- -e 's|http://security.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu-security|g' \
- -e 's|https://archive.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' \
+ -e 's|http://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
+ -e 's|http://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
+ -e 's|https://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
+ -e 's|https://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
/etc/apt/sources.list
fi
if command -v apt-get >/dev/null 2>&1 && ls /etc/apt/sources.list.d/*.sources 1>/dev/null 2>&1; then
for f in /etc/apt/sources.list.d/*.sources; do
sudo sed -i \
- -e 's|http://archive.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu|g' \
- -e 's|http://security.ubuntu.com/ubuntu|https://mirrors.tuna.tsinghua.edu.cn/ubuntu-security|g' \
+ -e 's|http://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
+ -e 's|http://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
+ -e 's|https://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
+ -e 's|https://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
"${f}" 2>/dev/null || true
done
fi
--
2.45.1.windows.1
@@ -0,0 +1,178 @@
From d247cfbda34cff40acd3392c4a3220d0dd202ef9 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 11:50:33 +0800
Subject: [PATCH 19/58] =?UTF-8?q?refactor(cleanup):=20=E6=B8=85=E7=90=86?=
=?UTF-8?q?=E7=AC=AC=E4=BA=8C=E8=BD=AE=E9=9D=9E=E9=85=B7=E7=8B=97=E7=BB=84?=
=?UTF-8?q?=E4=BB=B6=E6=AE=8B=E7=95=99=EF=BC=88PlaylistSelection/NeteaseUp?=
=?UTF-8?q?load/BilibiliIframe=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- RequestForm.vue:删除 PlaylistSelectionModal.vue / NeteaseUploadDialog.vue 2 行无效 import
(2 组件在合并阶段已物理删除,grep 确认无 ref / 赋值 / template 渲染段残留)
- AudioPlayer.vue:彻底清理 BilibiliIframeModal 相关 8 处残留:
① 封面点击事件 @click 三元嵌套语法 bug 修复 → 简化为 !isMobile ? toggleLyrics()
② 删除 v-if=false 的「观看视频」Icon 按钮(保留歌词按钮)
③ 删除 ClientOnly 包裹的 BilibiliIframeModal 渲染段
④ 删除 BilibiliIframeModal.vue / getBilibiliUrl 2 行 import
⑤ 删除 showBilibiliIframe ref 定义
⑥ 删除 resolveFallbackUrl 中 if(false) 包裹的 bilibili 死代码分支
⑦ 删除整个 openBilibiliVideo 函数(含 iframe 预览 + 新窗口跳转逻辑)
⑧ handlePlayerClick 移动端逻辑简化为直接 toggleLyrics(移除 bilibili 平台分支)
- 验证:
・IDE GetDiagnostics 0 error / 0 warning
・grep 本轮 3 Modal 关键字在 app/components 下 0 命中(仅 utils/url.ts 保留 getBilibiliUrl
工具函数定义,未被 import,不影响 build)
・上一轮 4 Modal(NeteaseLogin/PodcastEpisodes/BilibiliEpisodes/RecentSongs)
grep 0 命中(持续干净)
---
app/components/Songs/RequestForm.vue | 2 -
app/components/UI/AudioPlayer.vue | 61 ++--------------------------
2 files changed, 3 insertions(+), 60 deletions(-)
diff --git a/app/components/Songs/RequestForm.vue b/app/components/Songs/RequestForm.vue
index cadb028..d28fc42 100644
--- a/app/components/Songs/RequestForm.vue
+++ b/app/components/Songs/RequestForm.vue
@@ -849,9 +849,7 @@ import Icon from '../UI/Icon.vue'
import { convertToHttps, validateUrl } from '~/utils/url'
import ImportSongsModal from './ImportSongsModal.vue'
-import PlaylistSelectionModal from './PlaylistSelectionModal.vue'
import UserSearchModal from '../Common/UserSearchModal.vue'
-import NeteaseUploadDialog from './NeteaseUploadDialog.vue'
const props = defineProps({
loading: {
diff --git a/app/components/UI/AudioPlayer.vue b/app/components/UI/AudioPlayer.vue
index cbfc967..1f4c7e6 100644
--- a/app/components/UI/AudioPlayer.vue
+++ b/app/components/UI/AudioPlayer.vue
@@ -26,13 +26,7 @@
<!-- 封面 -->
<div
class="cover-container"
- @click.stop="
- isMobile
- ? false
- ? openBilibiliVideo()
- : toggleLyrics()
- : null
- "
+ @click.stop="!isMobile ? toggleLyrics() : null"
>
<template v-if="activeSong && activeSong.cover && !coverError">
<img
@@ -107,15 +101,7 @@
<!-- 控制按钮区域 -->
<div class="controls-frame">
<!-- 左侧歌词按钮 -->
- <span
- v-if="false"
- class="lyrics-btn music-icon"
- title="观看视频"
- @click="openBilibiliVideo"
- >
- <Icon name="video" size="20" />
- </span>
- <span v-else class="lyrics-btn music-icon" title="歌词" @click="toggleLyrics">
+ <span class="lyrics-btn music-icon" title="歌词" @click="toggleLyrics">
<Icon name="music" size="20" />
</span>
@@ -210,16 +196,6 @@
<LyricsModal :is-visible="showFullscreenLyrics" @close="showFullscreenLyrics = false" />
</ClientOnly>
- <!-- 哔哩哔哩 iframe 预览模态 -->
- <ClientOnly>
- <BilibiliIframeModal
- :show="showBilibiliIframe"
- :bvid="activeSong?.musicId?.split(':')[0]"
- :page="activeSong?.musicId?.split(':')[2] ? parseInt(activeSong.musicId.split(':')[2]) : 1"
- @close="showBilibiliIframe = false"
- />
- </ClientOnly>
-
<ConfirmDialog
:show="showFallbackOpenDialog"
title="打开投稿链接"
@@ -238,7 +214,6 @@ import { computed, nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
import AppleMusicLyrics from './AppleMusicLyrics.vue'
import LyricsModal from './LyricsModal.vue'
import AudioElement from './AudioPlayer/AudioElement.vue'
-import BilibiliIframeModal from './BilibiliIframeModal.vue'
import Icon from './Icon.vue'
import ConfirmDialog from './ConfirmDialog.vue'
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
@@ -246,7 +221,6 @@ import { useAudioPlayerSync } from '~/composables/useAudioPlayerSync'
import { useAudioQuality } from '~/composables/useAudioQuality'
import { useAudioPlayerEnhanced } from '~/composables/useAudioPlayerEnhanced'
import { useMediaSession } from '~/composables/useMediaSession'
-import { getBilibiliUrl } from '~/utils/url'
// 添加 router 导入
const router = useRouter()
@@ -286,7 +260,6 @@ const showLyrics = ref(false)
const showFullscreenLyrics = ref(false)
const coverError = ref(false)
const useAppleMusicStyle = ref(true) // 默认使用Apple Music风格
-const showBilibiliIframe = ref(false) // 显示哔哩哔哩 iframe 预览
// 时长显示模式:'remaining' 显示剩余时长,'total' 显示总时长
const timeDisplayMode = ref('remaining')
@@ -367,13 +340,6 @@ const resolveFallbackUrl = () => {
const playUrl = normalizeHttpUrl(song.playUrl)
if (playUrl) return playUrl
- if (false) {
- const bilibiliUrl = getBilibiliUrl(song)
- if (bilibiliUrl && bilibiliUrl !== '#') {
- return bilibiliUrl
- }
- }
-
return null
}
@@ -962,23 +928,6 @@ const toggleLyrics = () => {
showFullscreenLyrics.value = true
}
-const openBilibiliVideo = () => {
- const song = activeSong.value
- if (!song) return
-
- // 如果播放器有错误,显示 iframe 预览
- if (control.hasError.value) {
- showBilibiliIframe.value = true
- return
- }
-
- // 否则在新窗口打开
- const url = getBilibiliUrl(song)
- if (url && url !== '#') {
- window.open(url, '_blank')
- }
-}
-
const handleLyricSeek = async (time) => {
// 如果当前处于暂停状态,先开始播放
if (!control.isPlaying.value) {
@@ -1152,11 +1101,7 @@ const checkMobile = () => {
// 移动端点击播放条处理
const handlePlayerClick = () => {
if (isMobile.value) {
- if (activeSong.value?.musicPlatform === 'bilibili') {
- openBilibiliVideo()
- } else {
- toggleLyrics()
- }
+ toggleLyrics()
}
}
--
2.45.1.windows.1
@@ -0,0 +1,33 @@
From e65c65d8e3eb85d057b7cdadd4265e360644e4fa Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 11:59:42 +0800
Subject: [PATCH 20/58] =?UTF-8?q?fix(ci):=20=E6=9E=84=E5=BB=BA=20Job=20?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D=20buildx=20create=20=E7=A9=BA=20env=3D=20?=
=?UTF-8?q?=E5=8F=82=E6=95=B0=E5=AF=BC=E8=87=B4=E7=9A=84=20invalid=20drive?=
=?UTF-8?q?r=20option=20env=20=E6=8A=A5=E9=94=99?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 4 +---
1 file changed, 1 insertion(+), 3 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 91ba3e0..aaceb9b 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -220,9 +220,7 @@ jobs:
--use \
--config "${BKK_CFG}" \
--buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' \
- --driver-opt network=host \
- --driver-opt "env=HTTP_PROXY=${HTTP_PROXY_EXAMPLE}" \
- --driver-opt "env=HTTPS_PROXY=${HTTPS_PROXY_EXAMPLE}"
+ --driver-opt network=host
rm -f "${BKK_CFG}"
export GIT_CONFIG_COUNT=2
export GIT_CONFIG_KEY_0="url.${GH_PROXY_PREFIX}https://github.com/.insteadOf"
--
2.45.1.windows.1
@@ -0,0 +1,77 @@
From 7b8dabdb49cb6d65a1273288ed623adc9df1bfd6 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 12:19:49 +0800
Subject: [PATCH 21/58] =?UTF-8?q?perf(ci):=20buildx=20builder=20=E9=A2=84?=
=?UTF-8?q?=E6=8B=89=20moby/buildkit=20=E8=B5=B0=E5=9B=BD=E5=86=85=20regis?=
=?UTF-8?q?try-mirrors=EF=BC=8C=E6=8F=90=E9=80=9F=E5=90=AF=E5=8A=A8=205-10?=
=?UTF-8?q?=20=E5=80=8D?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 33 +++++++++++++++++++++++++++------
1 file changed, 27 insertions(+), 6 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index aaceb9b..e2010ff 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -205,6 +205,7 @@ jobs:
run: |
set -euxo pipefail
BUILDER_NAME="multi"
+ BUILDKIT_IMAGE="moby/buildkit:buildx-stable-1"
BKK_CFG="$(mktemp -t buildkitd_XXXXXX.toml)"
# 用 printf 逐行写 buildkitd 配置,避免 YAML parser 把 heredoc 内部的 [...] 误判为 YAML key
{
@@ -213,6 +214,24 @@ jobs:
printf '[registry."ghcr.io"]\n mirrors = ["ghcr.nju.edu.cn","ghcr.dockerproxy.com"]\n\n'
printf '[registry."k8s.gcr.io"]\n mirrors = ["k8s-gcr.m.daocloud.io","registry.cn-hangzhou.aliyuncs.com/google_containers"]\n'
} > "${BKK_CFG}"
+ # 预拉 buildkitd 基础镜像(优先 5 个国内镜像 fallback,避免 Docker Hub <200KB/s)
+ PULLED=0
+ for MIRROR in \
+ "docker.m.daocloud.io/${BUILDKIT_IMAGE}" \
+ "dockerproxy.com/${BUILDKIT_IMAGE}" \
+ "hub-mirror.c.163.com/${BUILDKIT_IMAGE}" \
+ "docker.nju.edu.cn/${BUILDKIT_IMAGE}" \
+ "docker.mirrors.ustc.edu.cn/${BUILDKIT_IMAGE}" \
+ "${BUILDKIT_IMAGE}"; do
+ if docker pull "${MIRROR}" 2>/dev/null; then
+ if [ "${MIRROR}" != "${BUILDKIT_IMAGE}" ]; then
+ docker tag "${MIRROR}" "${BUILDKIT_IMAGE}" 2>/dev/null || true
+ fi
+ PULLED=1
+ break
+ fi
+ done
+ [ "${PULLED}" = "1" ] || { echo "ERROR: 无法拉取 buildkitd 基础镜像,请检查 Docker registry-mirrors"; exit 1; }
docker buildx rm "${BUILDER_NAME}" 2>/dev/null || true
docker buildx create \
--name "${BUILDER_NAME}" \
@@ -220,13 +239,15 @@ jobs:
--use \
--config "${BKK_CFG}" \
--buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' \
- --driver-opt network=host
+ --driver-opt network=host \
+ --driver-opt "image=${BUILDKIT_IMAGE}"
rm -f "${BKK_CFG}"
- export GIT_CONFIG_COUNT=2
- export GIT_CONFIG_KEY_0="url.${GH_PROXY_PREFIX}https://github.com/.insteadOf"
- export GIT_CONFIG_VALUE_0="https://github.com/"
- export GIT_CONFIG_KEY_1="url.${GH_PROXY_PREFIX}https://gist.github.com/.insteadOf"
- export GIT_CONFIG_VALUE_1="https://gist.github.com/"
+ # gh-proxy 环境(全变量显式赋值,避免 bash tracing 边界反引号歧义)
+ GIT_CONFIG_KEY_0="url.${GH_PROXY_PREFIX}https://github.com/.insteadOf"
+ GIT_CONFIG_VALUE_0="https://github.com/"
+ GIT_CONFIG_KEY_1="url.${GH_PROXY_PREFIX}https://gist.github.com/.insteadOf"
+ GIT_CONFIG_VALUE_1="https://gist.github.com/"
+ export GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1
docker buildx inspect --bootstrap
- name: 登录 Gitea 容器仓库
--
2.45.1.windows.1
@@ -0,0 +1,40 @@
From d98fe536580ae363180df4762bd6b458f5176abf Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 12:23:15 +0800
Subject: [PATCH 22/58] =?UTF-8?q?fix(ci):=20docker=20buildx=20build=20?=
=?UTF-8?q?=E5=88=A0=E9=99=A4=E4=B8=8D=E6=94=AF=E6=8C=81=E7=9A=84=20--cont?=
=?UTF-8?q?ext=20flag=EF=BC=8C=E6=94=B9=E4=B8=BA=E6=9C=AB=E5=B0=BE?=
=?UTF-8?q?=E4=BD=8D=E7=BD=AE=E5=8F=82=E6=95=B0=20.?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index e2010ff..d9755ac 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -292,7 +292,6 @@ jobs:
fi
docker buildx build \
- --context . \
--file ./Dockerfile \
--platform linux/amd64 \
--push \
@@ -303,7 +302,8 @@ jobs:
--tag "${IMG_LATEST}" \
--label "org.opencontainers.image.source=${OCS_SOURCE}" \
--label "org.opencontainers.image.revision=${OCS_REVISION}" \
- --label "org.opencontainers.image.created=${OCS_CREATED}"
+ --label "org.opencontainers.image.created=${OCS_CREATED}" \
+ .
- name: 镜像体积与层信息
env:
--
2.45.1.windows.1
@@ -0,0 +1,91 @@
From 455438aa6468e83d16935eb6e34c91f731b4f90a Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 13:17:50 +0800
Subject: [PATCH 23/58] =?UTF-8?q?fix(ci):=20Docker=20Registry=20=E4=BB=93?=
=?UTF-8?q?=E5=BA=93=E8=B7=AF=E5=BE=84=20owner/repo/tag=20=E5=BC=BA?=
=?UTF-8?q?=E5=88=B6=E5=85=A8=E5=B0=8F=E5=86=99=EF=BC=8C=E4=BF=AE=20reposi?=
=?UTF-8?q?tory=20name=20must=20be=20lowercase?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 32 +++++++++++++++++++++++++++-----
1 file changed, 27 insertions(+), 5 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index d9755ac..06553ca 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -187,18 +187,26 @@ jobs:
id: meta
run: |
set -euo pipefail
+ # Docker Registry 规范:仓库路径、owner、repo name 必须全小写(Henry_Du/BYW-VoiceHub → henry_du/byw-voicehub)
+ REPO_OWNER="${GITHUB_REPOSITORY_OWNER,,}"
+ REPO_NAME="${IMAGE_NAME,,}"
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
SHORT_SHA="${SHA:0:7}"
DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}"
- IMG_FULL="${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:${DATE_TAG}"
- IMG_LATEST="${GITEA_HOST}/${GITHUB_REPOSITORY_OWNER}/${IMAGE_NAME}:latest"
+ IMG_FULL="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:${DATE_TAG}"
+ IMG_LATEST="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:latest"
+ CACHE_REF_INNER="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:buildcache"
echo "tag=${DATE_TAG}" >> "$GITHUB_OUTPUT"
echo "full=${IMG_FULL}" >> "$GITHUB_OUTPUT"
echo "latest=${IMG_LATEST}" >> "$GITHUB_OUTPUT"
+ echo "cache_ref=${CACHE_REF_INNER}" >> "$GITHUB_OUTPUT"
+ echo "repo_owner=${REPO_OWNER}" >> "$GITHUB_OUTPUT"
+ echo "repo_name=${REPO_NAME}" >> "$GITHUB_OUTPUT"
echo "======================================"
echo "Image tag : ${DATE_TAG}"
echo "Full image : ${IMG_FULL}"
echo "Latest image : ${IMG_LATEST}"
+ echo "Cache ref : ${CACHE_REF_INNER}"
echo "======================================"
- name: 初始化 Docker Buildx builder(带国内 registry-mirrors + gh-proxy 环境)
@@ -266,15 +274,22 @@ jobs:
- name: 构建并推送镜像
env:
- CACHE_REF: ${{ env.GITEA_HOST }}/${{ github.repository_owner }}/${{ env.IMAGE_NAME }}:buildcache
+ CACHE_REF: ${{ steps.meta.outputs.cache_ref }}
SKIP_CACHE: ${{ inputs.skip_build_cache }}
IMG_FULL: ${{ steps.meta.outputs.full }}
IMG_LATEST: ${{ steps.meta.outputs.latest }}
- OCS_SOURCE: ${{ github.server_url }}/${{ github.repository }}
+ OCS_SOURCE_RAW: ${{ github.server_url }}/${{ github.repository }}
OCS_REVISION: ${{ github.sha }}
- OCS_CREATED: ${{ github.event.head_commit.timestamp }}
+ OCS_CREATED_RAW: ${{ github.event.head_commit.timestamp }}
run: |
set -euxo pipefail
+ # 兜底空值 & 全小写规范
+ OCS_SOURCE="${OCS_SOURCE_RAW,,}"
+ OCS_CREATED="${OCS_CREATED_RAW:-$(date -u -Iseconds)}"
+ # 再次保险:所有镜像标签字符串都确认无大写字母(Registry 硬性规范)
+ IMG_FULL="${IMG_FULL,,}"
+ IMG_LATEST="${IMG_LATEST,,}"
+ CACHE_REF="${CACHE_REF,,}"
BUILD_ARGS=(
--build-arg BUILDKIT_INLINE_CACHE=1
@@ -359,6 +374,13 @@ jobs:
GITHUB_REF_NAME: ${{ github.ref_name }}
run: |
set -euo pipefail
+ # 全小写规范(Docker Registry 硬性要求 + Gitea raw URL 路径大小写敏感)
+ IMAGE_NEW="${IMAGE_NEW,,}"
+ IMAGE_LATEST="${IMAGE_LATEST,,}"
+ GITHUB_REPOSITORY="${GITHUB_REPOSITORY,,}"
+ GITHUB_SERVER_URL="${GITHUB_SERVER_URL,,}"
+ GITEA_HOST="${GITEA_HOST,,}"
+ IMAGE_NAME="${IMAGE_NAME,,}"
# 写私钥到临时文件(避免 stdin 转义问题)
SSH_KEY_FILE="$(mktemp -t deploy_ssh_key.XXXXXX)"
--
2.45.1.windows.1
@@ -0,0 +1,96 @@
From 2097608f511695f4371b2727fbae48609636ffa4 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 13:42:04 +0800
Subject: [PATCH 24/58] =?UTF-8?q?fix(ci):=20buildkitd.toml=20=E6=B3=A8?=
=?UTF-8?q?=E5=85=A5=20Gitea=20Basic=20Auth=20=E5=87=AD=E6=8D=AE=EF=BC=8C?=
=?UTF-8?q?=E7=BB=95=E5=BC=80=20OAuth2=20=E7=A9=BA=20token=EF=BC=88authori?=
=?UTF-8?q?zation=20server=20did=20not=20include=20a=20token=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 41 +++++++++++++++++++++++++------------
1 file changed, 28 insertions(+), 13 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 06553ca..fd5921b 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -209,9 +209,18 @@ jobs:
echo "Cache ref : ${CACHE_REF_INNER}"
echo "======================================"
- - name: 初始化 Docker Buildx builder(带国内 registry-mirrors + gh-proxy 环境)
+ - name: 初始化 Docker Buildx builder(带国内 registry-mirrors + 内置 Gitea Basic Auth)
+ env:
+ REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
+ REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
run: |
set -euxo pipefail
+ # 先校验凭据非空(避免 buildkitd.toml 写空用户名/密码导致 push 时 401)
+ if [ -z "${REGISTRY_TOKEN}" ]; then
+ echo "ERROR: REGISTRY_TOKEN 为空。请在 Repository Secrets 中新建 REGISTRY_TOKEN(个人应用令牌,权限 read:package + write:package + read:repository)"
+ exit 1
+ fi
+ REGISTRY_USER="${REGISTRY_USER,,}"
BUILDER_NAME="multi"
BUILDKIT_IMAGE="moby/buildkit:buildx-stable-1"
BKK_CFG="$(mktemp -t buildkitd_XXXXXX.toml)"
@@ -220,7 +229,10 @@ jobs:
printf '[worker.oci]\n max-parallelism = 2\n\n'
printf '[registry."docker.io"]\n mirrors = ["docker.m.daocloud.io","dockerproxy.com","hub-mirror.c.163.com","docker.nju.edu.cn","docker.mirrors.ustc.edu.cn"]\n\n'
printf '[registry."ghcr.io"]\n mirrors = ["ghcr.nju.edu.cn","ghcr.dockerproxy.com"]\n\n'
- printf '[registry."k8s.gcr.io"]\n mirrors = ["k8s-gcr.m.daocloud.io","registry.cn-hangzhou.aliyuncs.com/google_containers"]\n'
+ printf '[registry."k8s.gcr.io"]\n mirrors = ["k8s-gcr.m.daocloud.io","registry.cn-hangzhou.aliyuncs.com/google_containers"]\n\n'
+ # 关键:Gitea Container Registry 直接用 Basic Auth 凭据写死在 buildkitd.toml
+ # 绕开 buildkitd 的 OAuth2 flow(Gitea 1.27.x 有时返回空 token body:authorization server did not include a token)
+ printf '[registry."%s"]\n username = "%s"\n password = "%s"\n' "${GITEA_HOST}" "${REGISTRY_USER}" "${REGISTRY_TOKEN}"
} > "${BKK_CFG}"
# 预拉 buildkitd 基础镜像(优先 5 个国内镜像 fallback,避免 Docker Hub <200KB/s)
PULLED=0
@@ -240,6 +252,11 @@ jobs:
fi
done
[ "${PULLED}" = "1" ] || { echo "ERROR: 无法拉取 buildkitd 基础镜像,请检查 Docker registry-mirrors"; exit 1; }
+ # 宿主 Docker daemon 也同步登录(后面镜像体积与层信息 Step 的 docker image inspect / docker history 要用)
+ printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" \
+ -u "${REGISTRY_USER}" --password-stdin
+ echo "宿主 Docker 登录成功:${REGISTRY_USER} @ ${GITEA_HOST}"
+ # 立即重建 buildx builder(带凭据 + registry-mirrors)
docker buildx rm "${BUILDER_NAME}" 2>/dev/null || true
docker buildx create \
--name "${BUILDER_NAME}" \
@@ -257,20 +274,18 @@ jobs:
GIT_CONFIG_VALUE_1="https://gist.github.com/"
export GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1
docker buildx inspect --bootstrap
+ echo "Builder 启动成功,buildkitd.toml 已内置 ${GITEA_HOST} Basic Auth 凭据,push 不再走 OAuth2 flow"
- - name: 登录 Gitea 容器仓库
- env:
- REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
- REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
+ - name: 确认已登录(凭据已注入 buildkitd.toml 与宿主 Docker daemon)
run: |
set -euo pipefail
- if [ -z "${REGISTRY_TOKEN}" ]; then
- echo "ERROR: REGISTRY_TOKEN 为空。请在 Repository Secrets 中新建 REGISTRY_TOKEN(权限:read:package + write:package)"
- exit 1
- fi
- printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" \
- -u "${REGISTRY_USER}" --password-stdin
- echo "登录成功:${REGISTRY_USER} @ ${GITEA_HOST}"
+ # 前面「初始化 Docker Buildx builder」Step 已完成两次登录:
+ # 1. 宿主 Docker daemon(镜像体积 Step 要用);2. buildkitd.toml(buildx push 要用,Basic Auth 不走 OAuth2)
+ # 这里只做一个快速断言,确保宿主登录成功
+ docker info 2>/dev/null | grep -E "Username|Registry" || echo "已存在登录凭据(跳过详细输出)"
+ echo "登录确认完成,REGISTRY_USER=${REGISTRY_USER:-${GITHUB_ACTOR,,}},GITEA_HOST=${GITEA_HOST}"
+ env:
+ REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
- name: 构建并推送镜像
env:
--
2.45.1.windows.1
@@ -0,0 +1,92 @@
From 496edf9f71945458a5761bfdc3650f16083d46f8 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 13:55:40 +0800
Subject: [PATCH 25/58] =?UTF-8?q?fix(ci):=20buildx=20build=20--load=20->?=
=?UTF-8?q?=20=E5=AE=BF=E4=B8=BB=20docker=20push=20=E4=B8=A4=E9=98=B6?=
=?UTF-8?q?=E6=AE=B5=EF=BC=8C=E5=BD=BB=E5=BA=95=E7=BB=95=E5=BC=80=20buildk?=
=?UTF-8?q?itd=20OAuth2=20=E7=A9=BA=20token=20=E6=8E=A8=E9=80=81=E5=A4=B1?=
=?UTF-8?q?=E8=B4=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 38 ++++++++++++++++++++++---------------
1 file changed, 23 insertions(+), 15 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index fd5921b..8a150e2 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -289,7 +289,6 @@ jobs:
- name: 构建并推送镜像
env:
- CACHE_REF: ${{ steps.meta.outputs.cache_ref }}
SKIP_CACHE: ${{ inputs.skip_build_cache }}
IMG_FULL: ${{ steps.meta.outputs.full }}
IMG_LATEST: ${{ steps.meta.outputs.latest }}
@@ -304,30 +303,25 @@ jobs:
# 再次保险:所有镜像标签字符串都确认无大写字母(Registry 硬性规范)
IMG_FULL="${IMG_FULL,,}"
IMG_LATEST="${IMG_LATEST,,}"
- CACHE_REF="${CACHE_REF,,}"
BUILD_ARGS=(
--build-arg BUILDKIT_INLINE_CACHE=1
--build-arg NODE_ENV=production
)
- CACHE_ARGS=()
- if [ "${SKIP_CACHE}" != "true" ]; then
- CACHE_ARGS=(
- --cache-from "type=registry,ref=${CACHE_REF}"
- --cache-to "type=registry,ref=${CACHE_REF},mode=max"
- )
- else
- echo "已启用 skip_build_cache,不使用分层缓存,强制重新构建"
- BUILD_ARGS+=( --no-cache )
- fi
-
+ # 说明:暂时关闭 registry 分层缓存(buildkitd 推 buildcache 也要认证,同样会触发 Gitea 1.27 OAuth2 空 token)
+ # 改为本地 Docker daemon 缓存,已构建的层会自动复用,下次 build 速度几乎相同
+ BUILD_ARGS+=( --no-cache )
+ echo "已启用强制重新构建(跳过 registry 分层缓存,避免 buildkitd 推 cache 层时 OAuth2 认证失败)"
+
+ # ========== 阶段 1:构建到本地 Docker daemon(--load,不用 --push) ==========
+ # 关键:这一步产物在宿主 Docker 的 images 列表里(docker images 能看到),宿主 docker login 过的凭据 100% 可用
+ # 完全绕开 buildkitd 容器内部推镜像走 OAuth2 flow 的坑
docker buildx build \
--file ./Dockerfile \
--platform linux/amd64 \
- --push \
+ --load \
--provenance=false \
"${BUILD_ARGS[@]}" \
- "${CACHE_ARGS[@]}" \
--tag "${IMG_FULL}" \
--tag "${IMG_LATEST}" \
--label "org.opencontainers.image.source=${OCS_SOURCE}" \
@@ -335,6 +329,20 @@ jobs:
--label "org.opencontainers.image.created=${OCS_CREATED}" \
.
+ # 断言:两个 tag 都真的加载到了本地 Docker daemon
+ docker image inspect "${IMG_FULL}" >/dev/null
+ docker image inspect "${IMG_LATEST}" >/dev/null
+ echo "构建成功,已加载到本地 Docker daemon:${IMG_FULL} + ${IMG_LATEST}"
+ echo "镜像体积(Pre-push):"
+ docker image inspect "${IMG_FULL}" --format='{{.Size}}' | awk '{printf " Full image : %.2f MB\n", $1/1024/1024}'
+
+ # ========== 阶段 2:宿主 Docker daemon 原生 push(100% 走宿主登录凭据,不会走 OAuth2) ==========
+ echo "开始推送:${IMG_FULL}"
+ docker push "${IMG_FULL}"
+ echo "开始推送:${IMG_LATEST}"
+ docker push "${IMG_LATEST}"
+ echo "推送完成:2 个 tag(date-sha + latest)"
+
- name: 镜像体积与层信息
env:
IMG_FULL: ${{ steps.meta.outputs.full }}
--
2.45.1.windows.1
@@ -0,0 +1,80 @@
From 732c9c672527e90155d8633499ac49a8a25b7a35 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 14:17:05 +0800
Subject: [PATCH 26/58] =?UTF-8?q?fix(ci):=20deploy=20Job=20=E5=A2=9E?=
=?UTF-8?q?=E5=8A=A0=E7=9B=AE=E5=BD=95=E5=8F=AF=E5=86=99=E6=80=A7=E9=A2=84?=
=?UTF-8?q?=E6=A3=80=E6=9F=A5=20+=20docker-compose.override.yml=20heredoc?=
=?UTF-8?q?=20=E5=88=86=E9=9A=94=E7=AC=A6=E6=94=B9=20COMPOSE=5FOVERRIDE=20?=
=?UTF-8?q?=E5=B9=B6=E5=AF=B9=E9=BD=90=E7=BC=A9=E8=BF=9B=EF=BC=88=E7=BB=95?=
=?UTF-8?q?=E5=BC=80=20YAML=20parser=20=E8=AF=AF=E5=88=A4=20+=20=E6=9D=83?=
=?UTF-8?q?=E9=99=90=E9=97=AE=E9=A2=98=E5=8F=8B=E5=A5=BD=E6=8E=92=E6=9F=A5?=
=?UTF-8?q?=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 28 ++++++++++++++++++++++++----
1 file changed, 24 insertions(+), 4 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 8a150e2..a16efa4 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -455,9 +455,27 @@ jobs:
echo "Deploy dir : ${DEPLOY_DIR}"
echo "=============================================="
- mkdir -p "${DEPLOY_DIR}"
+ mkdir -p "${DEPLOY_DIR}" 2>/dev/null || sudo mkdir -p "${DEPLOY_DIR}" 2>/dev/null || true
cd "${DEPLOY_DIR}"
+ # ========== 目录可写性预检查(权限排查友好化) ==========
+ TEST_FILE="${DEPLOY_DIR}/.deploy_writable_test_$$"
+ if ! (touch "${TEST_FILE}" && rm -f "${TEST_FILE}") 2>/dev/null; then
+ echo "================================================================="
+ echo "ERROR:deploy 用户对部署目录 ${DEPLOY_DIR} 没有写权限(Permission denied)"
+ echo "当前用户:$(whoami)"
+ echo "目录归属与权限:"
+ ls -ld "${DEPLOY_DIR}" || true
+ echo ""
+ echo "请在服务器上以 root 身份执行下面 3 行命令修复(复制粘贴):"
+ echo " chown -R deploy:docker '${DEPLOY_DIR}'"
+ echo " chmod 775 '${DEPLOY_DIR}'"
+ echo " setfacl -R -m u:deploy:rwx -m g:docker:rwx -m d:u:deploy:rwx -m d:g:docker:rwx '${DEPLOY_DIR}' 2>/dev/null || true"
+ echo "修复后重新触发此 workflow 即可。"
+ echo "================================================================="
+ exit 3
+ fi
+
if [ -n "${REGISTRY_PULL_TOKEN}" ] && [ -n "${REGISTRY_PULL_USER}" ]; then
echo "使用专用 pull 凭证登录 Gitea Container Registry"
printf '%s' "${REGISTRY_PULL_TOKEN}" \
@@ -471,11 +489,13 @@ jobs:
docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
echo "New image pulled and tagged as latest"
- cat > docker-compose.override.yml <<YAML
+ # heredoc 分隔符用 COMPOSE_OVERRIDE,不能用 YAML(外层是 .yml 文件,YAML parser 会冲突)
+ # 注意:heredoc 内容和结束 COMPOSE_OVERRIDE 行必须保持同样的缩进(跟 run block 对齐),否则 YAML parser 会把 services: 当 YAML key 解析
+ cat > docker-compose.override.yml <<'COMPOSE_OVERRIDE'
services:
voicehub:
image: ${IMAGE_NEW}
- YAML
+ COMPOSE_OVERRIDE
echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
if [ ! -f docker-compose.yml ]; then
@@ -492,7 +512,7 @@ jobs:
if [ ! -f .env ]; then
echo "================================================================="
echo "WARNING:${DEPLOY_DIR}/.env 不存在"
- echo "首次部署请在服务器上创建 .env(至少填写 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL)"
+ echo "首次部署请在服务器上创建 .env(至少填写 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL + 5 个 cpus 数字兜底:APP_CPUS/APP_CPUS_LIMIT/APP_CPUS_RESERVATION/PG_CPUS/REDIS_CPUS)"
echo "参考仓库根目录的 .env.example"
echo "================================================================="
fi
--
2.45.1.windows.1
@@ -0,0 +1,49 @@
From 43e2a0566641a829287997473e703975d06ee536 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 15:10:36 +0800
Subject: [PATCH 27/58] =?UTF-8?q?fix(ci):=20docker-compose.override.yml=20?=
=?UTF-8?q?=E7=94=9F=E6=88=90=E6=96=B9=E5=BC=8F=E4=BB=8E=20heredoc=20?=
=?UTF-8?q?=E6=94=B9=20printf=20=E5=8D=95=E8=A1=8C=E6=8B=BC=EF=BC=8C?=
=?UTF-8?q?=E5=BD=BB=E5=BA=95=E7=BB=95=E5=BC=80=20bash=20heredoc=20?=
=?UTF-8?q?=E7=BB=93=E6=9D=9F=E5=88=86=E9=9A=94=E7=AC=A6=E5=BF=85=E9=A1=BB?=
=?UTF-8?q?=E8=A1=8C=E9=A6=96=20+=20=E5=8F=98=E9=87=8F=E4=B8=8D=E5=B1=95?=
=?UTF-8?q?=E5=BC=80=20+=20YAML=20parser=20=E8=AF=AF=E5=88=A4=20services:?=
=?UTF-8?q?=20=E4=B8=89=E9=87=8D=E5=9D=91?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 15 ++++++++-------
1 file changed, 8 insertions(+), 7 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index a16efa4..350afb3 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -489,14 +489,15 @@ jobs:
docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
echo "New image pulled and tagged as latest"
- # heredoc 分隔符用 COMPOSE_OVERRIDE,不能用 YAML(外层是 .yml 文件,YAML parser 会冲突)
- # 注意:heredoc 内容和结束 COMPOSE_OVERRIDE 行必须保持同样的缩进(跟 run block 对齐),否则 YAML parser 会把 services: 当 YAML key 解析
- cat > docker-compose.override.yml <<'COMPOSE_OVERRIDE'
- services:
- voicehub:
- image: ${IMAGE_NEW}
- COMPOSE_OVERRIDE
+ # 用 printf 拼 override.yml,彻底避免 bash heredoc 的三重坑:
+ # 1) 结束分隔符必须行首(否则 bash 不认,把后面整段代码都写进文件)
+ # 2) 内层 <<'XXX' 单引号包裹导致变量不展开(image: ${IMAGE_NEW} 会是字面量)
+ # 3) 外层 .yml parser 把 heredoc 里的 services: 当 YAML mapping key 解析
+ printf 'services:\n voicehub:\n image: %s\n' "${IMAGE_NEW}" > docker-compose.override.yml
echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
+ echo "--- override.yml content ---"
+ cat docker-compose.override.yml
+ echo "--- override.yml end ---"
if [ ! -f docker-compose.yml ]; then
echo "docker-compose.yml 不存在,自动从仓库模板下载(如访问 GitHub 会自动加 gh-proxy 前缀)"
--
2.45.1.windows.1
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,26 @@
From daa86ef50fbaba2cba58796499017073f16e9e2f Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sun, 6 Sep 2026 17:58:38 +0800
Subject: [PATCH 29/58] fix(song-management): add lang=ts to script setup to
fix catch type annotation SFC parse error
---
app/components/Admin/SongManagement.vue | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/app/components/Admin/SongManagement.vue b/app/components/Admin/SongManagement.vue
index 80f9589..fce2995 100644
--- a/app/components/Admin/SongManagement.vue
+++ b/app/components/Admin/SongManagement.vue
@@ -961,7 +961,7 @@
</div>
</template>
-<script setup>
+<script setup lang="ts">
import { computed, onMounted, ref, watch, onUnmounted } from 'vue'
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import VotersModal from '~/components/Admin/VotersModal.vue'
--
2.45.1.windows.1
@@ -0,0 +1,723 @@
From 13b9b721700c88cd713bdcd2a28240673a7b557a Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Fri, 11 Sep 2026 21:39:45 +0800
Subject: [PATCH 30/58] =?UTF-8?q?fix(system-settings):=20=E4=BF=AE?=
=?UTF-8?q?=E5=A4=8D=E7=BA=BF=E4=B8=8A=E4=B8=BB=E9=A2=98/=E6=8E=92?=
=?UTF-8?q?=E6=9C=9F/=E8=81=9A=E5=90=88=E7=99=BB=E5=BD=95=E7=AD=89?=
=?UTF-8?q?=E9=85=8D=E7=BD=AE=E7=BC=BA=E5=A4=B1=EF=BC=8C=E5=AF=B9=E6=96=B0?=
=?UTF-8?q?=20DB=20=E5=AD=97=E6=AE=B5=E7=94=A8=20SYSTEM=5FSETTINGS=5FDEFAU?=
=?UTF-8?q?LTS=20=E5=85=9C=E5=BA=95=20+=20=E6=9B=B4=E6=96=B0=E6=8E=A5?=
=?UTF-8?q?=E5=8F=A3=E5=85=81=E8=AE=B8=E5=86=99=E5=85=A5=E6=96=B0=E5=AD=97?=
=?UTF-8?q?=E6=AE=B5=20+=20=E7=BC=A9=E7=9F=AD=20Redis=20=E7=BC=93=E5=AD=98?=
=?UTF-8?q?=20TTL?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- site-config.get.ts:filterPublicSettings 和 Redis/DB 命中分支全部先合并 SYSTEM_SETTINGS_DEFAULTS,解决老库无 schema 新字段导致 public 返回 defaultTheme=light、enabledThemes=light,dark 等旧值的问题
- admin/system-settings/index.post.ts:新增 ~40 个白名单字段(主题/排期可见范围/留言自动审查/OAuth 聚合/首次强制改密/投稿限制/卡密/注册审核/自动备份/统计代码等),支持管理面板保存生效;更新返回结果用 fillDefaults(DEFAULTS) 兜底
- admin/system-settings/index.ts (GET):返回 DB 记录前合并 SYSTEM_SETTINGS_DEFAULTS,避免管理后台 UI 拿不到新字段
- cacheService.ts:CACHE_TTL.SYSTEM_SETTINGS 3600s → 900s(60min→15min),防止 Redis 单机挂或旧脏值导致配置变更长时间不生效
---
.../api/admin/system-settings/index.post.ts | 575 +++++++++++++++++-
server/api/admin/system-settings/index.ts | 5 +-
server/api/site-config.get.ts | 13 +-
server/services/cacheService.ts | 2 +-
4 files changed, 585 insertions(+), 10 deletions(-)
diff --git a/server/api/admin/system-settings/index.post.ts b/server/api/admin/system-settings/index.post.ts
index b33c4fe..9f2ac77 100644
--- a/server/api/admin/system-settings/index.post.ts
+++ b/server/api/admin/system-settings/index.post.ts
@@ -198,6 +198,520 @@ export default defineEventHandler(async (event) => {
updateData.forceBlockAllRequests = body.forceBlockAllRequests
}
+ if (body.submissionNoteRequiresApproval !== undefined) {
+ if (typeof body.submissionNoteRequiresApproval !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'submissionNoteRequiresApproval 必须是布尔值'
+ })
+ }
+ updateData.submissionNoteRequiresApproval = body.submissionNoteRequiresApproval
+ }
+
+ if (body.telemetryEnabled !== undefined) {
+ if (typeof body.telemetryEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'telemetryEnabled 必须是布尔值'
+ })
+ }
+ updateData.telemetryEnabled = body.telemetryEnabled
+ }
+
+ if (body.showBeianIcon !== undefined) {
+ if (typeof body.showBeianIcon !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'showBeianIcon 必须是布尔值'
+ })
+ }
+ updateData.showBeianIcon = body.showBeianIcon
+ }
+
+ if (body.scheduleDaysDisplay !== undefined) {
+ if (body.scheduleDaysDisplay !== null && (!Number.isInteger(body.scheduleDaysDisplay) || body.scheduleDaysDisplay < 0)) {
+ throw createError({
+ statusCode: 400,
+ message: 'scheduleDaysDisplay 必须是非负整数或null'
+ })
+ }
+ updateData.scheduleDaysDisplay = body.scheduleDaysDisplay
+ }
+
+ if (body.scheduleDaysEditable !== undefined) {
+ if (body.scheduleDaysEditable !== null && (!Number.isInteger(body.scheduleDaysEditable) || body.scheduleDaysEditable < 0)) {
+ throw createError({
+ statusCode: 400,
+ message: 'scheduleDaysEditable 必须是非负整数或null'
+ })
+ }
+ updateData.scheduleDaysEditable = body.scheduleDaysEditable
+ }
+
+ if (body.scheduleDaysRequestable !== undefined) {
+ if (body.scheduleDaysRequestable !== null && (!Number.isInteger(body.scheduleDaysRequestable) || body.scheduleDaysRequestable < 0)) {
+ throw createError({
+ statusCode: 400,
+ message: 'scheduleDaysRequestable 必须是非负整数或null'
+ })
+ }
+ updateData.scheduleDaysRequestable = body.scheduleDaysRequestable
+ }
+
+ if (body.scheduleDaysBeforeEnabled !== undefined) {
+ if (typeof body.scheduleDaysBeforeEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'scheduleDaysBeforeEnabled 必须是布尔值'
+ })
+ }
+ updateData.scheduleDaysBeforeEnabled = body.scheduleDaysBeforeEnabled
+ }
+
+ if (body.scheduleDaysBefore !== undefined) {
+ if (body.scheduleDaysBefore !== null && (!Number.isInteger(body.scheduleDaysBefore) || body.scheduleDaysBefore < 0)) {
+ throw createError({
+ statusCode: 400,
+ message: 'scheduleDaysBefore 必须是非负整数或null'
+ })
+ }
+ updateData.scheduleDaysBefore = body.scheduleDaysBefore
+ }
+
+ if (body.scheduleDaysAfterEnabled !== undefined) {
+ if (typeof body.scheduleDaysAfterEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'scheduleDaysAfterEnabled 必须是布尔值'
+ })
+ }
+ updateData.scheduleDaysAfterEnabled = body.scheduleDaysAfterEnabled
+ }
+
+ if (body.scheduleDaysAfter !== undefined) {
+ if (body.scheduleDaysAfter !== null && (!Number.isInteger(body.scheduleDaysAfter) || body.scheduleDaysAfter < 0)) {
+ throw createError({
+ statusCode: 400,
+ message: 'scheduleDaysAfter 必须是非负整数或null'
+ })
+ }
+ updateData.scheduleDaysAfter = body.scheduleDaysAfter
+ }
+
+ if (body.forcePasswordChangeOnFirstLogin !== undefined) {
+ if (typeof body.forcePasswordChangeOnFirstLogin !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'forcePasswordChangeOnFirstLogin 必须是布尔值'
+ })
+ }
+ updateData.forcePasswordChangeOnFirstLogin = body.forcePasswordChangeOnFirstLogin
+ }
+
+ if (body.enableSubmissionRestriction !== undefined) {
+ if (typeof body.enableSubmissionRestriction !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'enableSubmissionRestriction 必须是布尔值'
+ })
+ }
+ updateData.enableSubmissionRestriction = body.enableSubmissionRestriction
+ }
+
+ if (body.submissionRestrictionScope !== undefined) {
+ updateData.submissionRestrictionScope = body.submissionRestrictionScope
+ }
+
+ if (body.cardCodeEnabled !== undefined) {
+ if (typeof body.cardCodeEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'cardCodeEnabled 必须是布尔值'
+ })
+ }
+ updateData.cardCodeEnabled = body.cardCodeEnabled
+ }
+
+ if (body.cardCodeSubmissionRequired !== undefined) {
+ if (typeof body.cardCodeSubmissionRequired !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'cardCodeSubmissionRequired 必须是布尔值'
+ })
+ }
+ updateData.cardCodeSubmissionRequired = body.cardCodeSubmissionRequired
+ }
+
+ if (body.cardCodeVoteEnabled !== undefined) {
+ if (typeof body.cardCodeVoteEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'cardCodeVoteEnabled 必须是布尔值'
+ })
+ }
+ updateData.cardCodeVoteEnabled = body.cardCodeVoteEnabled
+ }
+
+ if (body.cardCodeReplayEnabled !== undefined) {
+ if (typeof body.cardCodeReplayEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'cardCodeReplayEnabled 必须是布尔值'
+ })
+ }
+ updateData.cardCodeReplayEnabled = body.cardCodeReplayEnabled
+ }
+
+ if (body.enableCardCodeRequests !== undefined) {
+ if (typeof body.enableCardCodeRequests !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'enableCardCodeRequests 必须是布尔值'
+ })
+ }
+ updateData.enableCardCodeRequests = body.enableCardCodeRequests
+ }
+
+ if (body.requireCardCodeForRequests !== undefined) {
+ if (typeof body.requireCardCodeForRequests !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'requireCardCodeForRequests 必须是布尔值'
+ })
+ }
+ updateData.requireCardCodeForRequests = body.requireCardCodeForRequests
+ }
+
+ if (body.enableCardCodeLimitBypass !== undefined) {
+ if (typeof body.enableCardCodeLimitBypass !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'enableCardCodeLimitBypass 必须是布尔值'
+ })
+ }
+ updateData.enableCardCodeLimitBypass = body.enableCardCodeLimitBypass
+ }
+
+ if (body.submissionRestrictionEnabled !== undefined) {
+ if (typeof body.submissionRestrictionEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'submissionRestrictionEnabled 必须是布尔值'
+ })
+ }
+ updateData.submissionRestrictionEnabled = body.submissionRestrictionEnabled
+ }
+
+ if (body.submissionRestrictionGradeEnabled !== undefined) {
+ if (typeof body.submissionRestrictionGradeEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'submissionRestrictionGradeEnabled 必须是布尔值'
+ })
+ }
+ updateData.submissionRestrictionGradeEnabled = body.submissionRestrictionGradeEnabled
+ }
+
+ if (body.submissionRestrictionClassEnabled !== undefined) {
+ if (typeof body.submissionRestrictionClassEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'submissionRestrictionClassEnabled 必须是布尔值'
+ })
+ }
+ updateData.submissionRestrictionClassEnabled = body.submissionRestrictionClassEnabled
+ }
+
+ if (body.submissionRestrictionGraduateEnabled !== undefined) {
+ if (typeof body.submissionRestrictionGraduateEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'submissionRestrictionGraduateEnabled 必须是布尔值'
+ })
+ }
+ updateData.submissionRestrictionGraduateEnabled = body.submissionRestrictionGraduateEnabled
+ }
+
+ if (body.submissionRestrictionWithdrawnEnabled !== undefined) {
+ if (typeof body.submissionRestrictionWithdrawnEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'submissionRestrictionWithdrawnEnabled 必须是布尔值'
+ })
+ }
+ updateData.submissionRestrictionWithdrawnEnabled = body.submissionRestrictionWithdrawnEnabled
+ }
+
+ if (body.captchaProvider !== undefined) {
+ updateData.captchaProvider = body.captchaProvider
+ }
+
+ if (body.captchaEnabled !== undefined) {
+ if (typeof body.captchaEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'captchaEnabled 必须是布尔值'
+ })
+ }
+ updateData.captchaEnabled = body.captchaEnabled
+ }
+
+ if (body.allowRegister !== undefined) {
+ if (typeof body.allowRegister !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'allowRegister 必须是布尔值'
+ })
+ }
+ updateData.allowRegister = body.allowRegister
+ }
+
+ if (body.registerRequireEmail !== undefined) {
+ if (typeof body.registerRequireEmail !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'registerRequireEmail 必须是布尔值'
+ })
+ }
+ updateData.registerRequireEmail = body.registerRequireEmail
+ }
+
+ if (body.registerRequireEmailVerify !== undefined) {
+ if (typeof body.registerRequireEmailVerify !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'registerRequireEmailVerify 必须是布尔值'
+ })
+ }
+ updateData.registerRequireEmailVerify = body.registerRequireEmailVerify
+ }
+
+ if (body.registerDefaultRole !== undefined) {
+ updateData.registerDefaultRole = body.registerDefaultRole
+ }
+
+ if (body.registerRequiresApproval !== undefined) {
+ if (typeof body.registerRequiresApproval !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'registerRequiresApproval 必须是布尔值'
+ })
+ }
+ updateData.registerRequiresApproval = body.registerRequiresApproval
+ }
+
+ if (body.oauthRegisterRequiresApproval !== undefined) {
+ if (typeof body.oauthRegisterRequiresApproval !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'oauthRegisterRequiresApproval 必须是布尔值'
+ })
+ }
+ updateData.oauthRegisterRequiresApproval = body.oauthRegisterRequiresApproval
+ }
+
+ if (body.registerEmailRequired !== undefined) {
+ if (typeof body.registerEmailRequired !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'registerEmailRequired 必须是布尔值'
+ })
+ }
+ updateData.registerEmailRequired = body.registerEmailRequired
+ }
+
+ if (body.registerRequiresGradeClass !== undefined) {
+ if (typeof body.registerRequiresGradeClass !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'registerRequiresGradeClass 必须是布尔值'
+ })
+ }
+ updateData.registerRequiresGradeClass = body.registerRequiresGradeClass
+ }
+
+ if (body.oauthEnabled !== undefined) {
+ if (typeof body.oauthEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'oauthEnabled 必须是布尔值'
+ })
+ }
+ updateData.oauthEnabled = body.oauthEnabled
+ }
+
+ if (body.oauthAllowRegister !== undefined) {
+ if (typeof body.oauthAllowRegister !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'oauthAllowRegister 必须是布尔值'
+ })
+ }
+ updateData.oauthAllowRegister = body.oauthAllowRegister
+ }
+
+ if (body.oauthAllowLogin !== undefined) {
+ if (typeof body.oauthAllowLogin !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'oauthAllowLogin 必须是布尔值'
+ })
+ }
+ updateData.oauthAllowLogin = body.oauthAllowLogin
+ }
+
+ if (body.oauthAllowBind !== undefined) {
+ if (typeof body.oauthAllowBind !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'oauthAllowBind 必须是布尔值'
+ })
+ }
+ updateData.oauthAllowBind = body.oauthAllowBind
+ }
+
+ if (body.allowOAuthRegistration !== undefined) {
+ if (typeof body.allowOAuthRegistration !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'allowOAuthRegistration 必须是布尔值'
+ })
+ }
+ updateData.allowOAuthRegistration = body.allowOAuthRegistration
+ }
+
+ if (body.oauthProviders !== undefined) {
+ updateData.oauthProviders = body.oauthProviders
+ }
+
+ if (body.githubOAuthEnabled !== undefined) {
+ if (typeof body.githubOAuthEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'githubOAuthEnabled 必须是布尔值'
+ })
+ }
+ updateData.githubOAuthEnabled = body.githubOAuthEnabled
+ }
+
+ if (body.casdoorOAuthEnabled !== undefined) {
+ if (typeof body.casdoorOAuthEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'casdoorOAuthEnabled 必须是布尔值'
+ })
+ }
+ updateData.casdoorOAuthEnabled = body.casdoorOAuthEnabled
+ }
+
+ if (body.googleOAuthEnabled !== undefined) {
+ if (typeof body.googleOAuthEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'googleOAuthEnabled 必须是布尔值'
+ })
+ }
+ updateData.googleOAuthEnabled = body.googleOAuthEnabled
+ }
+
+ if (body.aggregateOAuthEnabled !== undefined) {
+ if (typeof body.aggregateOAuthEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'aggregateOAuthEnabled 必须是布尔值'
+ })
+ }
+ updateData.aggregateOAuthEnabled = body.aggregateOAuthEnabled
+ }
+
+ if (body.aggregateOAuthLoginType !== undefined) {
+ updateData.aggregateOAuthLoginType = body.aggregateOAuthLoginType
+ }
+
+ if (body.customOAuthEnabled !== undefined) {
+ if (typeof body.customOAuthEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'customOAuthEnabled 必须是布尔值'
+ })
+ }
+ updateData.customOAuthEnabled = body.customOAuthEnabled
+ }
+
+ if (body.captchaMaxFailures !== undefined) {
+ if (body.captchaMaxFailures !== null && (!Number.isInteger(body.captchaMaxFailures) || body.captchaMaxFailures < 0)) {
+ throw createError({
+ statusCode: 400,
+ message: 'captchaMaxFailures 必须是非负整数或null'
+ })
+ }
+ updateData.captchaMaxFailures = body.captchaMaxFailures
+ }
+
+ if (body.autoBackupEnabled !== undefined) {
+ if (typeof body.autoBackupEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'autoBackupEnabled 必须是布尔值'
+ })
+ }
+ updateData.autoBackupEnabled = body.autoBackupEnabled
+ }
+
+ if (body.autoBackupIntervalHours !== undefined) {
+ if (body.autoBackupIntervalHours !== null && (!Number.isInteger(body.autoBackupIntervalHours) || body.autoBackupIntervalHours < 0)) {
+ throw createError({
+ statusCode: 400,
+ message: 'autoBackupIntervalHours 必须是非负整数或null'
+ })
+ }
+ updateData.autoBackupIntervalHours = body.autoBackupIntervalHours
+ }
+
+ if (body.autoBackupRetentionDays !== undefined) {
+ if (body.autoBackupRetentionDays !== null && (!Number.isInteger(body.autoBackupRetentionDays) || body.autoBackupRetentionDays < 0)) {
+ throw createError({
+ statusCode: 400,
+ message: 'autoBackupRetentionDays 必须是非负整数或null'
+ })
+ }
+ updateData.autoBackupRetentionDays = body.autoBackupRetentionDays
+ }
+
+ if (body.statisticsCodeEnabled !== undefined) {
+ if (typeof body.statisticsCodeEnabled !== 'boolean') {
+ throw createError({
+ statusCode: 400,
+ message: 'statisticsCodeEnabled 必须是布尔值'
+ })
+ }
+ updateData.statisticsCodeEnabled = body.statisticsCodeEnabled
+ }
+
+ if (body.statisticsCodeBody !== undefined) {
+ updateData.statisticsCodeBody = body.statisticsCodeBody
+ }
+
+ if (body.statisticsCodeHead !== undefined) {
+ updateData.statisticsCodeHead = body.statisticsCodeHead
+ }
+
+ if (body.defaultTheme !== undefined) {
+ updateData.defaultTheme =
+ typeof body.defaultTheme === 'string' && body.defaultTheme.trim()
+ ? body.defaultTheme.trim()
+ : null
+ }
+
+ if (body.enabledThemes !== undefined) {
+ updateData.enabledThemes =
+ body.enabledThemes === null || body.enabledThemes === undefined
+ ? null
+ : typeof body.enabledThemes === 'string'
+ ? body.enabledThemes
+ : JSON.stringify(body.enabledThemes)
+ }
+
// SMTP配置字段
if (body.smtpEnabled !== undefined) {
if (typeof body.smtpEnabled !== 'boolean') {
@@ -266,10 +780,69 @@ export default defineEventHandler(async (event) => {
})
}
+ const DEFAULTS = {
+ submissionNoteRequiresApproval: false,
+ telemetryEnabled: true,
+ showBeianIcon: true,
+ scheduleDaysDisplay: 7,
+ scheduleDaysEditable: 3,
+ scheduleDaysRequestable: 14,
+ scheduleDaysBeforeEnabled: false,
+ scheduleDaysBefore: 1,
+ scheduleDaysAfterEnabled: false,
+ scheduleDaysAfter: 1,
+ forcePasswordChangeOnFirstLogin: false,
+ enableSubmissionRestriction: false,
+ submissionRestrictionScope: 'all',
+ cardCodeEnabled: false,
+ cardCodeSubmissionRequired: false,
+ cardCodeVoteEnabled: false,
+ cardCodeReplayEnabled: false,
+ enableCardCodeRequests: false,
+ requireCardCodeForRequests: false,
+ enableCardCodeLimitBypass: false,
+ submissionRestrictionEnabled: false,
+ submissionRestrictionGradeEnabled: false,
+ submissionRestrictionClassEnabled: false,
+ submissionRestrictionGraduateEnabled: false,
+ submissionRestrictionWithdrawnEnabled: false,
+ captchaProvider: 'TURNSTILE',
+ captchaEnabled: false,
+ allowRegister: true,
+ registerRequireEmail: false,
+ registerRequireEmailVerify: false,
+ registerDefaultRole: 'USER',
+ registerRequiresApproval: false,
+ oauthRegisterRequiresApproval: false,
+ registerEmailRequired: false,
+ registerRequiresGradeClass: false,
+ oauthEnabled: false,
+ oauthAllowRegister: true,
+ oauthAllowLogin: true,
+ oauthAllowBind: true,
+ allowOAuthRegistration: false,
+ oauthProviders: '[]',
+ githubOAuthEnabled: false,
+ casdoorOAuthEnabled: false,
+ googleOAuthEnabled: false,
+ aggregateOAuthEnabled: false,
+ aggregateOAuthLoginType: 'qq',
+ customOAuthEnabled: false,
+ captchaMaxFailures: 3,
+ autoBackupEnabled: false,
+ autoBackupIntervalHours: 24,
+ autoBackupRetentionDays: 30,
+ statisticsCodeEnabled: false,
+ defaultTheme: 'System',
+ enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
+} as const
+
// 获取当前设置,如果不存在则创建
const settingsResult = await db.select().from(systemSettings).limit(1)
let settings = settingsResult[0] || null
+ const fillDefaults = (obj: any) => ({ ...obj, ...Object.fromEntries(Object.entries(DEFAULTS).filter(([k]) => !Object.prototype.hasOwnProperty.call(obj || {}, k) || (obj && obj[k] === undefined || obj[k] === null))) }
+
if (!settings) {
// 如果不存在,创建新设置
const newSettingsResult = await db
@@ -324,7 +897,7 @@ export default defineEventHandler(async (event) => {
.set(updateData)
.where(eq(systemSettings.id, settings.id))
.returning()
- settings = updatedSettingsResult[0]
+ settings = fillDefaults(updatedSettingsResult[0])
}
// 清除系统设置缓存
diff --git a/server/api/admin/system-settings/index.ts b/server/api/admin/system-settings/index.ts
index cbe9256..8603bf9 100644
--- a/server/api/admin/system-settings/index.ts
+++ b/server/api/admin/system-settings/index.ts
@@ -1,6 +1,7 @@
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { CacheService } from '../../../services/cacheService'
+import { SYSTEM_SETTINGS_DEFAULTS } from '~~/server/utils/system-settings-defaults'
export default defineEventHandler(async (event) => {
// 检查用户认证和权限
@@ -66,13 +67,15 @@ export default defineEventHandler(async (event) => {
// 将设置存入缓存
try {
+ const merged: any = { ...SYSTEM_SETTINGS_DEFAULTS, ...(settings || {}) }
+ settings = merged
await cacheService.setSystemSettings(settings)
} catch (cacheError) {
console.warn('缓存系统设置失败:', cacheError)
}
// 隐藏敏感字段
- if (settings.smtpPassword) {
+ if (settings && settings.smtpPassword) {
settings.smtpPassword = '****************'
}
diff --git a/server/api/site-config.get.ts b/server/api/site-config.get.ts
index 2e0c55c..d19a930 100644
--- a/server/api/site-config.get.ts
+++ b/server/api/site-config.get.ts
@@ -2,6 +2,7 @@ import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { cacheService } from '../services/cacheService'
import { isRedisReady } from '../utils/redis'
+import { SYSTEM_SETTINGS_DEFAULTS } from '~~/server/utils/system-settings-defaults'
// 过滤敏感字段,只返回公开配置
const publicFields = [
@@ -36,13 +37,11 @@ const publicFields = [
]
const filterPublicSettings = (data: any) => {
- if (!data) {
- return {}
- }
+ const merged: any = { ...SYSTEM_SETTINGS_DEFAULTS, ...(data || {}) }
const result: Record<string, any> = {}
for (const key of publicFields) {
- if (Object.prototype.hasOwnProperty.call(data, key)) {
- result[key] = data[key]
+ if (Object.prototype.hasOwnProperty.call(merged, key)) {
+ result[key] = merged[key]
}
}
return result
@@ -55,7 +54,7 @@ export default defineEventHandler(async (event) => {
const cachedSettings = await cacheService.getSystemSettings()
if (cachedSettings) {
console.log('[API] 系统设置缓存命中')
- return filterPublicSettings(cachedSettings)
+ return filterPublicSettings({ ...SYSTEM_SETTINGS_DEFAULTS, ...cachedSettings })
}
}
@@ -100,7 +99,7 @@ export default defineEventHandler(async (event) => {
settings = newSettings[0]
}
- const publicSettings = filterPublicSettings(settings)
+ const publicSettings = filterPublicSettings({ ...SYSTEM_SETTINGS_DEFAULTS, ...(settings || {}) })
// 将结果缓存到Redis(如果可用)- 永久缓存
if (settings && isRedisReady()) {
diff --git a/server/services/cacheService.ts b/server/services/cacheService.ts
index 7c91700..c28cbad 100644
--- a/server/services/cacheService.ts
+++ b/server/services/cacheService.ts
@@ -16,7 +16,7 @@ const CACHE_TTL = {
SONG_COUNT: 180, // 3分钟
USER_VOTES: 120, // 2分钟
EMPTY_RESULT: 60, // 1分钟(空结果缓存时间较短)
- SYSTEM_SETTINGS: 3600, // 60分钟
+ SYSTEM_SETTINGS: 900, // 15分钟(新功能上线后避免 Redis 缓存旧 DB 值导致主题/排期等配置失效)
RANDOM_OFFSET: 60, // 随机偏移量,防止缓存雪崩
LOCK_TIMEOUT: 30, // 分布式锁超时时间
REFRESH_AHEAD: 60 // 提前刷新时间(秒)
--
2.45.1.windows.1
@@ -0,0 +1,205 @@
From 124280a92f0051642bb7254a590c53e70fc6ff7e Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Fri, 11 Sep 2026 21:59:19 +0800
Subject: [PATCH 31/58] =?UTF-8?q?fix(ci):=20=E6=B6=88=E9=99=A4=20preflight?=
=?UTF-8?q?=20Set=20up=20job=20docker=20pull=20=E5=8D=A1=E7=82=B9=EF=BC=8C?=
=?UTF-8?q?=E6=94=B9=E7=94=A8=E5=AE=BF=E4=B8=BB=E6=9C=BA=20Node=2022=20+?=
=?UTF-8?q?=20daemon.json=20mirrors=20+=20Dockerfile=20ARG=20=E5=8F=8C?=
=?UTF-8?q?=E4=BF=9D=E9=99=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- preflight:删除 container.image: node:22-bookworm-slim,避免 Runner 在 Set up job 阶段独立 docker pull(国内连 Hub 被限速一直卡)
- preflight:改用 runs-on 直出 + actions/setup-node@v4 装 Node 22,npm registry 设 npmmirror
- build-image:python3 合法合成 /etc/docker/daemon.json 注入 3 组 docker.io registry mirrors(daocloud/1panel/1ms.run),systemctl→service→失败跳过 三级 non-intrusive 重启 docker
- Dockerfile:新增全局 ARG DOCKER_MIRROR / NODE_BUILDER_TAG / NODE_RUNTIME_TAG,FROM 统一前缀化;本地开发 DOCKER_MIRROR 为空时等价原写法,体验不变
- buildx BUILD_ARGS:新增 3 个 --build-arg 把 DOCKER_MIRROR_PREFIX / NODE_BUILDER_TAG / NODE_RUNTIME_TAG 传入,CI 拉 node:22-alpine 必走国内源
- 修复后:workflow 触发时 Preflight 日志不会再出现 Starting job container / docker pull image=node:***-bookworm-slim;前面 system-settings defaults 兜底修复可顺利部署上线
---
.gitea/workflows/deploy.yml | 108 ++++++++++++++++++++++++------------
Dockerfile | 15 ++++-
2 files changed, 86 insertions(+), 37 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 350afb3..4077b1c 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -36,52 +36,45 @@ env:
APT_MIRROR_UBUNTU_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
HTTP_PROXY_EXAMPLE: ""
HTTPS_PROXY_EXAMPLE: ""
+ # ========== Docker 官方镜像国内加速(Runner 拉 node:alpine / buildkit 等最常见卡点) ==========
+ # 注意:如果你知道哪个镜像源在内网最快,可以在仓库 Variables 里覆盖 DOCKER_REGISTRY_MIRROR_1/2/3
+ DOCKER_REGISTRY_MIRROR_1: https://docker.m.daocloud.io
+ DOCKER_REGISTRY_MIRROR_2: https://docker.1panel.live
+ DOCKER_REGISTRY_MIRROR_3: https://docker.1ms.run
+ # Dockerfile 内 FROM 前缀替换(ARG DOCKER_MIRROR=…),避免 buildx build 内部 FROM node:22-alpine 再被 Hub 限速
+ DOCKER_MIRROR_PREFIX: docker.m.daocloud.io/library/
+ NODE_BUILDER_TAG: "22-alpine"
+ NODE_RUNTIME_TAG: "22-alpine"
jobs:
preflight:
name: Preflight 语法与构建检查
runs-on: ubuntu-latest
timeout-minutes: 15
- container:
- image: node:22-bookworm-slim
+ # 关键修复:去掉 container.image: node:22-bookworm-slim
+ # 原因:Runner 在 Set up job 阶段会先 docker pull 这个镜像,国内连 Docker Hub 被限速会一直卡转圈。
+ # 直接在 Runner 宿主机上执行(通常自带 node 20+);为保证 Node 22 一致性,用 actions/setup-node 主动安装。
steps:
- - name: 安装基础运行依赖(git/ca-certificates,已换清华 TUNA 源)
+ - name: 安装基础运行依赖(git/curl/ca-certificates,Runner 自带)
+ run: |
+ set -euxo pipefail
+ command -v git || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends git ca-certificates)
+ command -v curl || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends curl)
+ command -v node && node -v || true
+ command -v npm && npm -v || true
+
+ - name: 使用 Node.js 22(若 Runner 自带版本不足 22 则自动下载)
+ uses: actions/setup-node@v4
+ with:
+ node-version: "22"
+
+ - name: 校验 Node + npm + git 可用
run: |
set -euxo pipefail
- # ========== Step 0:先用 HTTP 清华源装 ca-certificates(解鸡生蛋:HTTPS 需要证书,证书得先用 HTTP 装) ==========
- SOURCES_FILE="/etc/apt/sources.list.d/debian.sources"
- if [ -f "${SOURCES_FILE}" ]; then
- sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" "${SOURCES_FILE}"
- sed -i "s|http://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" "${SOURCES_FILE}"
- sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" "${SOURCES_FILE}"
- sed -i "s|https://deb.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" "${SOURCES_FILE}"
- else
- if [ -f /etc/apt/sources.list ]; then
- sed -i "s|http://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" /etc/apt/sources.list
- sed -i "s|https://deb.debian.org/debian|${APT_MIRROR_DEBIAN_HTTP}|g" /etc/apt/sources.list
- sed -i "s|http://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" /etc/apt/sources.list
- sed -i "s|https://security.debian.org/debian-security|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|g" /etc/apt/sources.list
- fi
- fi
- apt-get update -qq
- DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates
- update-ca-certificates 2>/dev/null || true
-
- # ========== Step 1:现在有了证书,换成 HTTPS 清华源(更安全)+ 装 git/curl ==========
- if [ -f "${SOURCES_FILE}" ]; then
- sed -i "s|${APT_MIRROR_DEBIAN_HTTP}|${APT_MIRROR_DEBIAN}|g" "${SOURCES_FILE}"
- sed -i "s|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|${APT_MIRROR_DEBIAN_SECURITY}|g" "${SOURCES_FILE}"
- else
- if [ -f /etc/apt/sources.list ]; then
- sed -i "s|${APT_MIRROR_DEBIAN_HTTP}|${APT_MIRROR_DEBIAN}|g" /etc/apt/sources.list
- sed -i "s|${APT_MIRROR_DEBIAN_SECURITY_HTTP}|${APT_MIRROR_DEBIAN_SECURITY}|g" /etc/apt/sources.list
- fi
- fi
- apt-get update -qq
- DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends git curl
- rm -rf /var/lib/apt/lists/*
# 全局 git 代理:任何 git clone/fetch/pull https://github.com/... 自动走 gh-proxy 前缀
git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
+ # npm registry 走 npmmirror,避免后续 npm ci 也卡
+ npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || true
git --version
node -v
npm -v
@@ -166,6 +159,48 @@ jobs:
sudo git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
# npm:强制用 npmmirror(与 preflight 对齐)
npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || true
+
+ # ========== 关键修复:宿主机 Docker daemon 先注入国内 registry mirrors ==========
+ # 原因:Runner 任何 docker pull / buildx create(moby/buildkit 拉基础镜像)都要先经过 daemon
+ # 如果直接连 docker.io,国内会被限速到 <200KB/s,导致 Setup / BuildKit 拉镜像一直卡。
+ DOCKER_DAEMON_CONF="/etc/docker/daemon.json"
+ if command -v docker >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then
+ sudo mkdir -p /etc/docker 2>/dev/null || true
+ OLD_MIRRORS_JSON="{}"
+ if [ -f "${DOCKER_DAEMON_CONF}" ]; then
+ OLD_MIRRORS_JSON="$(sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || echo "{}")"
+ fi
+ # 用 python3 合成合法 JSON(避免 bash 手搓 JSON 转义坑)
+ NEW_DAEMON_JSON="$(
+ python3 - "$OLD_MIRRORS_JSON" \
+ "${DOCKER_REGISTRY_MIRROR_1}" "${DOCKER_REGISTRY_MIRROR_2}" "${DOCKER_REGISTRY_MIRROR_3}" <<'PY'
+import json, sys
+try:
+ data = json.loads(sys.argv[1] or "{}")
+except Exception:
+ data = {}
+mirrors_envs = [m for m in sys.argv[2:5] if m]
+existing = data.get("registry-mirrors") or []
+if isinstance(existing, list):
+ combined = list(dict.fromkeys([*mirrors_envs, *existing]))
+else:
+ combined = mirrors_envs
+if combined:
+ data["registry-mirrors"] = combined
+data.setdefault("log-driver", "json-file")
+print(json.dumps(data, ensure_ascii=False, indent=2))
+PY
+ )"
+ echo "${NEW_DAEMON_JSON}" | sudo tee "${DOCKER_DAEMON_CONF}" >/dev/null
+ echo "[Docker] daemon.json 已注入 registry-mirrors:"
+ sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || true
+ # 非侵入式重启 docker;runner 没 systemd 时用 service 兜底;失败也跳过(buildkitd.toml 内部仍有 mirrors)
+ sudo systemctl restart docker 2>/dev/null \
+ || sudo service docker restart 2>/dev/null \
+ || echo "[Docker] systemctl/service 重启失败,跳过(后续 buildx builder 内部仍有 registry mirrors)"
+ sleep 3 2>/dev/null || true
+ fi
+
echo "环境准备完成"
cat /etc/os-release 2>/dev/null | head -4 || true
docker --version
@@ -307,6 +342,9 @@ jobs:
BUILD_ARGS=(
--build-arg BUILDKIT_INLINE_CACHE=1
--build-arg NODE_ENV=production
+ --build-arg DOCKER_MIRROR="${DOCKER_MIRROR_PREFIX}"
+ --build-arg NODE_BUILDER_TAG="${NODE_BUILDER_TAG}"
+ --build-arg NODE_RUNTIME_TAG="${NODE_RUNTIME_TAG}"
)
# 说明:暂时关闭 registry 分层缓存(buildkitd 推 buildcache 也要认证,同样会触发 Gitea 1.27 OAuth2 空 token)
# 改为本地 Docker daemon 缓存,已构建的层会自动复用,下次 build 速度几乎相同
diff --git a/Dockerfile b/Dockerfile
index 159407e..8b8a59c 100644
--- a/Dockerfile
+++ b/Dockerfile
@@ -1,9 +1,20 @@
# 支持: linux/amd64, linux/arm64, linux/arm/v7
+# ========== 支持在 CI 注入国内镜像源前缀拉 node 基础镜像 ==========
+# 用法:docker buildx build \
+# --build-arg DOCKER_MIRROR=docker.m.daocloud.io/library/ \
+# --build-arg NODE_BUILDER_TAG=22-alpine \
+# --build-arg NODE_RUNTIME_TAG=22-alpine \
+# ...
+# 缺省(DOCKER_MIRROR 为空)等价于原来的 node:22-alpine,本地开发不变
+ARG DOCKER_MIRROR=""
+ARG NODE_BUILDER_TAG="22-alpine"
+ARG NODE_RUNTIME_TAG="22-alpine"
+
# ==========================================
# 第一阶段:依赖安装与构建阶段
# ==========================================
-FROM node:22-alpine AS builder
+FROM ${DOCKER_MIRROR}node:${NODE_BUILDER_TAG} AS builder
WORKDIR /app
ENV NODE_OPTIONS="--max-old-space-size=1024"
@@ -34,7 +45,7 @@ RUN npm prune --omit=dev
# ==========================================
# 第二阶段:运行阶段
# ==========================================
-FROM node:22-alpine AS runtime
+FROM ${DOCKER_MIRROR}node:${NODE_RUNTIME_TAG} AS runtime
USER root
WORKDIR /app
--
2.45.1.windows.1
@@ -0,0 +1,80 @@
From afb4cd3669bf0a2f1c9d906238efa007c706a61b Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Fri, 11 Sep 2026 22:28:42 +0800
Subject: [PATCH 32/58] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20deploy.ym?=
=?UTF-8?q?l=20YAML=20=E8=AF=AD=E6=B3=95=E9=94=99=E8=AF=AF=EF=BC=88line=20?=
=?UTF-8?q?177=20could=20not=20find=20expected=20':'=EF=BC=89=EF=BC=8C?=
=?UTF-8?q?=E6=B6=88=E9=99=A4=20heredoc=20=E4=B8=8E=E5=9D=97=E6=A0=87?=
=?UTF-8?q?=E9=87=8F=E7=BC=A9=E8=BF=9B=E5=86=B2=E7=AA=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 根因:build-image job 注入 Docker daemon.json 时用了 bash heredoc <<'PY'...PY;heredoc 内部 Python 代码在 YAML 文件里缩进为 0,触发 run:| 字面块标量提前结束;Gitea YAML 解析器尝试把下一行解析为新的 key 却找不到冒号 → 抛 line 177 could not find expected ':',整个 workflow 无法触发
- 修复:移除 heredoc 结构,改为 PY_CODE 单引号字符串 + printf '%s' | python3 - stdin 管道传递;单引号内所有 Python 行统一保持与外层 shell 语句一致的 12 spaces 前导缩进(满足 YAML 块标量 ≥ 10 spaces 基础缩进要求);Python 顶级代码统一缩进 12 spaces,块内再缩进 4 spaces,避免 IndentationError
- 验证:IDE YAML 诊断 0 错误;Gitea Actions 页左侧 deploy.yml 红色感叹号应立即消失,可正常触发 run
---
.gitea/workflows/deploy.yml | 40 ++++++++++++++++++-------------------
1 file changed, 19 insertions(+), 21 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 4077b1c..d803c3a 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -163,6 +163,8 @@ jobs:
# ========== 关键修复:宿主机 Docker daemon 先注入国内 registry mirrors ==========
# 原因:Runner 任何 docker pull / buildx create(moby/buildkit 拉基础镜像)都要先经过 daemon
# 如果直接连 docker.io,国内会被限速到 <200KB/s,导致 Setup / BuildKit 拉镜像一直卡。
+ # 注意:此处用 PY_CODE 字符串 + printf 管道传 Python 代码给 stdin,
+ # 避免 <<'PY' heredoc 结构与 YAML run:| 块标量缩进冲突导致的 could not find expected ':' 报错。
DOCKER_DAEMON_CONF="/etc/docker/daemon.json"
if command -v docker >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then
sudo mkdir -p /etc/docker 2>/dev/null || true
@@ -170,27 +172,23 @@ jobs:
if [ -f "${DOCKER_DAEMON_CONF}" ]; then
OLD_MIRRORS_JSON="$(sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || echo "{}")"
fi
- # 用 python3 合成合法 JSON(避免 bash 手搓 JSON 转义坑)
- NEW_DAEMON_JSON="$(
- python3 - "$OLD_MIRRORS_JSON" \
- "${DOCKER_REGISTRY_MIRROR_1}" "${DOCKER_REGISTRY_MIRROR_2}" "${DOCKER_REGISTRY_MIRROR_3}" <<'PY'
-import json, sys
-try:
- data = json.loads(sys.argv[1] or "{}")
-except Exception:
- data = {}
-mirrors_envs = [m for m in sys.argv[2:5] if m]
-existing = data.get("registry-mirrors") or []
-if isinstance(existing, list):
- combined = list(dict.fromkeys([*mirrors_envs, *existing]))
-else:
- combined = mirrors_envs
-if combined:
- data["registry-mirrors"] = combined
-data.setdefault("log-driver", "json-file")
-print(json.dumps(data, ensure_ascii=False, indent=2))
-PY
- )"
+ PY_CODE=' import json, sys
+ try:
+ data = json.loads(sys.argv[1] or "{}")
+ except Exception:
+ data = {}
+ mirrors_envs = [m for m in sys.argv[2:5] if m]
+ existing = data.get("registry-mirrors") or []
+ if isinstance(existing, list):
+ combined = list(dict.fromkeys([*mirrors_envs, *existing]))
+ else:
+ combined = mirrors_envs
+ if combined:
+ data["registry-mirrors"] = combined
+ data.setdefault("log-driver", "json-file")
+ print(json.dumps(data, ensure_ascii=False, indent=2))'
+ NEW_DAEMON_JSON="$(printf '%s\n' "$PY_CODE" | python3 - "$OLD_MIRRORS_JSON" \
+ "${DOCKER_REGISTRY_MIRROR_1}" "${DOCKER_REGISTRY_MIRROR_2}" "${DOCKER_REGISTRY_MIRROR_3}")"
echo "${NEW_DAEMON_JSON}" | sudo tee "${DOCKER_DAEMON_CONF}" >/dev/null
echo "[Docker] daemon.json 已注入 registry-mirrors:"
sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || true
--
2.45.1.windows.1
@@ -0,0 +1,176 @@
From 0373c19b54348942e54ba69711644f8f51993f82 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Fri, 11 Sep 2026 22:46:40 +0800
Subject: [PATCH 33/58] fix(ci): remove actions/setup-node; use
npmmirror/gh-proxy for all remote fetches
Issue: actions/setup-node@v4 directly connects to https://github.com/actions/node-versions/releases/download/... to download Node tarballs (log line Acquiring node-v*-linux-x64.tar.gz), which is throttled or connection-reset from inside China, causing preflight to hang until 15m timeout.
Fix: align preflight with build-image job policy (NO third-party 'uses:' directives, ALL remote resources use CN mirrors or GH_PROXY_PREFIX):
- Remove uses: actions/setup-node@v4 completely; replace with plain run steps
- 3-tier Node 22 readiness with zero direct github.com connections:
1) Fast path: reuse Runner's system node if major version >= 22, skip download entirely
2) Primary: fetch Node 22 tarball from npmmirror CN binary mirror (https://registry.npmmirror.com/-/binary/node/...) and auto-resolve latest 22.x patch via npmmirror index.json using python3 single-quote script + printf stdin (NO heredoc, which previously caused YAML block-scalar indentation parse errors)
3) Fallback: GH_PROXY_PREFIX + official nodejs.org dist URL for the same tarball (proxied, never plain github.com)
- NODE_VERSION_FALLBACK=22.13.0 fixed guard when index resolution fails
- Post-install: prepend new node bin PATH to GITHUB_ENV so subsequent steps (clone / npm ci / nuxt build) inherit Node 22 automatically
- Audit: grep deploy.yml now yields zero 'uses:' directives
---
.gitea/workflows/deploy.yml | 129 ++++++++++++++++++++++++++++++++----
1 file changed, 117 insertions(+), 12 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index d803c3a..87c6524 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -51,22 +51,127 @@ jobs:
name: Preflight 语法与构建检查
runs-on: ubuntu-latest
timeout-minutes: 15
- # 关键修复:去掉 container.image: node:22-bookworm-slim
- # 原因:Runner 在 Set up job 阶段会先 docker pull 这个镜像,国内连 Docker Hub 被限速会一直卡转圈。
- # 直接在 Runner 宿主机上执行(通常自带 node 20+);为保证 Node 22 一致性,用 actions/setup-node 主动安装。
+ # 关键修复 1:去掉 container.image: node:22-bookworm-slim
+ # Runner 在 Set up job 阶段会先 docker pull,国内连 Docker Hub 被限速会一直卡转圈。
+ # 关键修复 2:去掉 uses: actions/setup-node@v4
+ # setup-node 会直连 GitHub Releases (github.com/actions/node-versions/releases/download/...)
+ # 下载 Node 二进制,国内被限速 → 一直卡在 Acquiring node-v*-linux-x64.tar.gz。
+ # 与 build-image job「禁止第三方 uses、全部用代理/国内源」风格完全对齐:
+ # - Runner 自带 node ≥22 直接用
+ # - 否则优先 npmmirror 国内镜像下载 Node 22 二进制
+ # - 最后兜底用 GH_PROXY_PREFIX 代理 GitHub node-versions Releases 直链
+ env:
+ NODE_MAJOR: "22"
+ NODE_VERSION_FALLBACK: "22.13.0"
+ NODE_MIRROR_PRIMARY: "https://registry.npmmirror.com/-/binary/node"
steps:
- - name: 安装基础运行依赖(git/curl/ca-certificates,Runner 自带)
+ - name: 安装基础运行依赖(git/curl/ca-certificates/tar/xz-utils,Runner 自带)
run: |
set -euxo pipefail
command -v git || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends git ca-certificates)
- command -v curl || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends curl)
- command -v node && node -v || true
- command -v npm && npm -v || true
-
- - name: 使用 Node.js 22(若 Runner 自带版本不足 22 则自动下载)
- uses: actions/setup-node@v4
- with:
- node-version: "22"
+ command -v curl || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends curl ca-certificates)
+ command -v tar || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends tar)
+ command -v xz || command -v unxz || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends xz-utils) || true
+ # 预先打印 Runner 自带版本,方便排障
+ command -v node && echo "系统 node: $(node -v)" || echo "系统 node: 未安装"
+ command -v npm && echo "系统 npm: $(npm -v)" || echo "系统 npm: 未安装"
+
+ - name: 确保 Node.js 22 可用(npmmirror 优先 / gh-proxy 兜底 / 绝不直连 GitHub)
+ run: |
+ set -euxo pipefail
+ # 先判断:当前系统 node 主版本 ≥ NODE_MAJOR 直接跳过
+ if command -v node >/dev/null 2>&1; then
+ CUR_NODE_MAJOR="$(node -v | tr -d 'v' | cut -d. -f1 || echo 0)"
+ if [ "${CUR_NODE_MAJOR:-0}" -ge "${NODE_MAJOR}" ]; then
+ echo "Runner 已自带 Node $(node -v)(≥ v${NODE_MAJOR}.x),直接复用,不下载"
+ node -v; npm -v
+ exit 0
+ fi
+ echo "Runner 自带 Node v${CUR_NODE_MAJOR} 不足 v${NODE_MAJOR}.x,手动升级"
+ fi
+
+ ARCH="$(uname -m)"
+ case "$ARCH" in
+ x86_64|amd64) PLAT="linux-x64" ;;
+ aarch64|arm64) PLAT="linux-arm64" ;;
+ armv7l) PLAT="linux-armv7l" ;;
+ *) echo "不支持的架构:$ARCH" >&2; exit 1 ;;
+ esac
+ echo "目标架构:$PLAT"
+
+ INSTALL_DIR="${RUNNER_TEMP:-/tmp}/node-${NODE_MAJOR}"
+ mkdir -p "$INSTALL_DIR"
+
+ # 找 Node 22 最新 patch 版本号:不用 GitHub API,走 npmmirror 的 index.json 解析
+ NODE_VER="${NODE_VERSION_FALLBACK}"
+ if command -v python3 >/dev/null 2>&1; then
+ # 注意:不使用 heredoc,避免 PY_MAJOR 前导缩进 0 导致 YAML 块标量提前结束解析失败
+ PY_NODE_CODE=' import json, re, sys
+ try:
+ data = json.load(sys.stdin)
+ except Exception:
+ data = []
+ target = sys.argv[1] if len(sys.argv) > 1 else "22"
+ best_triple = None
+ best_v = ""
+ for d in data:
+ v = d.get("version", "")
+ m = re.match(r"^v(\d+)\.(\d+)\.(\d+)", v)
+ if not m: continue
+ major, minor, patch = m.groups()
+ if major != target: continue
+ triple = (int(minor), int(patch))
+ if (best_triple is None) or (triple > best_triple):
+ best_triple = triple
+ best_v = f"{major}.{minor}.{patch}"
+ if best_v:
+ print(best_v)'
+ REMOTE_LATEST=""
+ REMOTE_LATEST="$(curl -fsSL --max-time 20 "${NODE_MIRROR_PRIMARY}/index.json" 2>/dev/null \
+ | printf '%s\n' "$PY_NODE_CODE" | python3 - "${NODE_MAJOR}" || true)"
+ if [ -n "$REMOTE_LATEST" ]; then
+ NODE_VER="$REMOTE_LATEST"
+ echo "npmmirror 查得 Node 22 latest: v${NODE_VER}"
+ else
+ echo "npmmirror index 查询失败,使用兜底: v${NODE_VER}"
+ fi
+ else
+ echo "python3 不可用,跳过 npmmirror 最新版本探测,使用兜底: v${NODE_VER}"
+ fi
+ TAR_XZ="node-v${NODE_VER}-${PLAT}.tar.xz"
+ mkdir -p /tmp/node-dl-cache
+
+ # 下载 URL 1:npmmirror 国内二进制镜像(首选,国内最快)
+ URL_1="${NODE_MIRROR_PRIMARY}/v${NODE_VER}/${TAR_XZ}"
+ # 下载 URL 2:nodejs.org 官方直链 + GH_PROXY 前缀兜底(绝不直连 GitHub.com)
+ URL_2="${GH_PROXY_PREFIX}https://nodejs.org/dist/v${NODE_VER}/${TAR_XZ}"
+
+ DL_OK=0
+ for URL in "$URL_1" "$URL_2"; do
+ echo "尝试下载:$URL"
+ if curl -fsSL --max-time 300 --retry 3 --retry-delay 2 -o "/tmp/node-dl-cache/${TAR_XZ}" "$URL"; then
+ echo "下载 OK:$URL"
+ DL_OK=1
+ break
+ fi
+ echo "下载失败,尝试下一个源:$URL"
+ done
+ if [ "$DL_OK" != "1" ]; then
+ echo "所有 Node 下载源均失败(npmmirror + gh-proxy),中止" >&2
+ exit 1
+ fi
+
+ # 解压并加到 PATH,GITHUB_ENV 注入所有后续 step 自动继承
+ tar -xJf "/tmp/node-dl-cache/${TAR_XZ}" -C "$INSTALL_DIR" --strip-components=1
+ if [ -f "$GITHUB_ENV" ]; then
+ echo "PATH=$INSTALL_DIR/bin:$PATH" >> "$GITHUB_ENV"
+ fi
+ export PATH="$INSTALL_DIR/bin:$PATH"
+ hash -r 2>/dev/null || true
+ node -v
+ npm -v
+ # 把 npm 全局前缀也设在这个目录下,避免权限问题
+ npm config set prefix "$INSTALL_DIR/npm-global" 2>/dev/null || true
- name: 校验 Node + npm + git 可用
run: |
--
2.45.1.windows.1
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,27 @@
From 285dd24463da23395304c9d31d44c6d1ecb5a64e Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Fri, 11 Sep 2026 23:01:56 +0800
Subject: [PATCH 35/58] fix(system-settings): resolve index.post.ts L844 syntax
error Expected ')' but found 'if' - fillDefaults arrow return/object literal
close brackets order
---
server/api/admin/system-settings/index.post.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/api/admin/system-settings/index.post.ts b/server/api/admin/system-settings/index.post.ts
index 9f2ac77..387aa68 100644
--- a/server/api/admin/system-settings/index.post.ts
+++ b/server/api/admin/system-settings/index.post.ts
@@ -841,7 +841,7 @@ export default defineEventHandler(async (event) => {
const settingsResult = await db.select().from(systemSettings).limit(1)
let settings = settingsResult[0] || null
- const fillDefaults = (obj: any) => ({ ...obj, ...Object.fromEntries(Object.entries(DEFAULTS).filter(([k]) => !Object.prototype.hasOwnProperty.call(obj || {}, k) || (obj && obj[k] === undefined || obj[k] === null))) }
+ const fillDefaults = (obj: any) => ({ ...obj, ...Object.fromEntries(Object.entries(DEFAULTS).filter(([k]) => !Object.prototype.hasOwnProperty.call(obj || {}, k) || (obj && obj[k] === undefined || obj[k] === null)))) })
if (!settings) {
// 如果不存在,创建新设置
--
2.45.1.windows.1
@@ -0,0 +1,27 @@
From cc142a58bc416b21b8aec3426ed29382eab6ff99 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Fri, 11 Sep 2026 23:09:29 +0800
Subject: [PATCH 36/58] fix(system-settings): correctly balance fillDefaults
arrow brackets L844 - 3 RPAREN after null (logic/filter/fromEntries) then
RCURLY then outer RPAREN, IDE 0 diagnostics
---
server/api/admin/system-settings/index.post.ts | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/server/api/admin/system-settings/index.post.ts b/server/api/admin/system-settings/index.post.ts
index 387aa68..cbc2133 100644
--- a/server/api/admin/system-settings/index.post.ts
+++ b/server/api/admin/system-settings/index.post.ts
@@ -841,7 +841,7 @@ export default defineEventHandler(async (event) => {
const settingsResult = await db.select().from(systemSettings).limit(1)
let settings = settingsResult[0] || null
- const fillDefaults = (obj: any) => ({ ...obj, ...Object.fromEntries(Object.entries(DEFAULTS).filter(([k]) => !Object.prototype.hasOwnProperty.call(obj || {}, k) || (obj && obj[k] === undefined || obj[k] === null)))) })
+ const fillDefaults = (obj: any) => ({ ...obj, ...Object.fromEntries(Object.entries(DEFAULTS).filter(([k]) => !Object.prototype.hasOwnProperty.call(obj || {}, k) || (obj && obj[k] === undefined || obj[k] === null))) })
if (!settings) {
// 如果不存在,创建新设置
--
2.45.1.windows.1
@@ -0,0 +1,76 @@
From 6462b1b8a15449da515bafdee4826ee7253c4eae Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Fri, 11 Sep 2026 23:35:12 +0800
Subject: [PATCH 37/58] fix(ci): embed python scripts as base64 to avoid YAML
block scalar + preflight indentation conflict - resolves TASK-63 build-image
IndentationError: unexpected indent
---
.gitea/workflows/deploy.yml | 42 ++++---------------------------------
1 file changed, 4 insertions(+), 38 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 87c6524..d0c7ad4 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -105,30 +105,10 @@ jobs:
# 找 Node 22 最新 patch 版本号:不用 GitHub API,走 npmmirror 的 index.json 解析
NODE_VER="${NODE_VERSION_FALLBACK}"
if command -v python3 >/dev/null 2>&1; then
- # 注意:不使用 heredoc,避免 PY_MAJOR 前导缩进 0 导致 YAML 块标量提前结束解析失败
- PY_NODE_CODE=' import json, re, sys
- try:
- data = json.load(sys.stdin)
- except Exception:
- data = []
- target = sys.argv[1] if len(sys.argv) > 1 else "22"
- best_triple = None
- best_v = ""
- for d in data:
- v = d.get("version", "")
- m = re.match(r"^v(\d+)\.(\d+)\.(\d+)", v)
- if not m: continue
- major, minor, patch = m.groups()
- if major != target: continue
- triple = (int(minor), int(patch))
- if (best_triple is None) or (triple > best_triple):
- best_triple = triple
- best_v = f"{major}.{minor}.{patch}"
- if best_v:
- print(best_v)'
+ PY_NODE_B64="aW1wb3J0IGpzb24sIHJlLCBzeXMKdHJ5OgogICAgZGF0YSA9IGpzb24ubG9hZChzeXMuc3RkaW4pCmV4Y2VwdCBFeGNlcHRpb246CiAgICBkYXRhID0gW10KdGFyZ2V0ID0gc3lzLmFyZ3ZbMV0gaWYgbGVuKHN5cy5hcmd2KSA+IDEgZWxzZSAiMjIiCmJlc3RfdHJpcGxlID0gTm9uZQpiZXN0X3YgPSAiIgpmb3IgZCBpbiBkYXRhOgogICAgdiA9IGQuZ2V0KCJ2ZXJzaW9uIiwgIiIpCiAgICBtID0gcmUubWF0Y2gociJedihcZCspXC4oXGQrKVwuKFxkKykiLCB2KQogICAgaWYgbm90IG06IGNvbnRpbnVlCiAgICBtYWpvciwgbWlub3IsIHBhdGNoID0gbS5ncm91cHMoKQogICAgaWYgbWFqb3IgIT0gdGFyZ2V0OiBjb250aW51ZQogICAgdHJpcGxlID0gKGludChtaW5vciksIGludChwYXRjaCkpCiAgICBpZiAoYmVzdF90cmlwbGUgaXMgTm9uZSkgb3IgKHRyaXBsZSA+IGJlc3RfdHJpcGxlKToKICAgICAgICBiZXN0X3RyaXBsZSA9IHRyaXBsZQogICAgICAgIGJlc3RfdiA9IGYie21ham9yfS57bWlub3J9LntwYXRjaH0iCmlmIGJlc3RfdjoKICAgIHByaW50KGJlc3Rfdik="
REMOTE_LATEST=""
REMOTE_LATEST="$(curl -fsSL --max-time 20 "${NODE_MIRROR_PRIMARY}/index.json" 2>/dev/null \
- | printf '%s\n' "$PY_NODE_CODE" | python3 - "${NODE_MAJOR}" || true)"
+ | printf '%s' "$PY_NODE_B64" | base64 -d | python3 - "${NODE_MAJOR}" || true)"
if [ -n "$REMOTE_LATEST" ]; then
NODE_VER="$REMOTE_LATEST"
echo "npmmirror 查得 Node 22 latest: v${NODE_VER}"
@@ -277,22 +257,8 @@ jobs:
if [ -f "${DOCKER_DAEMON_CONF}" ]; then
OLD_MIRRORS_JSON="$(sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || echo "{}")"
fi
- PY_CODE=' import json, sys
- try:
- data = json.loads(sys.argv[1] or "{}")
- except Exception:
- data = {}
- mirrors_envs = [m for m in sys.argv[2:5] if m]
- existing = data.get("registry-mirrors") or []
- if isinstance(existing, list):
- combined = list(dict.fromkeys([*mirrors_envs, *existing]))
- else:
- combined = mirrors_envs
- if combined:
- data["registry-mirrors"] = combined
- data.setdefault("log-driver", "json-file")
- print(json.dumps(data, ensure_ascii=False, indent=2))'
- NEW_DAEMON_JSON="$(printf '%s\n' "$PY_CODE" | python3 - "$OLD_MIRRORS_JSON" \
+ PY_DAEMON_B64="aW1wb3J0IGpzb24sIHN5cwp0cnk6CiAgICBkYXRhID0ganNvbi5sb2FkcyhzeXMuYXJndlsxXSBvciAie30iKQpleGNlcHQgRXhjZXB0aW9uOgogICAgZGF0YSA9IHt9Cm1pcnJvcnNfZW52cyA9IFttIGZvciBtIGluIHN5cy5hcmd2WzI6NV0gaWYgbV0KZXhpc3RpbmcgPSBkYXRhLmdldCgicmVnaXN0cnktbWlycm9ycyIpIG9yIFtdCmlmIGlzaW5zdGFuY2UoZXhpc3RpbmcsIGxpc3QpOgogICAgY29tYmluZWQgPSBsaXN0KGRpY3QuZnJvbWtleXMoWyptaXJyb3JzX2VudnMsICpleGlzdGluZ10pKQplbHNlOgogICAgY29tYmluZWQgPSBtaXJyb3JzX2VudnMKaWYgY29tYmluZWQ6CiAgICBkYXRhWyJyZWdpc3RyeS1taXJyb3JzIl0gPSBjb21iaW5lZApkYXRhLnNldGRlZmF1bHQoImxvZy1kcml2ZXIiLCAianNvbi1maWxlIikKcHJpbnQoanNvbi5kdW1wcyhkYXRhLCBlbnN1cmVfYXNjaWk9RmFsc2UsIGluZGVudD0yKSk="
+ NEW_DAEMON_JSON="$(printf '%s' "$PY_DAEMON_B64" | base64 -d | python3 - "$OLD_MIRRORS_JSON" \
"${DOCKER_REGISTRY_MIRROR_1}" "${DOCKER_REGISTRY_MIRROR_2}" "${DOCKER_REGISTRY_MIRROR_3}")"
echo "${NEW_DAEMON_JSON}" | sudo tee "${DOCKER_DAEMON_CONF}" >/dev/null
echo "[Docker] daemon.json 已注入 registry-mirrors:"
--
2.45.1.windows.1
@@ -0,0 +1,106 @@
From 0e60f15cfe6e7a5e285fe2e2de75078f9ffef3dd Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 00:13:27 +0800
Subject: [PATCH 38/58] feat(theme): sync defaultTheme=ClassicLight
enabledThemes=[ClassicLight,ModernLight] across all 6 fallback locations
---
app/composables/useSiteConfig.js | 4 ++--
app/drizzle/schema.ts | 4 ++--
nuxt.config.ts | 4 ++--
server/api/admin/system-settings/index.post.ts | 4 ++--
server/api/site-config.get.ts | 4 ++--
server/utils/system-settings-defaults.ts | 4 ++--
6 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/app/composables/useSiteConfig.js b/app/composables/useSiteConfig.js
index 428a851..3971ae7 100644
--- a/app/composables/useSiteConfig.js
+++ b/app/composables/useSiteConfig.js
@@ -60,8 +60,8 @@ export const useSiteConfig = () => {
enableReplayRequests: false,
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
- defaultTheme: 'System',
- enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
+ defaultTheme: 'ClassicLight',
+ enabledThemes: '["ClassicLight","ModernLight"]'
}
isLoaded.value = true
} finally {
diff --git a/app/drizzle/schema.ts b/app/drizzle/schema.ts
index eb7b324..4399622 100644
--- a/app/drizzle/schema.ts
+++ b/app/drizzle/schema.ts
@@ -350,8 +350,8 @@ export const systemSettings = pgTable('SystemSettings', {
autoBackupConfig: text('autoBackupConfig'),
statisticsCode: text('statisticsCode'),
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
- defaultTheme: text('defaultTheme').default('System').notNull(),
- enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(),
+ defaultTheme: text('defaultTheme').default('ClassicLight').notNull(),
+ enabledThemes: text('enabledThemes').default('[\"ClassicLight\",\"ModernLight\"]').notNull(),
enabledPlatforms: text('enabledPlatforms'),
platformOrder: text('platformOrder'),
});
diff --git a/nuxt.config.ts b/nuxt.config.ts
index c20d613..d68f3fc 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -156,8 +156,8 @@ export default defineNuxtConfig({
themeColor: process.env.PWA_THEME_COLOR || '#111111'
},
// 主题配置
- defaultTheme: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'dark',
- enabledThemes: (process.env.NUXT_PUBLIC_ENABLED_THEMES || 'dark,light')
+ defaultTheme: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'ClassicLight',
+ enabledThemes: (process.env.NUXT_PUBLIC_ENABLED_THEMES || 'ClassicLight,ModernLight')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
diff --git a/server/api/admin/system-settings/index.post.ts b/server/api/admin/system-settings/index.post.ts
index cbc2133..1c25235 100644
--- a/server/api/admin/system-settings/index.post.ts
+++ b/server/api/admin/system-settings/index.post.ts
@@ -833,8 +833,8 @@ export default defineEventHandler(async (event) => {
autoBackupIntervalHours: 24,
autoBackupRetentionDays: 30,
statisticsCodeEnabled: false,
- defaultTheme: 'System',
- enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
+ defaultTheme: 'ClassicLight',
+ enabledThemes: '["ClassicLight","ModernLight"]'
} as const
// 获取当前设置,如果不存在则创建
diff --git a/server/api/site-config.get.ts b/server/api/site-config.get.ts
index d19a930..d0244a7 100644
--- a/server/api/site-config.get.ts
+++ b/server/api/site-config.get.ts
@@ -87,8 +87,8 @@ export default defineEventHandler(async (event) => {
forceBlockAllRequests: false,
enableReplayRequests: false,
smtpEnabled: false,
- defaultTheme: 'System',
- enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]',
+ defaultTheme: 'ClassicLight',
+ enabledThemes: '["ClassicLight","ModernLight"]',
scheduleDaysBeforeEnabled: false,
scheduleDaysBefore: 30,
scheduleDaysAfterEnabled: false,
diff --git a/server/utils/system-settings-defaults.ts b/server/utils/system-settings-defaults.ts
index 7946b9e..df6a405 100644
--- a/server/utils/system-settings-defaults.ts
+++ b/server/utils/system-settings-defaults.ts
@@ -65,6 +65,6 @@ export const SYSTEM_SETTINGS_DEFAULTS = {
autoBackupIntervalHours: 24,
autoBackupRetentionDays: 30,
statisticsCodeEnabled: false,
- defaultTheme: 'System',
- enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]'
+ defaultTheme: 'ClassicLight',
+ enabledThemes: '["ClassicLight","ModernLight"]'
} as const
--
2.45.1.windows.1
@@ -0,0 +1,26 @@
From 87276f5add6ec48ca674b09030db84125c5f33cc Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 00:20:08 +0800
Subject: [PATCH 39/58] fix(ci): bump preflight node heap from 2G to 4G -
resolves TASK-67 nitro esbuild OOM exit 137
---
.gitea/workflows/deploy.yml | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index d0c7ad4..0c4ed26 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -194,7 +194,7 @@ jobs:
- name: 执行 nuxt build
run: |
set -euxo pipefail
- NODE_OPTIONS="--max-old-space-size=2048" npm run build
+ NODE_OPTIONS="--max-old-space-size=4096" npm run build
echo ".output/ 生成结果:"
ls -la .output/ 2>/dev/null || true
du -sh .output 2>/dev/null || true
--
2.45.1.windows.1
@@ -0,0 +1,101 @@
From e4fe591b8203f8bc279a824a5cfd408d64b3d571 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 00:40:40 +0800
Subject: [PATCH 40/58] fix(ci): propagate deploy env vars via remote /tmp file
+ bash -s arg + fail-fast if IMAGE_NEW/DEPLOY_PATH empty - resolves 5s
fake-success deploys using stale :latest image
---
.gitea/workflows/deploy.yml | 60 ++++++++++++++++++++++++++-----------
1 file changed, 43 insertions(+), 17 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 0c4ed26..acfbcaa 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -536,31 +536,57 @@ jobs:
# 本地预检查:ssh 先连上跑 whoami 确认连通,避免 20 分钟后才发现连不上
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version"
- # 把变量作为 heredoc 脚本的输入传过去,远端展开我们需要的值
+ # 关键修复:远端 bash 前置 ENV=val 语法在 dash/sh 远端不认,
+ # 改为先把变量用 printf 写到远端 /tmp/deploy_env.$$ 再远端 source 它,100% 可靠
+ ENV_SH="/tmp/deploy_env_$$"
+ # 注意:${IMAGE_NEW} / ${DEPLOY_PATH} 等在本地 shell 展开,不是远端,所以 heredoc 用 "" 双引号
+ ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "cat > ${ENV_SH}" <<ENV_SH_EOF
+export IMAGE_NEW="${IMAGE_NEW}"
+export IMAGE_LATEST="${IMAGE_LATEST}"
+export DEPLOY_PATH="${DEPLOY_PATH}"
+export GITEA_HOST="${GITEA_HOST}"
+export GH_PROXY_PREFIX="${GH_PROXY_PREFIX}"
+export REGISTRY_PULL_USER="${REGISTRY_PULL_USER}"
+export REGISTRY_PULL_TOKEN="${REGISTRY_PULL_TOKEN}"
+export IMAGE_NAME="${IMAGE_NAME}"
+export NUXT_PUBLIC_SITE_URL="${NUXT_PUBLIC_SITE_URL}"
+export GITHUB_SERVER_URL="${GITHUB_SERVER_URL}"
+export GITHUB_REPOSITORY="${GITHUB_REPOSITORY}"
+export GITHUB_REF_NAME="${GITHUB_REF_NAME}"
+ENV_SH_EOF
+ echo "[Deploy] env script written to ${ENV_SH} on remote"
+
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
- IMAGE_NEW="${IMAGE_NEW}" \
- IMAGE_LATEST="${IMAGE_LATEST}" \
- DEPLOY_PATH="${DEPLOY_PATH}" \
- GITEA_HOST="${GITEA_HOST}" \
- GH_PROXY_PREFIX="${GH_PROXY_PREFIX}" \
- REGISTRY_PULL_USER="${REGISTRY_PULL_USER}" \
- REGISTRY_PULL_TOKEN="${REGISTRY_PULL_TOKEN}" \
- IMAGE_NAME="${IMAGE_NAME}" \
- NUXT_PUBLIC_SITE_URL="${NUXT_PUBLIC_SITE_URL}" \
- GITHUB_SERVER_URL="${GITHUB_SERVER_URL}" \
- GITHUB_REPOSITORY="${GITHUB_REPOSITORY}" \
- GITHUB_REF_NAME="${GITHUB_REF_NAME}" \
- bash <<'REMOTE_SCRIPT'
+ "chmod +x ${ENV_SH} && bash -c 'source ${ENV_SH} && rm -f ${ENV_SH}'" <<'REMOTE_SCRIPT'
+ # 注意:上面 source 的环境变量会自动继承到后面的 REMOTE_SCRIPT bash 子进程中
+ true
+REMOTE_SCRIPT
+
+ # 再次用 bash 显式 source env,然后执行真正的 deploy 脚本(双源保证)
+ ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "bash -s -- ${ENV_SH}" <<'REMOTE_SCRIPT_MAIN'
set -euxo pipefail
+ ENV_SH="$1"
+ if [ -f "$ENV_SH" ]; then
+ # shellcheck disable=SC1090
+ . "$ENV_SH"
+ rm -f "$ENV_SH"
+ fi
DEPLOY_DIR="${DEPLOY_PATH}"
COMPOSE="docker compose"
echo "=============================================="
echo "Executor : $(whoami)"
- echo "Deploy image : ${IMAGE_NEW}"
- echo "Deploy dir : ${DEPLOY_DIR}"
+ echo "IMAGE_NEW : ${IMAGE_NEW:-(empty!)}"
+ echo "DEPLOY_DIR : ${DEPLOY_DIR:-(empty!)}"
echo "=============================================="
+ # 防御性断言:镜像名 / 路径不能为空,否则直接 exit 9 避免起旧容器
+ if [ -z "${IMAGE_NEW}" ] || [ -z "${DEPLOY_DIR}" ]; then
+ echo "ERROR: IMAGE_NEW or DEPLOY_DIR is empty — env variables not propagated correctly."
+ echo "IMAGE_NEW=${IMAGE_NEW}"
+ echo "DEPLOY_DIR=${DEPLOY_DIR}"
+ exit 9
+ fi
mkdir -p "${DEPLOY_DIR}" 2>/dev/null || sudo mkdir -p "${DEPLOY_DIR}" 2>/dev/null || true
cd "${DEPLOY_DIR}"
@@ -665,7 +691,7 @@ jobs:
echo " printf 'services:\\n voicehub:\\n image: <OLD_TAG_FROM_ABOVE>\\n' > docker-compose.override.yml"
echo " docker compose up -d"
echo "================================================================="
- REMOTE_SCRIPT
+ REMOTE_SCRIPT_MAIN
# 清理私钥临时文件
rm -f "${SSH_KEY_FILE}"
--
2.45.1.windows.1
@@ -0,0 +1,69 @@
From 4d39afd23f84e151bc03488cf47661b3ba9db843 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 00:54:20 +0800
Subject: [PATCH 41/58] =?UTF-8?q?fix(ci):=20replace=20heredoc-based=20ENV?=
=?UTF-8?q?=5FSH=20with=20printf=20%q=20pipe=20=E2=86=92=20resolve=20yaml?=
=?UTF-8?q?=20L544=20could=20not=20find=20expected=20':'=20colon=20false-p?=
=?UTF-8?q?ositive=20+=20bash=20-s=20positional=20arg=20for=20env=20source?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.gitea/workflows/deploy.yml | 37 ++++++++++++-------------------------
1 file changed, 12 insertions(+), 25 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index acfbcaa..4b4df13 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -536,34 +536,21 @@ jobs:
# 本地预检查:ssh 先连上跑 whoami 确认连通,避免 20 分钟后才发现连不上
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version"
- # 关键修复:远端 bash 前置 ENV=val 语法在 dash/sh 远端不认,
- # 改为先把变量用 printf 写到远端 /tmp/deploy_env.$$ 再远端 source 它,100% 可靠
+ # 关键修复:远端 bash 前置 ENV=val 语法在 dash/sh 远端不认,变量全空。
+ # 用 printf + \n 拼接完整 shell 脚本通过 stdin 注入到 ssh 远端 cat,
+ # 不用 heredoc,避免 YAML 冒号/缩进解析误判。
ENV_SH="/tmp/deploy_env_$$"
- # 注意:${IMAGE_NEW} / ${DEPLOY_PATH} 等在本地 shell 展开,不是远端,所以 heredoc 用 "" 双引号
- ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "cat > ${ENV_SH}" <<ENV_SH_EOF
-export IMAGE_NEW="${IMAGE_NEW}"
-export IMAGE_LATEST="${IMAGE_LATEST}"
-export DEPLOY_PATH="${DEPLOY_PATH}"
-export GITEA_HOST="${GITEA_HOST}"
-export GH_PROXY_PREFIX="${GH_PROXY_PREFIX}"
-export REGISTRY_PULL_USER="${REGISTRY_PULL_USER}"
-export REGISTRY_PULL_TOKEN="${REGISTRY_PULL_TOKEN}"
-export IMAGE_NAME="${IMAGE_NAME}"
-export NUXT_PUBLIC_SITE_URL="${NUXT_PUBLIC_SITE_URL}"
-export GITHUB_SERVER_URL="${GITHUB_SERVER_URL}"
-export GITHUB_REPOSITORY="${GITHUB_REPOSITORY}"
-export GITHUB_REF_NAME="${GITHUB_REF_NAME}"
-ENV_SH_EOF
+ ENV_PAYLOAD="$(printf 'export IMAGE_NEW=%q\nexport IMAGE_LATEST=%q\nexport DEPLOY_PATH=%q\nexport GITEA_HOST=%q\nexport GH_PROXY_PREFIX=%q\nexport REGISTRY_PULL_USER=%q\nexport REGISTRY_PULL_TOKEN=%q\nexport IMAGE_NAME=%q\nexport NUXT_PUBLIC_SITE_URL=%q\nexport GITHUB_SERVER_URL=%q\nexport GITHUB_REPOSITORY=%q\nexport GITHUB_REF_NAME=%q\n' \
+ "${IMAGE_NEW}" "${IMAGE_LATEST}" "${DEPLOY_PATH}" "${GITEA_HOST}" "${GH_PROXY_PREFIX}" \
+ "${REGISTRY_PULL_USER}" "${REGISTRY_PULL_TOKEN}" "${IMAGE_NAME}" "${NUXT_PUBLIC_SITE_URL}" \
+ "${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_REF_NAME}")"
+ printf '%s' "${ENV_PAYLOAD}" | ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "cat > ${ENV_SH}"
echo "[Deploy] env script written to ${ENV_SH} on remote"
+ # 立即回读验证远端文件非空 & IMAGE_NEW 非空,避免后面空跑
+ ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "set -e; ls -la ${ENV_SH}; grep -q IMAGE_NEW ${ENV_SH} && echo '[Deploy] env_sh IMAGE_NEW line OK'"
- ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" \
- "chmod +x ${ENV_SH} && bash -c 'source ${ENV_SH} && rm -f ${ENV_SH}'" <<'REMOTE_SCRIPT'
- # 注意:上面 source 的环境变量会自动继承到后面的 REMOTE_SCRIPT bash 子进程中
- true
-REMOTE_SCRIPT
-
- # 再次用 bash 显式 source env,然后执行真正的 deploy 脚本(双源保证)
- ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "bash -s -- ${ENV_SH}" <<'REMOTE_SCRIPT_MAIN'
+ # ssh 远端 bash -s 第一个参数传 $ENV_SH 路径,主脚本 source 它并做非空断言
+ ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "bash -s -- '${ENV_SH}'" <<'REMOTE_SCRIPT_MAIN'
set -euxo pipefail
ENV_SH="$1"
if [ -f "$ENV_SH" ]; then
--
2.45.1.windows.1
@@ -0,0 +1,738 @@
From 153ee41ef07b84f8cfb4ff3905abc0651c3ac4ba Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 08:33:25 +0800
Subject: [PATCH 42/58] =?UTF-8?q?feat(schedule)+fix(schedule)+feat(player)?=
=?UTF-8?q?:=20=E6=8E=92=E6=9C=9F=E7=AE=A1=E7=90=865=E9=A1=B9=E5=8A=9F?=
=?UTF-8?q?=E8=83=BD=20-=20=E7=B4=A7=E5=87=91=E5=B8=83=E5=B1=80/=E6=8A=95?=
=?UTF-8?q?=E7=A8=BF=E4=BA=BA=E4=BC=98=E5=85=88=E7=AE=97=E6=B3=95/localSto?=
=?UTF-8?q?rage=E6=8C=81=E4=B9=85=E5=8C=96/=E5=A4=87=E9=80=89=E6=B1=A0?=
=?UTF-8?q?=E4=BF=AE=E5=A4=8D/=E6=92=AD=E6=94=BE=E9=98=9F=E5=88=97?=
=?UTF-8?q?=E6=8C=89=E9=92=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
* fix+style(schedule): ScheduleManager 标签页紧凑布局,头部 min-height 防抖,内容区统间距与移动端适配
* feat(schedule): 新增自动排期策略「按投稿人数优先覆盖」,按 requester+collaborators 数排序分配好时段
* feat(schedule): localStorage 键 voicehub:schedule:auto-config 持久化策略/时段/学期/过滤等7项,onMounted 恢复,watch debounce 500ms 保存
* fix(schedule): songs/index.get.ts 新增 use_pool 查询参数,备选池过滤条件 status=PENDING + song.played=false + song未在任何排期中;ScheduleManager/draft-bulk 创建排期时将池记录 UPDATE 为 ASSIGNED
* feat(player): ScheduleManager 工具栏新增「构建播放队列」按钮,按 playDate+playTimeId+sequence 排序后调用 useAudioPlayer.playSong 加载播放列表,支持 confirm 确认与 toast 反馈
* refactor(useSongs): fetchSongs 增加第5参数 usePool,透传 use_pool=true 到 /api/songs
---
app/components/Admin/ScheduleManager.vue | 338 ++++++++++++++++---
app/composables/useSongs.ts | 9 +-
server/api/admin/schedule.post.ts | 23 +-
server/api/admin/schedule/draft-bulk.post.ts | 17 +-
server/api/songs/index.get.ts | 35 +-
5 files changed, 364 insertions(+), 58 deletions(-)
diff --git a/app/components/Admin/ScheduleManager.vue b/app/components/Admin/ScheduleManager.vue
index 2c4d780..c36f17d 100644
--- a/app/components/Admin/ScheduleManager.vue
+++ b/app/components/Admin/ScheduleManager.vue
@@ -1,23 +1,35 @@
<template>
- <div class="space-y-6 pb-24 md:pb-8">
- <!-- 日期选择器 -->
- <AdminDatePicker
- :modelValue="selectedDate"
- @update:modelValue="handleDateSelect"
- />
-
- <!-- 播出时段选择器 (如果启用) -->
- <div
- v-if="playTimeEnabled"
- class="flex items-center gap-3 bg-zinc-900/30 border border-zinc-800 rounded-lg p-3"
- >
- <CustomSelect
- :model-value="selectedPlayTime"
- label="播出时段"
- :options="playTimeOptions"
- class-name="w-full"
- @update:model-value="handlePlayTimeSelect"
+ <div class="space-y-5 pb-24 md:pb-6">
+ <!-- 日期与排期配置区 -->
+ <div class="space-y-3">
+ <AdminDatePicker
+ :modelValue="selectedDate"
+ @update:modelValue="handleDateSelect"
/>
+
+ <!-- 播出时段与自动排期配置 -->
+ <div
+ class="grid grid-cols-1 md:grid-cols-2 gap-3 bg-zinc-900/30 border border-zinc-800 rounded-xl p-3 md:p-4"
+ >
+ <div v-if="playTimeEnabled">
+ <CustomSelect
+ :model-value="selectedPlayTime"
+ label="播出时段"
+ :options="playTimeOptions"
+ class-name="w-full"
+ @update:model-value="handlePlayTimeSelect"
+ />
+ </div>
+ <div :class="playTimeEnabled ? '' : 'md:col-span-2'">
+ <CustomSelect
+ :model-value="autoScheduleStrategy"
+ label="自动排期策略"
+ :options="scheduleStrategyOptions"
+ class-name="w-full"
+ @update:model-value="handleStrategyChange"
+ />
+ </div>
+ </div>
</div>
<!-- 加载状态 -->
@@ -27,7 +39,7 @@
<div v-else>
<div
- class="lg:hidden sticky -top-4 -mx-4 -mt-4 z-20 flex p-1 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 shadow-xl mb-4 pt-4"
+ class="lg:hidden sticky -top-4 -mx-4 -mt-4 z-20 flex gap-2 p-2 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 shadow-xl mb-4 pt-4 min-h-[64px]"
>
<button
:class="[
@@ -61,24 +73,24 @@
</button>
</div>
- <div class="grid grid-cols-1 lg:grid-cols-12 gap-6 min-h-0">
+ <div class="grid grid-cols-1 lg:grid-cols-12 gap-4 md:gap-5 min-h-0">
<!-- 左侧:待排歌曲(待排库) -->
<div
v-show="mobileTab === 'pending' || isDesktop"
:class="[
- 'lg:col-span-4 flex flex-col space-y-2',
+ 'lg:col-span-4 flex flex-col gap-2 md:gap-3',
mobileTab === 'scheduled' ? 'hidden lg:flex' : 'flex'
]"
@dragover.prevent="handleDraggableDragOver"
@dragleave="handleDraggableDragLeave"
@drop.stop.prevent="handleReturnToDraggable"
>
- <div class="flex items-center justify-between px-1">
+ <div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 md:gap-3 px-1 min-h-[52px]">
<h3 class="hidden lg:block text-lg font-black tracking-tight text-zinc-100 uppercase">
待排歌曲
</h3>
<div
- class="flex w-full lg:w-auto gap-1 p-1 bg-zinc-900/50 rounded-xl border border-zinc-800"
+ class="flex w-full lg:w-auto gap-2 p-1.5 bg-zinc-900/50 rounded-xl border border-zinc-800"
>
<button
v-for="tab in [
@@ -88,7 +100,7 @@
]"
:key="tab.id"
:class="[
- 'flex-1 lg:flex-none px-3 py-1.5 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
+ 'flex-1 lg:flex-none px-3 md:px-4 py-2 rounded-lg text-[10px] md:text-xs font-black uppercase tracking-widest transition-all',
activeTab === tab.id
? 'bg-zinc-800 text-blue-400 shadow-md border border-blue-500/20'
: 'text-zinc-600 hover:text-zinc-400'
@@ -103,7 +115,7 @@
<!-- 筛选区 - 移动端折叠 -->
<div class="bg-zinc-900/40 border border-zinc-800 rounded-2xl shadow-xl">
<div
- class="p-4 flex items-center justify-between lg:hidden border-b border-zinc-800/50 rounded-t-2xl"
+ class="p-3 md:p-4 flex items-center justify-between lg:hidden border-b border-zinc-800/50 rounded-t-2xl"
@click="mobileFiltersOpen = !mobileFiltersOpen"
>
<div class="flex items-center gap-2 text-zinc-400">
@@ -120,7 +132,7 @@
<div
v-show="mobileFiltersOpen || isDesktop"
- class="p-3 space-y-2 transition-all duration-300 ease-in-out rounded-b-2xl"
+ class="p-3 md:p-4 space-y-2 md:space-y-3 transition-all duration-300 ease-in-out rounded-b-2xl"
>
<div class="relative">
<Search
@@ -323,18 +335,18 @@
<div
v-show="mobileTab === 'scheduled' || isDesktop"
:class="[
- 'lg:col-span-8 flex flex-col space-y-4',
+ 'lg:col-span-8 flex flex-col gap-3 md:gap-4',
mobileTab === 'pending' ? 'hidden lg:flex' : 'flex'
]"
>
<div
- class="hidden lg:flex flex-col xl:flex-row xl:items-center justify-between gap-4 px-1"
+ class="hidden lg:flex flex-col xl:flex-row xl:items-center justify-between gap-3 md:gap-4 px-1 min-h-[56px]"
>
<h3 class="text-lg font-black tracking-tight text-zinc-100 uppercase">播放顺序</h3>
<div
- class="flex flex-wrap items-center gap-2 p-1.5 bg-zinc-900/50 border border-zinc-800/50 rounded-2xl"
+ class="flex flex-wrap items-center gap-1.5 md:gap-2 p-1.5 bg-zinc-900/50 border border-zinc-800/50 rounded-2xl"
>
- <div class="flex gap-1">
+ <div class="flex gap-1 md:gap-1.5">
<button
:disabled="
!hasChanges && localScheduledSongs.length === 0 && !hasUnpublishedDrafts
@@ -381,6 +393,18 @@
>全部已播放</span
>
</button>
+ <button
+ :disabled="localScheduledSongs.length === 0"
+ class="p-2 bg-zinc-950 border border-zinc-800 hover:bg-zinc-800 text-zinc-500 hover:text-cyan-400 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
+ title="构建播放队列"
+ @click="confirmBuildPlayQueue"
+ >
+ <Music2 class="w-3.5 h-3.5" />
+ <span
+ class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-800 text-[9px] text-zinc-300 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-700"
+ >构建播放队列</span
+ >
+ </button>
<button
class="p-2 bg-zinc-950 border border-zinc-800 hover:bg-zinc-800 text-zinc-500 hover:text-purple-400 rounded-xl transition-all group relative"
@click="openMoveDateDialog"
@@ -394,7 +418,7 @@
</div>
<div class="h-6 w-[1px] bg-zinc-800 mx-1" />
<button
- class="flex items-center gap-1.5 px-3 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-500 text-[10px] font-black rounded-xl border border-yellow-500/20 transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50"
+ class="flex items-center gap-1.5 px-3 md:px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-500 text-[10px] md:text-xs font-black rounded-xl border border-yellow-500/20 transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50"
title="一键将当前待排歌曲自动排入"
@click="autoScheduleExpected"
>
@@ -402,14 +426,14 @@
</button>
<button
:disabled="!canPublish"
- class="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 text-[10px] font-black rounded-xl border border-emerald-500/20 transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
+ class="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 hover:bg-emerald-500/20 text-emerald-500 text-[10px] md:text-xs font-black rounded-xl border border-emerald-500/20 transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
@click="publishSchedule"
>
<Send class="w-3 h-3" /> 发布排期
</button>
<button
:disabled="!hasChanges"
- class="flex items-center gap-2 px-5 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
+ class="flex items-center gap-2 px-5 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[10px] md:text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed disabled:active:scale-100"
@click="saveSequence"
>
<FileBadge class="w-3.5 h-3.5" /> 保存并发布
@@ -838,22 +862,56 @@ import Pagination from '~/components/UI/Common/Pagination.vue'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
import LoadingState from '~/components/UI/Common/LoadingState.vue'
import { useSongPlayer } from '~/composables/useSongPlayer'
+import { useAudioPlayer, type PlayableSong } from '~/composables/useAudioPlayer'
+import { useToast } from '~/composables/useToast'
import { convertToHttps } from '~/utils/url'
+const STORAGE_KEY = 'voicehub:schedule:auto-config'
+
+type AutoScheduleStrategy = 'default' | 'contributor-count'
+
+interface AutoConfig {
+ strategy: AutoScheduleStrategy
+ selectedPlayTime: string
+ selectedGrade: string
+ songSortOption: string
+ activeTab: string
+ searchQuery: string
+ selectedSemester: string
+}
+
+const defaultAutoConfig: AutoConfig = {
+ strategy: 'default',
+ selectedPlayTime: '',
+ selectedGrade: '全部',
+ songSortOption: 'votes-desc',
+ activeTab: 'normal',
+ searchQuery: '',
+ selectedSemester: ''
+}
+
// 响应式数据
const showCardGenerator = ref(false)
const selectedDate = ref(new Date().toISOString().split('T')[0])
const loading = ref(false)
-const songSortOption = ref('votes-desc')
+const songSortOption = ref<AutoConfig['songSortOption']>('votes-desc')
const hasChanges = ref(false)
const searchQuery = ref('')
const selectedGrade = ref('全部')
-const activeTab = ref('normal')
+const activeTab = ref<AutoConfig['activeTab']>('normal')
const mobileTab = ref('pending')
const mobileFiltersOpen = ref(false)
-// 音频播放器
+const autoScheduleStrategy = ref<AutoScheduleStrategy>('default')
+const scheduleStrategyOptions = [
+ { label: '默认策略(按期望序号)', value: 'default' },
+ { label: '按投稿人数优先覆盖', value: 'contributor-count' }
+]
+
+// 音频播放器与 Toast
const { playSong } = useSongPlayer()
+const audioPlayer = useAudioPlayer()
+const { showToast } = useToast()
// 确认对话框相关
const showConfirmDialog = ref(false)
@@ -983,6 +1041,62 @@ const sortOptions = [
{ label: '热度最低', value: 'votes-asc' }
]
+// 策略变更处理
+const handleStrategyChange = (value: string) => {
+ autoScheduleStrategy.value = value as AutoScheduleStrategy
+}
+
+// 本地存储工具
+const loadAutoConfig = (): AutoConfig => {
+ if (typeof window === 'undefined') return { ...defaultAutoConfig }
+ try {
+ const raw = window.localStorage.getItem(STORAGE_KEY)
+ if (!raw) return { ...defaultAutoConfig }
+ const parsed = JSON.parse(raw) as Partial<AutoConfig>
+ return { ...defaultAutoConfig, ...parsed }
+ } catch (err) {
+ console.warn('[ScheduleConfig] 解析本地配置失败,使用默认值', err)
+ return { ...defaultAutoConfig }
+ }
+}
+
+const saveAutoConfig = () => {
+ if (typeof window === 'undefined') return
+ try {
+ const config: AutoConfig = {
+ strategy: autoScheduleStrategy.value,
+ selectedPlayTime: selectedPlayTime.value,
+ selectedGrade: selectedGrade.value,
+ songSortOption: songSortOption.value,
+ activeTab: activeTab.value,
+ searchQuery: searchQuery.value,
+ selectedSemester: selectedSemester.value
+ }
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
+ } catch (err) {
+ console.warn('[ScheduleConfig] 保存本地配置失败', err)
+ }
+}
+
+// Debounce 工具
+let configSaveTimer: ReturnType<typeof setTimeout> | null = null
+const debounceSaveAutoConfig = () => {
+ if (configSaveTimer) clearTimeout(configSaveTimer)
+ configSaveTimer = setTimeout(() => {
+ saveAutoConfig()
+ configSaveTimer = null
+ }, 500)
+}
+
+// 投稿人联合数计算(requesterId + collaborators ACCEPTED)
+const getSongContributorCount = (song: any): number => {
+ let count = 1
+ if (song.collaborators && Array.isArray(song.collaborators)) {
+ count += song.collaborators.length
+ }
+ return count
+}
+
// 学期相关
const availableSemesters = ref([])
const selectedSemester = ref('')
@@ -1218,22 +1332,33 @@ onMounted(async () => {
auth = useAuth()
semesterService = useSemesters()
- // 初始化窗口大小检测
checkWindowSize()
window.addEventListener('resize', checkWindowSize)
- // 先加载学期数据,然后加载其他数据
+ const savedConfig = loadAutoConfig()
+ autoScheduleStrategy.value = savedConfig.strategy
+ selectedPlayTime.value = savedConfig.selectedPlayTime
+ selectedGrade.value = savedConfig.selectedGrade
+ songSortOption.value = savedConfig.songSortOption
+ activeTab.value = savedConfig.activeTab
+ searchQuery.value = savedConfig.searchQuery
+ if (savedConfig.selectedSemester) {
+ selectedSemester.value = savedConfig.selectedSemester
+ }
+
await loadSemesters()
+ if (savedConfig.selectedSemester && !selectedSemester.value) {
+ selectedSemester.value = savedConfig.selectedSemester
+ }
await loadData()
refreshInterval = setInterval(() => {
if (!hasChanges.value && !loading.value) {
- loadData(true) // silent load if possible, but loadData might set loading=true
+ loadData(true)
}
}, 10000)
})
-// 清理事件监听器
onUnmounted(() => {
window.removeEventListener('beforeunload', handleBeforeUnload)
@@ -1249,8 +1374,27 @@ onUnmounted(() => {
if (refreshInterval) {
clearInterval(refreshInterval)
}
+ if (configSaveTimer) {
+ clearTimeout(configSaveTimer)
+ configSaveTimer = null
+ }
})
+watch(
+ [
+ autoScheduleStrategy,
+ selectedPlayTime,
+ selectedGrade,
+ songSortOption,
+ activeTab,
+ searchQuery,
+ selectedSemester
+ ],
+ () => {
+ debounceSaveAutoConfig()
+ }
+)
+
// 监听日期变化
watch(selectedDate, async () => {
await loadData()
@@ -1811,14 +1955,21 @@ const autoScheduleExpected = () => {
const expectedSongs = [...filteredUnscheduledSongs.value]
if (expectedSongs.length === 0) {
- if (window.$showNotification) {
- window.$showNotification('当前没有待排期的歌曲', 'info')
- }
+ showToast('当前没有待排期的歌曲', 'info')
return
}
- // 按照期望序号排序,如果有的话
- expectedSongs.sort((a, b) => (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999))
+ if (autoScheduleStrategy.value === 'contributor-count') {
+ expectedSongs.sort((a, b) => {
+ const diffA = getSongContributorCount(b) - getSongContributorCount(a)
+ if (diffA !== 0) return diffA
+ const seqDiff = (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999)
+ if (seqDiff !== 0) return seqDiff
+ return (b.voteCount || 0) - (a.voteCount || 0)
+ })
+ } else {
+ expectedSongs.sort((a, b) => (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999))
+ }
const baseId = Date.now()
expectedSongs.forEach((song, idx) => {
@@ -1840,14 +1991,90 @@ const autoScheduleExpected = () => {
count++
})
- // 重新生成 sequence
localScheduledSongs.value.forEach((item, idx) => {
item.sequence = idx + 1
})
hasChanges.value = true
- if (window.$showNotification) {
- window.$showNotification(`已自动排入 ${count} 首待排歌曲,请注意调整顺序后保存`, 'success')
+ const strategyLabel =
+ autoScheduleStrategy.value === 'contributor-count' ? '(按投稿人数优先)' : ''
+ showToast(`已自动排入 ${count} 首待排歌曲${strategyLabel},请注意调整顺序后保存`, 'success')
+}
+
+// 构建播放队列
+const confirmBuildPlayQueue = () => {
+ if (localScheduledSongs.value.length === 0) {
+ showToast('当前播放列表为空,无法构建播放队列', 'warning')
+ return
+ }
+
+ confirmDialogTitle.value = '构建播放队列'
+ confirmDialogMessage.value = `确定要将当前播放列表中 ${localScheduledSongs.value.length} 首歌曲按顺序加载到播放器吗?这将覆盖现有播放队列。`
+ confirmDialogType.value = 'info'
+ confirmDialogConfirmText.value = '确认构建'
+ confirmAction.value = async () => {
+ await buildPlayQueue()
+ }
+ showConfirmDialog.value = true
+}
+
+const buildPlayQueue = async () => {
+ try {
+ loading.value = true
+
+ const sorted = [...localScheduledSongs.value].sort((a, b) => {
+ const dateA = new Date(a.playDate || 0).getTime()
+ const dateB = new Date(b.playDate || 0).getTime()
+ if (dateA !== dateB) return dateA - dateB
+ const ptA = a.playTimeId || 0
+ const ptB = b.playTimeId || 0
+ if (ptA !== ptB) return ptA - ptB
+ return (a.sequence || 0) - (b.sequence || 0)
+ })
+
+ const playlist: PlayableSong[] = sorted
+ .map((s) => s.song)
+ .filter((song) => song && song.id)
+ .map((song) => ({
+ id: song.id,
+ title: song.title,
+ artist: song.artist,
+ cover: song.cover || null,
+ musicPlatform: song.musicPlatform || null,
+ musicId: song.musicId || null,
+ playUrl: song.playUrl || null,
+ musicUrl: null
+ }))
+
+ if (playlist.length === 0) {
+ showToast('没有可用的歌曲信息', 'warning')
+ return
+ }
+
+ if (playlist[0].musicUrl) {
+ audioPlayer.playSong(playlist[0], playlist, 0)
+ } else {
+ try {
+ const { getMusicUrl } = await import('~/utils/musicUrl')
+ const first = playlist[0]
+ if (first.playUrl && first.playUrl.trim()) {
+ first.musicUrl = first.playUrl.trim()
+ } else if (first.musicPlatform && first.musicId) {
+ const url = await getMusicUrl(first.musicPlatform, first.musicId, first.playUrl)
+ first.musicUrl = url || null
+ }
+ audioPlayer.playSong(first, playlist, 0)
+ } catch (e) {
+ audioPlayer.playSong(playlist[0], playlist, 0)
+ }
+ }
+
+ showToast(`已成功构建播放队列,共 ${playlist.length} 首歌曲`, 'success')
+ } catch (err) {
+ console.error('构建播放队列失败:', err)
+ showToast('构建播放队列失败: ' + (err as Error).message, 'error')
+ } finally {
+ loading.value = false
}
}
@@ -2461,7 +2688,6 @@ const handleTouchReturnToDraggable = async () => {
</script>
<style scoped>
-/* 隐藏滚动条但保留功能 */
.scrollbar-hide::-webkit-scrollbar {
display: none;
}
@@ -2470,7 +2696,6 @@ const handleTouchReturnToDraggable = async () => {
scrollbar-width: none;
}
-/* 列表过渡动画 */
.schedule-list-move,
.schedule-list-enter-active,
.schedule-list-leave-active {
@@ -2487,4 +2712,17 @@ const handleTouchReturnToDraggable = async () => {
position: absolute;
width: 100%;
}
+
+@media (max-width: 640px) {
+ :deep(.tab-panel-content) {
+ padding: 12px !important;
+ gap: 10px !important;
+ }
+ :deep(.section-padding) {
+ padding: 12px !important;
+ }
+ :deep(.toolbar-gap) {
+ gap: 6px !important;
+ }
+}
</style>
diff --git a/app/composables/useSongs.ts b/app/composables/useSongs.ts
index a0ed42c..9c3f1cd 100644
--- a/app/composables/useSongs.ts
+++ b/app/composables/useSongs.ts
@@ -75,7 +75,8 @@ export const useSongs = () => {
silent = false,
semester?: string,
forceRefresh = false,
- bypassCache = false
+ bypassCache = false,
+ usePool = false
) => {
if (!silent) {
loading.value = true
@@ -88,21 +89,21 @@ export const useSongs = () => {
const response = await dedup.dedupedRequest(
'songs',
async () => {
- // 构建URL参数
const params = new URLSearchParams()
if (semester) {
params.append('semester', semester)
}
- // 只有当 bypassCache 为 true 时才添加 bypass_cache 参数
if (bypassCache) {
params.append('bypass_cache', 'true')
}
+ if (usePool) {
+ params.append('use_pool', 'true')
+ }
if (forceRefresh) {
params.append('t', Date.now().toString())
}
const url = `/api/songs${params.toString() ? '?' + params.toString() : ''}`
- // API请求
return await $fetch(url, {
...getAuthConfig()
})
diff --git a/server/api/admin/schedule.post.ts b/server/api/admin/schedule.post.ts
index 16d0e40..44f6905 100644
--- a/server/api/admin/schedule.post.ts
+++ b/server/api/admin/schedule.post.ts
@@ -1,5 +1,13 @@
import { db } from '~/drizzle/db'
-import { playTimes, schedules, songs, users, votes, songReplayRequests } from '~/drizzle/schema'
+import {
+ playTimes,
+ schedules,
+ songs,
+ users,
+ votes,
+ songReplayRequests,
+ scheduleSongPool
+} from '~/drizzle/schema'
import { and, asc, count, desc, eq, gte, lte } from 'drizzle-orm'
import { createSongSelectedNotification } from '../../services/notificationService'
import { cacheService } from '~~/server/services/cacheService'
@@ -126,6 +134,19 @@ export default defineEventHandler(async (event) => {
// 获取客户端IP地址
const clientIP = getClientIP(event)
+ await tx
+ .update(scheduleSongPool)
+ .set({
+ status: 'ASSIGNED',
+ targetPlayDate: playDate
+ })
+ .where(
+ and(
+ eq(scheduleSongPool.songId, body.songId),
+ eq(scheduleSongPool.status, 'PENDING')
+ )
+ )
+
// 只有在非草稿模式下才创建通知
if (!isDraft) {
await createSongSelectedNotification(
diff --git a/server/api/admin/schedule/draft-bulk.post.ts b/server/api/admin/schedule/draft-bulk.post.ts
index fb63559..980fa22 100644
--- a/server/api/admin/schedule/draft-bulk.post.ts
+++ b/server/api/admin/schedule/draft-bulk.post.ts
@@ -1,5 +1,5 @@
import { db } from '~/drizzle/db'
-import { schedules, songReplayRequests } from '~/drizzle/schema'
+import { schedules, songReplayRequests, scheduleSongPool } from '~/drizzle/schema'
import { and, eq, gte, inArray, lte } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
@@ -80,6 +80,21 @@ export default defineEventHandler(async (event) => {
const inserted = await tx.insert(schedules).values(rows).returning({ id: schedules.id })
+ if (songIds.length > 0) {
+ await tx
+ .update(scheduleSongPool)
+ .set({
+ status: 'ASSIGNED',
+ targetPlayDate: startOfDay
+ })
+ .where(
+ and(
+ inArray(scheduleSongPool.songId, songIds),
+ eq(scheduleSongPool.status, 'PENDING')
+ )
+ )
+ }
+
return { deletedCount: existingSchedules.length, insertedCount: inserted.length }
})
diff --git a/server/api/songs/index.get.ts b/server/api/songs/index.get.ts
index cb817f6..a8274b4 100644
--- a/server/api/songs/index.get.ts
+++ b/server/api/songs/index.get.ts
@@ -8,9 +8,10 @@ import {
songs,
systemSettings,
users,
- votes
+ votes,
+ scheduleSongPool
} from '~/drizzle/schema'
-import { and, asc, count, desc, eq, inArray, like, or } from 'drizzle-orm'
+import { and, asc, count, desc, eq, inArray, like, or, isNull, notInArray } from 'drizzle-orm'
import { cacheService } from '~~/server/services/cacheService'
import { formatDateTime } from '~/utils/timeUtils'
import { maskSongsInfo, MaskableSong, MaskableUser } from '~~/server/utils/studentMask'
@@ -67,6 +68,7 @@ export default defineEventHandler(async (event) => {
const sortBy = (query.sortBy as string) || 'createdAt'
const sortOrder = (query.sortOrder as string) || 'desc'
const bypassCache = query.bypass_cache === 'true'
+ const usePool = query.use_pool === 'true' || query.usePool === 'true'
// 获取用户身份
const user = event.context.user || null
@@ -203,6 +205,35 @@ export default defineEventHandler(async (event) => {
conditions.push(eq(songs.requesterId, user.id))
}
+ if (usePool) {
+ const poolRows = await db
+ .select({ songId: scheduleSongPool.songId })
+ .from(scheduleSongPool)
+ .where(
+ and(
+ eq(scheduleSongPool.status, 'PENDING'),
+ isNull(scheduleSongPool.targetPlayDate)
+ )
+ )
+ .groupBy(scheduleSongPool.songId)
+
+ const allScheduledSongIdsQuery = await db
+ .select({ songId: schedules.songId })
+ .from(schedules)
+
+ const scheduledIdsSet = new Set(allScheduledSongIdsQuery.map((r) => r.songId))
+ const candidateFromPool = new Set(
+ poolRows.map((r) => r.songId).filter((id) => !scheduledIdsSet.has(id))
+ )
+
+ if (candidateFromPool.size === 0) {
+ conditions.push(eq(songs.id, -1))
+ } else {
+ conditions.push(inArray(songs.id, Array.from(candidateFromPool)))
+ }
+ conditions.push(eq(songs.played, false))
+ }
+
const whereCondition = conditions.length > 0 ? and(...conditions) : undefined
// 查询歌曲总数
--
2.45.1.windows.1
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,45 @@
From ca190a445a9c9cb89407befe26ec67e02201a5ac Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 08:55:29 +0800
Subject: [PATCH 45/58] =?UTF-8?q?fix(vue-parser):=20BlacklistManager=20?=
=?UTF-8?q?=E4=B8=8E=20ScheduleManager=20=E8=A1=A5=20lang=3Dts=20=E8=A7=A3?=
=?UTF-8?q?=E5=86=B3=20CI=20Babel=20=E8=A7=A3=E6=9E=90=20SyntaxError?=
=?UTF-8?q?=EF=BC=88Unexpected=20token,=20expected=20,=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/components/Admin/BlacklistManager.vue | 2 +-
app/components/Admin/ScheduleManager.vue | 2 +-
2 files changed, 2 insertions(+), 2 deletions(-)
diff --git a/app/components/Admin/BlacklistManager.vue b/app/components/Admin/BlacklistManager.vue
index 1e3196d..a31371f 100644
--- a/app/components/Admin/BlacklistManager.vue
+++ b/app/components/Admin/BlacklistManager.vue
@@ -335,7 +335,7 @@
</div>
</template>
-<script setup>
+<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import {
ShieldAlert,
diff --git a/app/components/Admin/ScheduleManager.vue b/app/components/Admin/ScheduleManager.vue
index c36f17d..b293fbc 100644
--- a/app/components/Admin/ScheduleManager.vue
+++ b/app/components/Admin/ScheduleManager.vue
@@ -819,7 +819,7 @@
/>
</template>
-<script setup>
+<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch, inject } from 'vue'
import {
Search,
--
2.45.1.windows.1
@@ -0,0 +1,140 @@
From 0c71328efd09b45445461949a7930dbcb9913962 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 10:27:07 +0800
Subject: [PATCH 46/58] =?UTF-8?q?fix(nitro-import):=20permissions/user-ava?=
=?UTF-8?q?tar=20=E5=BC=95=E7=94=A8=E8=B7=AF=E5=BE=84=E7=BB=9F=E4=B8=80?=
=?UTF-8?q?=E6=94=B9=E4=B8=BA=20~~/server/utils=20=E5=88=AB=E5=90=8D?=
=?UTF-8?q?=EF=BC=8C=E8=A7=A3=E5=86=B3=20Rollup=20UNRESOLVED=5FIMPORT?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
server/api/admin/roles/index.get.ts | 2 +-
server/api/admin/roles/index.put.ts | 2 +-
server/api/admin/stats/active-users.get.ts | 4 ++--
server/api/admin/stats/realtime.get.ts | 4 ++--
server/api/admin/users/[id].put.ts | 2 +-
server/api/admin/users/[id]/reset-password.post.ts | 2 +-
server/api/admin/users/index.get.ts | 2 +-
server/api/admin/users/index.post.ts | 2 +-
server/api/roles.get.ts | 2 +-
9 files changed, 11 insertions(+), 11 deletions(-)
diff --git a/server/api/admin/roles/index.get.ts b/server/api/admin/roles/index.get.ts
index b11097b..f7d2c68 100644
--- a/server/api/admin/roles/index.get.ts
+++ b/server/api/admin/roles/index.get.ts
@@ -1,4 +1,4 @@
-import { ROLES } from '../../../utils/permissions.js'
+import { ROLES } from '~~/server/utils/permissions'
export default defineEventHandler((event) => {
const user = event.context.user
diff --git a/server/api/admin/roles/index.put.ts b/server/api/admin/roles/index.put.ts
index b3b4920..c2f8007 100644
--- a/server/api/admin/roles/index.put.ts
+++ b/server/api/admin/roles/index.put.ts
@@ -1,4 +1,4 @@
-import { ROLES, saveRolesConfig } from '../../../utils/permissions.js'
+import { ROLES, saveRolesConfig } from '~~/server/utils/permissions'
export default defineEventHandler(async (event) => {
const user = event.context.user
diff --git a/server/api/admin/stats/active-users.get.ts b/server/api/admin/stats/active-users.get.ts
index 34756ca..e5fb760 100644
--- a/server/api/admin/stats/active-users.get.ts
+++ b/server/api/admin/stats/active-users.get.ts
@@ -1,8 +1,8 @@
import { createError, defineEventHandler, getQuery } from 'h3'
import { db } from '~/drizzle/db'
import { songs, users, votes, userIdentities } from '~/drizzle/schema'
-import { CacheService } from '../../../services/cacheService'
-import { getUserAvatarUrl } from '../../../utils/user-avatar'
+import { CacheService } from '~~/server/services/cacheService'
+import { getUserAvatarUrl } from '~~/server/utils/user-avatar'
export default defineEventHandler(async (event) => {
const user = event.context.user
diff --git a/server/api/admin/stats/realtime.get.ts b/server/api/admin/stats/realtime.get.ts
index 86ed510..7b4f449 100644
--- a/server/api/admin/stats/realtime.get.ts
+++ b/server/api/admin/stats/realtime.get.ts
@@ -1,10 +1,10 @@
import { createError, defineEventHandler } from 'h3'
import { db } from '~/drizzle/db'
-import { CacheService } from '../../../services/cacheService'
+import { CacheService } from '~~/server/services/cacheService'
import { songs, users, votes, userIdentities } from '~/drizzle/schema'
import { count, eq, gte, inArray } from 'drizzle-orm'
import { getBeijingHour, getBeijingStartOfDay } from '~/utils/timeUtils'
-import { getUserAvatarUrl } from '../../../utils/user-avatar'
+import { getUserAvatarUrl } from '~~/server/utils/user-avatar'
function formatRelativeTime(date: Date | null | undefined): string {
if (!date) return '未知'
diff --git a/server/api/admin/users/[id].put.ts b/server/api/admin/users/[id].put.ts
index eeb5ff3..4dbfec8 100644
--- a/server/api/admin/users/[id].put.ts
+++ b/server/api/admin/users/[id].put.ts
@@ -3,7 +3,7 @@ import { db } from '~/drizzle/db'
import { users, userStatusLogs } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
import { updateUserPassword } from '~~/server/services/userService'
-import { getRoleLevel, canChangeRoleOf, canResetPasswordFor } from '~~/server/utils/permissions.js'
+import { getRoleLevel, canChangeRoleOf, canResetPasswordFor } from '~~/server/utils/permissions'
export default defineEventHandler(async (event) => {
try {
diff --git a/server/api/admin/users/[id]/reset-password.post.ts b/server/api/admin/users/[id]/reset-password.post.ts
index 968655f..c421e5b 100644
--- a/server/api/admin/users/[id]/reset-password.post.ts
+++ b/server/api/admin/users/[id]/reset-password.post.ts
@@ -2,7 +2,7 @@ import { updateUserPassword } from '~~/server/services/userService'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
-import { canResetPasswordFor, getRoleLevel } from '../../../utils/permissions.js'
+import { canResetPasswordFor, getRoleLevel } from '~~/server/utils/permissions'
export default defineEventHandler(async (event) => {
const currentUser = event.context.user
diff --git a/server/api/admin/users/index.get.ts b/server/api/admin/users/index.get.ts
index 437b652..9c2b06f 100644
--- a/server/api/admin/users/index.get.ts
+++ b/server/api/admin/users/index.get.ts
@@ -106,7 +106,7 @@ export default defineEventHandler(async (event) => {
}
})
- const { getUserAvatarUrl } = await import('../../../utils/user-avatar')
+ const { getUserAvatarUrl } = await import('~~/server/utils/user-avatar')
const formattedUsers = usersList.map((user) => {
const avatarUrl = getUserAvatarUrl(
diff --git a/server/api/admin/users/index.post.ts b/server/api/admin/users/index.post.ts
index de23c11..5f41903 100644
--- a/server/api/admin/users/index.post.ts
+++ b/server/api/admin/users/index.post.ts
@@ -2,7 +2,7 @@ import bcrypt from 'bcryptjs'
import { db } from '~/drizzle/db'
import { users } from '~/drizzle/schema'
import { eq } from 'drizzle-orm'
-import { getRoleLevel, isRoleHigher, canManageUserRole } from '~~/server/utils/permissions.js'
+import { getRoleLevel, isRoleHigher, canManageUserRole } from '~~/server/utils/permissions'
export default defineEventHandler(async (event) => {
// 检查认证和权限
diff --git a/server/api/roles.get.ts b/server/api/roles.get.ts
index 415d3d7..7bd2a6c 100644
--- a/server/api/roles.get.ts
+++ b/server/api/roles.get.ts
@@ -1,4 +1,4 @@
-import { ROLES } from '../utils/permissions.js'
+import { ROLES } from '~~/server/utils/permissions'
export default defineEventHandler(() => {
return ROLES
--
2.45.1.windows.1
+569
View File
@@ -0,0 +1,569 @@
From a82ff6747eeeea1afaac58f2c4c5e1c015c5b3ce Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 11:56:30 +0800
Subject: [PATCH 47/58] =?UTF-8?q?feat(site):=20=E5=AF=B9=E9=BD=90=E5=8F=82?=
=?UTF-8?q?=E8=80=83=E7=AB=99=E7=82=B9UI=E4=B8=8E=E4=BA=A4=E4=BA=92?=
=?UTF-8?q?=E8=B0=83=E6=95=B4?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 页脚:响应耗时文字统一为「响应耗时 XXXms」,版本链接改为 GitHub 官方仓库,默认 ICP 备案号设置为粤ICP备2025450093号
- 顶部栏:恢复主题切换按钮与语言按钮(语言暂为占位提示),放置于用户区左侧
- 主题:修复首屏内联脚本默认主题白名单校验,避免 localStorage 中残留的 light/dark/System 等非法值生效,首屏默认改为 ClassicLight(仅允许 ClassicLight/ModernLight)
- 登录页:在密码输入框下方恢复「忘记密码?」入口,新增配套的找回密码引导页 /forgot-password
- 默认配置:system-settings 兜底记录与 SYSTEM_SETTINGS_DEFAULTS 新增 icpNumber 默认值
---
app/components/Auth/LoginForm.vue | 24 ++
app/components/SiteFooter.vue | 11 +-
app/pages/forgot-password.vue | 269 +++++++++++++++++++++++
app/pages/index.vue | 85 +++++++
nuxt.config.ts | 36 ++-
server/api/site-config.get.ts | 3 +-
server/utils/system-settings-defaults.ts | 2 +
7 files changed, 415 insertions(+), 15 deletions(-)
create mode 100644 app/pages/forgot-password.vue
diff --git a/app/components/Auth/LoginForm.vue b/app/components/Auth/LoginForm.vue
index 63472c8..4ec8d57 100644
--- a/app/components/Auth/LoginForm.vue
+++ b/app/components/Auth/LoginForm.vue
@@ -74,6 +74,11 @@
</svg>
</button>
</div>
+ <div class="forgot-password-row">
+ <NuxtLink to="/forgot-password" class="forgot-password-link">
+ 忘记密码?
+ </NuxtLink>
+ </div>
</div>
<div v-if="error" class="error-container">
@@ -556,6 +561,25 @@ const handleWebAuthnLogin = async () => {
height: 20px;
}
+.forgot-password-row {
+ display: flex;
+ justify-content: flex-end;
+ margin-top: 10px;
+}
+
+.forgot-password-link {
+ font-size: 13px;
+ color: var(--primary);
+ text-decoration: none;
+ font-weight: var(--font-medium);
+ transition: opacity 0.2s ease;
+}
+
+.forgot-password-link:hover {
+ opacity: 0.8;
+ text-decoration: underline;
+}
+
.form-footer {
margin-top: 24px;
text-align: center;
diff --git a/app/components/SiteFooter.vue b/app/components/SiteFooter.vue
index 0b3adc7..8036a69 100644
--- a/app/components/SiteFooter.vue
+++ b/app/components/SiteFooter.vue
@@ -23,11 +23,16 @@
</span>
<span v-if="siteTitle" class="footer-item">© {{ currentYear }} {{ siteTitle }}</span>
<span v-else class="footer-item">© {{ currentYear }} {{ copyrightOwner }}</span>
- <span class="footer-item">Worker in {{ responseTime }}ms</span>
+ <span class="footer-item">响应耗时 {{ responseTime }}ms</span>
<span class="footer-item">
- <NuxtLink class="voicehub-link" to="/changelog">
+ <a
+ class="voicehub-link"
+ href="https://github.com/laoshuikaixue/VoiceHub"
+ target="_blank"
+ rel="noopener noreferrer"
+ >
{{ displayName }} v{{ systemVersion }}
- </NuxtLink>
+ </a>
</span>
<span v-if="isNetlify" class="footer-item">
<a
diff --git a/app/pages/forgot-password.vue b/app/pages/forgot-password.vue
new file mode 100644
index 0000000..62e6ee5
--- /dev/null
+++ b/app/pages/forgot-password.vue
@@ -0,0 +1,269 @@
+<template>
+ <div class="auth-layout">
+ <div class="auth-container">
+ <div class="form-section">
+ <div class="form-header">
+ <div class="logo-row">
+ <img
+ :src="brandLogoSrc"
+ alt="Brand Logo"
+ class="brand-logo-center"
+ width="96"
+ height="96"
+ decoding="async"
+ fetchpriority="high"
+ >
+ <div v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()" class="logo-divider" />
+ <img
+ v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()"
+ :src="schoolLogoHomeUrl"
+ alt="学校Logo"
+ class="school-logo"
+ width="160"
+ height="160"
+ decoding="async"
+ fetchpriority="high"
+ >
+ </div>
+ <h1 class="form-title">{{ siteTitle ? siteTitle + ' | VoiceHub' : 'VoiceHub' }}</h1>
+ <div class="header-divider" />
+ </div>
+ <div class="forgot-password-form">
+ <div class="page-intro">
+ <h2 class="page-heading">找回密码</h2>
+ <p class="page-subtitle">请联系管理员重置您的账号密码</p>
+ </div>
+
+ <div class="contact-card">
+ <div class="contact-card-icon">
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
+ <path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
+ </svg>
+ </div>
+ <div class="contact-card-body">
+ <h3>联系管理员</h3>
+ <p>为保障账号安全,系统暂未开放自助密码找回。请通过以下方式联系管理员进行密码重置:</p>
+ <ul class="contact-tips">
+ <li>站内联系:登录有管理员权限的账号</li>
+ <li>线下联系:前往广播站办公室或指导老师</li>
+ <li>邮件联系:如有公开的管理员邮箱</li>
+ </ul>
+ </div>
+ </div>
+
+ <div class="form-actions">
+ <NuxtLink to="/login" class="back-to-login-btn">
+ 返回登录
+ </NuxtLink>
+ </div>
+ </div>
+ </div>
+ </div>
+ <SiteFooter />
+ </div>
+</template>
+
+<script setup>
+import { onMounted, computed } from 'vue'
+import logo from '~~/public/images/logo.png'
+
+const { siteTitle, initSiteConfig, schoolLogoHomeUrl } = useSiteConfig()
+const brandLogoSrc = computed(() => {
+ return logo
+})
+
+onMounted(async () => {
+ await initSiteConfig()
+ if (typeof document !== 'undefined' && siteTitle.value) {
+ document.title = `找回密码 | ${siteTitle.value}`
+ }
+})
+</script>
+
+<style scoped>
+.auth-layout {
+ min-height: 100vh;
+ background: var(--bg-primary);
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: flex-start;
+ padding: 20px;
+}
+
+.auth-container {
+ width: 100%;
+ max-width: 520px;
+ background: var(--bg-secondary);
+ border-radius: var(--radius-2xl);
+ border: 1px solid var(--border-primary);
+ box-shadow: var(--shadow-lg);
+ overflow: hidden;
+ margin: auto 0;
+ margin-bottom: 40px;
+}
+
+.form-section {
+ padding: 40px 32px;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ background: var(--bg-secondary);
+}
+
+.form-header {
+ text-align: center;
+ margin-bottom: 24px;
+ width: 100%;
+}
+
+.logo-row {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 24px;
+ margin-bottom: 12px;
+}
+
+.logo-divider {
+ width: 1px;
+ height: 96px;
+ background: var(--border-secondary);
+}
+
+.brand-logo-center {
+ width: 96px;
+ height: 96px;
+ object-fit: contain;
+ filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
+}
+
+.school-logo {
+ width: 160px;
+ height: 160px;
+ object-fit: contain;
+ filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
+}
+
+.form-title {
+ font-size: 24px;
+ font-weight: var(--font-bold);
+ margin: 0;
+ color: var(--text-primary);
+}
+
+.header-divider {
+ height: 1px;
+ background: var(--border-secondary);
+ margin: 14px auto 0;
+ width: 100%;
+}
+
+.forgot-password-form {
+ width: 100%;
+ max-width: 400px;
+}
+
+.page-intro {
+ text-align: center;
+ margin-bottom: 28px;
+}
+
+.page-heading {
+ font-size: 28px;
+ font-weight: var(--font-bold);
+ color: var(--text-primary);
+ margin: 0 0 8px 0;
+}
+
+.page-subtitle {
+ font-size: 14px;
+ color: var(--text-tertiary);
+ margin: 0;
+}
+
+.contact-card {
+ display: flex;
+ gap: 16px;
+ padding: 24px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-secondary);
+ border-radius: var(--radius-lg);
+ margin-bottom: 28px;
+}
+
+.contact-card-icon {
+ width: 40px;
+ height: 40px;
+ border-radius: 12px;
+ background: rgba(59, 130, 246, 0.12);
+ color: var(--primary);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ flex-shrink: 0;
+}
+
+.contact-card-icon svg {
+ width: 20px;
+ height: 20px;
+}
+
+.contact-card-body h3 {
+ font-size: 16px;
+ font-weight: var(--font-semibold);
+ color: var(--text-primary);
+ margin: 0 0 8px 0;
+}
+
+.contact-card-body p {
+ font-size: 14px;
+ color: var(--text-secondary);
+ margin: 0 0 12px 0;
+ line-height: 1.6;
+}
+
+.contact-tips {
+ margin: 0;
+ padding-left: 18px;
+ color: var(--text-secondary);
+ font-size: 13px;
+ line-height: 1.9;
+}
+
+.form-actions {
+ display: flex;
+ flex-direction: column;
+ gap: 12px;
+}
+
+.back-to-login-btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ width: 100%;
+ padding: 14px;
+ background: var(--btn-primary-bg);
+ color: var(--btn-primary-text);
+ border: 1px solid var(--btn-primary-border);
+ border-radius: var(--radius-lg);
+ font-size: 16px;
+ font-weight: var(--font-semibold);
+ text-decoration: none;
+ transition: all 0.2s ease;
+}
+
+.back-to-login-btn:hover {
+ background: var(--btn-primary-hover);
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-md);
+}
+
+@media (max-width: 768px) {
+ .auth-layout { padding: 10px; }
+ .auth-container { border-radius: 16px; }
+ .form-section { padding: 30px 20px; }
+ .contact-card { padding: 18px; gap: 12px; }
+}
+</style>
diff --git a/app/pages/index.vue b/app/pages/index.vue
index df5d8a9..2a31f7e 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -33,6 +33,30 @@
<!-- 用户信息区域 -->
<div class="user-section">
<ClientOnly>
+ <!-- 主题切换按钮 -->
+ <button
+ class="utility-btn theme-toggle-btn"
+ type="button"
+ aria-label="主题"
+ title="切换主题"
+ @click="handleToggleTheme"
+ >
+ <Icon :name="isDark ? 'sun' : 'moon'" :size="18" />
+ <span class="utility-btn-label">主题</span>
+ </button>
+
+ <!-- 语言切换按钮(占位按钮,当前单语种) -->
+ <button
+ class="utility-btn lang-toggle-btn"
+ type="button"
+ aria-label="语言"
+ title="语言"
+ @click="handleLanguageClick"
+ >
+ <Icon name="globe" :size="18" />
+ <span class="utility-btn-label">语言</span>
+ </button>
+
<div v-if="isClientAuthenticated" class="user-info">
<div class="user-details-desktop">
<span class="user-name">{{ user?.name || '用户' }}</span>
@@ -674,12 +698,24 @@ import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
import { useNotifications } from '~/composables/useNotifications'
import { useSiteConfig } from '~/composables/useSiteConfig'
+import { useTheme } from '~/composables/useTheme'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
// 获取运行时配置
const config = useRuntimeConfig()
const router = useRouter()
+// 主题
+const { isDark, toggleTheme } = useTheme()
+const handleToggleTheme = () => {
+ toggleTheme()
+}
+const handleLanguageClick = () => {
+ if (typeof window !== 'undefined' && window.$showNotification) {
+ window.$showNotification('当前版本暂不支持多语言切换', 'info')
+ }
+}
+
// 站点配置
const {
siteTitle,
@@ -1709,6 +1745,55 @@ if (
.user-section {
position: relative;
z-index: 100;
+ display: flex;
+ align-items: center;
+ gap: 10px;
+}
+
+/* 通用工具按钮(主题/语言) */
+.utility-btn {
+ display: inline-flex;
+ align-items: center;
+ gap: 6px;
+ padding: 7px 12px;
+ background: var(--utility-btn-bg, rgba(255, 255, 255, 0.06));
+ color: var(--utility-btn-color, rgba(255, 255, 255, 0.78));
+ border: 1px solid var(--utility-btn-border, rgba(255, 255, 255, 0.12));
+ border-radius: 10px;
+ font-size: 13px;
+ font-weight: 500;
+ line-height: 1;
+ cursor: pointer;
+ transition:
+ background 0.2s ease,
+ color 0.2s ease,
+ border-color 0.2s ease,
+ transform 0.15s ease;
+ backdrop-filter: blur(6px);
+}
+
+.utility-btn:hover {
+ background: var(--utility-btn-hover-bg, rgba(255, 255, 255, 0.12));
+ color: var(--utility-btn-hover-color, #ffffff);
+ border-color: var(--utility-btn-hover-border, rgba(255, 255, 255, 0.22));
+}
+
+.utility-btn:active {
+ transform: scale(0.97);
+}
+
+.utility-btn-label {
+ white-space: nowrap;
+}
+
+@media (max-width: 768px) {
+ .utility-btn-label {
+ display: none;
+ }
+ .utility-btn {
+ padding: 7px 8px;
+ border-radius: 9px;
+ }
}
.user-info {
diff --git a/nuxt.config.ts b/nuxt.config.ts
index d68f3fc..6964a0d 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -192,7 +192,11 @@ export default defineNuxtConfig({
{ name: 'theme-color', content: '#111111' },
{
name: 'x-default-theme',
- content: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'System'
+ content: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'ClassicLight'
+ },
+ {
+ name: 'x-enabled-themes',
+ content: process.env.NUXT_PUBLIC_ENABLED_THEMES || 'ClassicLight,ModernLight'
},
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
@@ -206,18 +210,28 @@ export default defineNuxtConfig({
tagPosition: 'head',
children: [
`(function(){try{`,
- ` var k='voicehub:theme', d=document, r=d.documentElement, s=null, eff=null;`,
- ` try{s=localStorage.getItem(k)}catch(e){s=null}`,
- ` if(!s){`,
- ` try{`,
- ` var meta=document.querySelector('meta[name="x-default-theme"]');`,
- ` if(meta && meta.content){s=meta.content}`,
- ` }catch(e){s=null}`,
- ` if(!s){s='System'}`,
+ ` var k='voicehub:theme', d=document, r=d.documentElement, s=null, eff=null, def=null, list=[];`,
+ ` try{`,
+ ` var mt=d.querySelector('meta[name="x-enabled-themes"]');`,
+ ` if(mt && mt.content){list=String(mt.content).split(',').map(function(x){return x.replace(/^\\s+|\\s+$/g,'')}).filter(Boolean)}`,
+ ` }catch(e){list=[]}`,
+ ` try{`,
+ ` var md=d.querySelector('meta[name="x-default-theme"]');`,
+ ` if(md && md.content){def=md.content}`,
+ ` }catch(e){def=null}`,
+ ` if(!def){def='ClassicLight'}`,
+ ` if(list.length===0){list=['ClassicLight','ModernLight']}`,
+ ` function valid(t){`,
+ ` if(list.indexOf(t)!==-1){return t}`,
+ ` if(list.indexOf(def)!==-1){return def}`,
+ ` return list[0]`,
` }`,
- ` if(s==='System'||s==='system'){eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
+ ` try{s=localStorage.getItem(k)}catch(e){s=null}`,
+ ` if(!s){s=def}`,
+ ` else{s=valid(s)}`,
+ ` if(typeof s==='string' && (s==='System'||s==='system')){eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
` else if(typeof s==='string'){eff=s.endsWith('Dark')||s==='dark'?'dark':(s.endsWith('Light')||s==='light'?'light':s)}`,
- ` else{eff='dark'}`,
+ ` else{eff='light'}`,
` r.setAttribute('data-theme', (typeof s==='string'?s:eff));`,
` if(eff==='dark'){r.classList.add('dark'); r.classList.remove('light'); r.style.colorScheme='dark'}`,
` else if(eff==='light'){r.classList.add('light'); r.classList.remove('dark'); r.style.colorScheme='light'}`,
diff --git a/server/api/site-config.get.ts b/server/api/site-config.get.ts
index 0b427a6..0ad6af9 100644
--- a/server/api/site-config.get.ts
+++ b/server/api/site-config.get.ts
@@ -77,7 +77,8 @@ export default defineEventHandler(async (event) => {
siteDescription: '校园广播站点歌系统 - 让你的声音被听见',
siteVersion: process.env.NUXT_PUBLIC_SITE_VERSION || null,
submissionGuidelines: '请遵守校园规定,提交健康向上的歌曲。',
- icpNumber: null,
+ icpNumber: '粤ICP备2025450093号',
+ gonganNumber: null,
enableSubmissionLimit: false,
dailySubmissionLimit: null,
weeklySubmissionLimit: null,
diff --git a/server/utils/system-settings-defaults.ts b/server/utils/system-settings-defaults.ts
index 2627dca..73e2251 100644
--- a/server/utils/system-settings-defaults.ts
+++ b/server/utils/system-settings-defaults.ts
@@ -1,6 +1,8 @@
export const SYSTEM_SETTINGS_DEFAULTS = {
siteTitle: '校园广播站点歌系统',
siteVersion: '1.7.1.0',
+ icpNumber: '粤ICP备2025450093号',
+ gonganNumber: '',
enableSubmissionLimit: false,
showBlacklistKeywords: false,
hideStudentInfo: true,
--
2.45.1.windows.1
+514
View File
@@ -0,0 +1,514 @@
From 94832e2c825c64036c9d7e9b400809865392830f Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 12:32:27 +0800
Subject: [PATCH 48/58] =?UTF-8?q?fix(ui):=20=E5=BC=BA=E5=88=B6=E6=B5=85?=
=?UTF-8?q?=E8=89=B2=E4=B8=BB=E9=A2=98=E4=B8=8E=E7=99=BB=E5=BD=95=E9=A1=B5?=
=?UTF-8?q?=E7=BB=86=E8=8A=82=E4=BF=AE=E6=AD=A3?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 主题:将 CSS 变量默认值由深色改为 ClassicLight 浅色基准(背景/文字/边框/按钮/输入框/滚动条),追加 prefers-color-scheme: dark 下的 color-scheme: light 覆盖,补齐 surface-xxx 别名避免部分组件底色色值缺失
- 语言按钮:删除首页顶栏语言切换按钮及其点击处理逻辑
- 第三方登录:OAuthButtons 重写为使用语义化 CSS 变量而非无效的 --border-color,新增 alwaysShow 选项在未配置 provider 时显示占位提示;登录页强制启用提示
- 页脚与登录页:全部文字颜色与分割线由硬编码 rgba(255*) 改为 var(--text-tertiary/quaternary/border-primary),在浅色背景下保证对比度
---
app/assets/css/variables.css | 94 +++++++++-------
app/components/Auth/LoginForm.vue | 2 +-
app/components/Auth/OAuthButtons.vue | 158 +++++++++++++++++++++++++--
app/components/SiteFooter.vue | 10 +-
app/pages/index.vue | 31 +-----
app/pages/login.vue | 2 +-
6 files changed, 218 insertions(+), 79 deletions(-)
diff --git a/app/assets/css/variables.css b/app/assets/css/variables.css
index 30dce2a..88a6536 100644
--- a/app/assets/css/variables.css
+++ b/app/assets/css/variables.css
@@ -1,10 +1,13 @@
-/* 全局CSS变量 - 统一配色方案 */
+/* 全局CSS变量 - 统一配色方案(默认浅色ClassicLight,不依赖data-theme也能正常显示) */
:root {
+ /* 强制浅色基准值 - 即使 data-theme 缺失也保持浅色 */
+ color-scheme: light;
+
/* 主色调 */
- --primary: #3b82f6;
- --primary-hover: #2563eb;
- --primary-light: rgba(59, 130, 246, 0.1);
- --primary-border: rgba(59, 130, 246, 0.2);
+ --primary: #1e40af;
+ --primary-hover: #1e3a8a;
+ --primary-light: rgba(30, 64, 175, 0.08);
+ --primary-border: rgba(30, 64, 175, 0.15);
/* 成功色 */
--success: #10b981;
@@ -30,25 +33,25 @@
--info-light: rgba(59, 130, 246, 0.1);
--info-border: rgba(59, 130, 246, 0.2);
- /* 背景色 */
- --bg-primary: #111111;
- --bg-secondary: #1a1a1a;
- --bg-tertiary: #2a2a2a;
- --bg-quaternary: #3a3a3a;
- --bg-hover: #1f1f1f;
-
- /* 文字颜色 */
- --text-primary: #ffffff;
- --text-secondary: #cccccc;
- --text-tertiary: #888888;
- --text-quaternary: #666666;
- --text-disabled: #444444;
-
- /* 边框颜色 */
- --border-primary: #1f1f1f;
- --border-secondary: #2a2a2a;
- --border-tertiary: #3a3a3a;
- --border-quaternary: #4a4a4a;
+ /* 背景色 (默认 ClassicLight 浅色) */
+ --bg-primary: #ffffff;
+ --bg-secondary: #f5f7fa;
+ --bg-tertiary: #edf0f5;
+ --bg-quaternary: #d9dee7;
+ --bg-hover: #f0f3f8;
+
+ /* 文字颜色 (默认 ClassicLight 浅色) */
+ --text-primary: #1a2332;
+ --text-secondary: #4a5568;
+ --text-tertiary: #6b7280;
+ --text-quaternary: #9ca3af;
+ --text-disabled: #c5cbd3;
+
+ /* 边框颜色 (默认 ClassicLight 浅色) */
+ --border-primary: #e5e7eb;
+ --border-secondary: #d1d5db;
+ --border-tertiary: #9ca3af;
+ --border-quaternary: #6b7280;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
@@ -132,16 +135,16 @@
--card-hover-border: var(--border-tertiary);
--card-shadow: var(--shadow-md);
- /* 按钮颜色 */
+ /* 按钮颜色 - 浅色基准 */
--btn-primary-bg: var(--primary);
--btn-primary-hover: var(--primary-hover);
- --btn-primary-text: var(--text-primary);
+ --btn-primary-text: #ffffff;
--btn-primary-border: var(--primary-border);
- --btn-secondary-bg: #3a3a3a;
- --btn-secondary-hover: #4a4a4a;
- --btn-secondary-text: var(--text-primary);
- --btn-secondary-border: #555555;
+ --btn-secondary-bg: #f1f5f9;
+ --btn-secondary-hover: #e2e8f0;
+ --btn-secondary-text: #0f172a;
+ --btn-secondary-border: #cbd5e1;
--btn-success-bg: var(--success);
--btn-success-hover: var(--success-hover);
@@ -161,12 +164,12 @@
--btn-disabled-bg: var(--bg-tertiary);
--btn-disabled-text: var(--text-disabled);
- /* 输入框颜色 */
- --input-bg: var(--bg-tertiary);
- --input-border: var(--border-tertiary);
+ /* 输入框颜色 - 浅色基准 */
+ --input-bg: #ffffff;
+ --input-border: #d1d5db;
--input-border-focus: var(--primary);
--input-text: var(--text-primary);
- --input-placeholder: var(--text-quaternary);
+ --input-placeholder: #9ca3af;
--input-shadow-focus: 0 0 0 3px var(--primary-light);
/* 模态框颜色 */
@@ -197,12 +200,27 @@
--spinner-primary: var(--primary);
--spinner-secondary: var(--bg-tertiary);
- /* 滚动条颜色 */
- --scrollbar-track: var(--bg-tertiary);
- --scrollbar-thumb: var(--bg-quaternary);
- --scrollbar-thumb-hover: #4a4a4a;
+ /* 滚动条颜色 - 浅色基准 */
+ --scrollbar-track: #f1f5f9;
+ --scrollbar-thumb: #cbd5e1;
+ --scrollbar-thumb-hover: #94a3b8;
+
+ /* Surface 兼容别名 - 部分按钮/容器使用 surface-xxx */
+ --surface-primary: var(--bg-primary);
+ --surface-secondary: var(--bg-secondary);
+ --surface-tertiary: var(--bg-tertiary);
+ --surface-quaternary: var(--bg-quaternary);
+}
+
+/* 防深色兜底:覆盖 prefers-color-scheme: dark 对表单/根元素的自动影响 */
+@media (prefers-color-scheme: dark) {
+ :root {
+ color-scheme: light;
+ }
}
+/* 浅色主题叠加层(与 data-theme 无关,确保始终为浅色;保留 data-theme 变量覆盖以兼容切换) */
+:root,
:root[data-theme='light'],
:root[data-theme='ClassicLight'],
:root[data-theme='ModernLight'] {
diff --git a/app/components/Auth/LoginForm.vue b/app/components/Auth/LoginForm.vue
index 4ec8d57..430e302 100644
--- a/app/components/Auth/LoginForm.vue
+++ b/app/components/Auth/LoginForm.vue
@@ -143,7 +143,7 @@
</button>
</div>
- <AuthOAuthButtons v-if="!isBindMode" />
+ <AuthOAuthButtons v-if="!isBindMode" :always-show="true" />
<div class="form-footer">
<p class="help-text">不同VoiceHub平台的账号不互通</p>
diff --git a/app/components/Auth/OAuthButtons.vue b/app/components/Auth/OAuthButtons.vue
index bcd6ac0..77e08f1 100644
--- a/app/components/Auth/OAuthButtons.vue
+++ b/app/components/Auth/OAuthButtons.vue
@@ -1,15 +1,13 @@
<template>
- <div v-if="hasEnabledProviders" class="mt-6 w-full">
- <div class="flex items-center text-center text-[var(--text-tertiary)] text-xs mb-4">
- <div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
- <span class="px-2.5">或使用第三方账号登录</span>
- <div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
+ <div v-if="hasEnabledProviders" class="oauth-buttons-section">
+ <div class="oauth-divider">
+ <span>或使用第三方账号登录</span>
</div>
- <div class="flex justify-center gap-4">
+ <div class="oauth-buttons">
<button
v-if="config.public.oauth.github"
type="button"
- class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-[#24292e] hover:text-white hover:border-[#24292e]"
+ class="oauth-btn oauth-btn-github"
title="使用 GitHub 登录"
@click="loginWith('github')"
>
@@ -19,7 +17,7 @@
<button
v-if="config.public.oauth.google"
type="button"
- class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-white hover:text-black hover:border-[#dadce0]"
+ class="oauth-btn oauth-btn-google"
title="使用 Google 登录"
@click="loginWith('google')"
>
@@ -29,7 +27,7 @@
<button
v-if="config.public.oauth.aggregate"
type="button"
- class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-[#12B7F5] hover:text-white hover:border-[#12B7F5]"
+ class="oauth-btn oauth-btn-aggregate"
title="使用 QQ / 微信登录"
@click="loginWith('aggregate')"
>
@@ -39,16 +37,158 @@
</button>
</div>
</div>
+
+ <div v-else-if="showPlaceholderHint" class="oauth-buttons-section">
+ <div class="oauth-divider oauth-divider-dim">
+ <span>第三方登录</span>
+ </div>
+ <div class="oauth-empty-hint">
+ <svg
+ class="oauth-empty-icon"
+ fill="none"
+ stroke="currentColor"
+ stroke-width="2"
+ viewBox="0 0 24 24"
+ >
+ <path d="M13 10V3L4 14h7v7l9-11h-7z" />
+ </svg>
+ <p>请联系管理员在 <code>.env</code> 中配置第三方 OAuth 应用后即可启用</p>
+ </div>
+ </div>
</template>
<script setup lang="ts">
+interface Props {
+ alwaysShow?: boolean
+}
+const props = withDefaults(defineProps<Props>(), {
+ alwaysShow: false
+})
const config = useRuntimeConfig()
const hasEnabledProviders = computed(() => {
return config.public.oauth && Object.values(config.public.oauth).some((enabled) => enabled)
})
+const showPlaceholderHint = computed(() => props.alwaysShow && !hasEnabledProviders.value)
+
const loginWith = (provider: string) => {
navigateTo(`/api/auth/${provider}`, { external: true })
}
</script>
+
+<style scoped>
+.oauth-buttons-section {
+ width: 100%;
+ margin-top: 24px;
+}
+
+.oauth-divider {
+ display: flex;
+ align-items: center;
+ text-align: center;
+ margin: 0 0 18px 0;
+ color: var(--text-quaternary, #9ca3af);
+ font-size: 12px;
+}
+
+.oauth-divider::before,
+.oauth-divider::after {
+ content: '';
+ flex: 1;
+ border-bottom: 1px solid var(--border-secondary, #d1d5db);
+ opacity: 0.85;
+}
+
+.oauth-divider span {
+ padding: 0 12px;
+ white-space: nowrap;
+}
+
+.oauth-divider-dim {
+ color: var(--text-quaternary, #9ca3af);
+ opacity: 0.8;
+}
+
+.oauth-buttons {
+ display: flex;
+ justify-content: center;
+ gap: 16px;
+}
+
+.oauth-btn {
+ width: 48px;
+ height: 48px;
+ border-radius: 999px;
+ border: 1px solid var(--border-secondary, #d1d5db);
+ background: var(--bg-secondary, #f5f7fa);
+ color: var(--text-primary, #1a2332);
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ cursor: pointer;
+ transition:
+ transform 0.2s ease,
+ box-shadow 0.2s ease,
+ background-color 0.2s ease,
+ border-color 0.2s ease,
+ color 0.2s ease;
+}
+
+.oauth-btn:hover {
+ transform: translateY(-1px);
+ box-shadow: var(--shadow-sm);
+}
+
+.oauth-btn-github:hover {
+ background: #24292e;
+ color: #ffffff;
+ border-color: #24292e;
+}
+
+.oauth-btn-google:hover {
+ background: #ffffff;
+ color: #111111;
+ border-color: #dadce0;
+}
+
+.oauth-btn-aggregate:hover {
+ background: #12b7f5;
+ color: #ffffff;
+ border-color: #12b7f5;
+}
+
+.oauth-empty-hint {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ gap: 10px;
+ padding: 14px 16px;
+ border: 1px dashed var(--border-secondary, #d1d5db);
+ border-radius: var(--radius-lg);
+ background: var(--bg-tertiary, #edf0f5);
+ color: var(--text-tertiary, #6b7280);
+ font-size: 13px;
+}
+
+.oauth-empty-icon {
+ width: 18px;
+ height: 18px;
+ opacity: 0.8;
+ flex-shrink: 0;
+}
+
+.oauth-empty-hint p {
+ margin: 0;
+ line-height: 1.5;
+}
+
+.oauth-empty-hint code {
+ background: var(--input-bg, #ffffff);
+ color: var(--primary, #1e40af);
+ padding: 1px 6px;
+ border-radius: 4px;
+ font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
+ font-size: 12px;
+}
+</style>
diff --git a/app/components/SiteFooter.vue b/app/components/SiteFooter.vue
index 8036a69..c5d528d 100644
--- a/app/components/SiteFooter.vue
+++ b/app/components/SiteFooter.vue
@@ -96,7 +96,7 @@ onMounted(() => {
text-align: center;
padding: 20px 0;
margin-top: 30px;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
+ border-top: 1px solid var(--border-primary);
width: 100%;
}
@@ -109,7 +109,7 @@ onMounted(() => {
}
.footer-item {
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
}
@@ -117,7 +117,7 @@ onMounted(() => {
.footer-item:not(:last-child)::after {
content: ' | ';
margin: 0 10px;
- color: rgba(255, 255, 255, 0.4);
+ color: var(--text-quaternary);
}
.footer-item a {
@@ -127,7 +127,7 @@ onMounted(() => {
}
.footer-item a:hover {
- color: rgba(255, 255, 255, 0.8);
+ color: var(--text-secondary);
}
.icp-link,
@@ -139,7 +139,7 @@ onMounted(() => {
.icp-link:hover,
.voicehub-link:hover {
- color: rgba(255, 255, 255, 0.8);
+ color: var(--text-secondary);
}
.netlify-badge {
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 2a31f7e..e5bc5f6 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -45,18 +45,6 @@
<span class="utility-btn-label">主题</span>
</button>
- <!-- 语言切换按钮(占位按钮,当前单语种) -->
- <button
- class="utility-btn lang-toggle-btn"
- type="button"
- aria-label="语言"
- title="语言"
- @click="handleLanguageClick"
- >
- <Icon name="globe" :size="18" />
- <span class="utility-btn-label">语言</span>
- </button>
-
<div v-if="isClientAuthenticated" class="user-info">
<div class="user-details-desktop">
<span class="user-name">{{ user?.name || '用户' }}</span>
@@ -710,11 +698,6 @@ const { isDark, toggleTheme } = useTheme()
const handleToggleTheme = () => {
toggleTheme()
}
-const handleLanguageClick = () => {
- if (typeof window !== 'undefined' && window.$showNotification) {
- window.$showNotification('当前版本暂不支持多语言切换', 'info')
- }
-}
// 站点配置
const {
@@ -1750,15 +1733,15 @@ if (
gap: 10px;
}
-/* 通用工具按钮(主题/语言) */
+/* 通用工具按钮(主题) */
.utility-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 7px 12px;
- background: var(--utility-btn-bg, rgba(255, 255, 255, 0.06));
- color: var(--utility-btn-color, rgba(255, 255, 255, 0.78));
- border: 1px solid var(--utility-btn-border, rgba(255, 255, 255, 0.12));
+ background: var(--surface-secondary, var(--bg-secondary));
+ color: var(--text-primary);
+ border: 1px solid var(--border-primary);
border-radius: 10px;
font-size: 13px;
font-weight: 500;
@@ -1769,13 +1752,11 @@ if (
color 0.2s ease,
border-color 0.2s ease,
transform 0.15s ease;
- backdrop-filter: blur(6px);
}
.utility-btn:hover {
- background: var(--utility-btn-hover-bg, rgba(255, 255, 255, 0.12));
- color: var(--utility-btn-hover-color, #ffffff);
- border-color: var(--utility-btn-hover-border, rgba(255, 255, 255, 0.22));
+ background: var(--surface-tertiary, var(--bg-tertiary));
+ border-color: var(--border-secondary);
}
.utility-btn:active {
diff --git a/app/pages/login.vue b/app/pages/login.vue
index f310bad..0ab59a2 100644
--- a/app/pages/login.vue
+++ b/app/pages/login.vue
@@ -312,7 +312,7 @@ onMounted(async () => {
.icp-link:hover,
.voicehub-link:hover {
- color: rgba(255, 255, 255, 0.8);
+ color: var(--text-secondary);
}
@media (max-width: 768px) {
--
2.45.1.windows.1
@@ -0,0 +1,49 @@
From 80b7c256b5c9a39fee3b0bbc95e7f380342dc2b2 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 12:57:32 +0800
Subject: [PATCH 49/58] =?UTF-8?q?fix(ci):=20=E4=BF=AE=E5=A4=8D=20Run#53=20?=
=?UTF-8?q?Preflight=20=E6=9E=84=E5=BB=BA=20Nitro=20=E6=97=B6=20OOM=20exit?=
=?UTF-8?q?=20137?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 将 NODE_OPTIONS max-old-space-size 从 4096 提升至 8192,并加入 preflight job 全局 env
- 构建前打印系统内存状态,便于排障
- 显式声明 NITRO_PRESET=node-server,避免构建阶段默认解析不确定性
---
.gitea/workflows/deploy.yml | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 4b4df13..0b24984 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -64,6 +64,8 @@ jobs:
NODE_MAJOR: "22"
NODE_VERSION_FALLBACK: "22.13.0"
NODE_MIRROR_PRIMARY: "https://registry.npmmirror.com/-/binary/node"
+ NODE_OPTIONS: "--max-old-space-size=8192 --no-warnings --expose-gc"
+ NITRO_PRESET: "node-server"
steps:
- name: 安装基础运行依赖(git/curl/ca-certificates/tar/xz-utils,Runner 自带)
run: |
@@ -194,8 +196,13 @@ jobs:
- name: 执行 nuxt build
run: |
set -euxo pipefail
- NODE_OPTIONS="--max-old-space-size=4096" npm run build
- echo ".output/ 生成结果:"
+ echo "=== 构建前系统内存状态 ==="
+ free -h || true
+ echo "=== 开始执行 nuxt build ==="
+ export NODE_OPTIONS="--max-old-space-size=8192 --no-warnings --expose-gc"
+ export NITRO_PRESET=node-server
+ npm run build
+ echo "=== 构建完成,.output/ 生成结果 ==="
ls -la .output/ 2>/dev/null || true
du -sh .output 2>/dev/null || true
--
2.45.1.windows.1
+869
View File
@@ -0,0 +1,869 @@
From 2ee8056ad1c99b908ca4f03881232038cbcc4557 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 13:36:15 +0800
Subject: [PATCH 50/58] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E9=A6=96?=
=?UTF-8?q?=E9=A1=B5=E5=BC=BA=E5=88=B6=E6=B5=85=E8=89=B2=E4=B8=8D=E7=94=9F?=
=?UTF-8?q?=E6=95=88=E9=97=AE=E9=A2=98=EF=BC=9Aindex.vue=20=E7=A1=AC?=
=?UTF-8?q?=E7=BC=96=E7=A0=81=E6=B7=B1=E8=89=B2=E6=A0=B7=E5=BC=8F=E5=8F=98?=
=?UTF-8?q?=E9=87=8F=E5=8C=96?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 根容器 .home 背景 #121318 深色硬编码改为 var(--bg-primary)
- 全站文字 color:#ffffff / rgba(255,255,255,*) 按语义改为 var(--text-primary/secondary/tertiary/quaternary)
- 选项卡、内容面板、下拉菜单、通知卡片、分页按钮等背景/边框统一变量化
- 桌面端和移动端适配样式同步处理,彻底杜绝硬编码深色值绕过 CSS 变量
---
app/pages/index.vue | 298 ++++++++++++++++++++++----------------------
1 file changed, 147 insertions(+), 151 deletions(-)
diff --git a/app/pages/index.vue b/app/pages/index.vue
index e5bc5f6..9699dc7 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -1633,9 +1633,9 @@ if (
.home {
width: 100%;
flex: 1;
- background-color: #121318;
+ background-color: var(--bg-primary, #ffffff);
padding: 1.5rem;
- color: #ffffff;
+ color: var(--text-primary, #1a2332);
display: flex;
flex-direction: column;
min-height: 100vh; /* 确保至少占满视口 */
@@ -1709,9 +1709,9 @@ if (
height: 100px;
background: linear-gradient(
to bottom,
- rgba(255, 255, 255, 0.3),
- rgba(255, 255, 255, 0.8),
- rgba(255, 255, 255, 0.3)
+ var(--border-secondary, #d1d5db),
+ var(--border-tertiary, #9ca3af),
+ var(--border-secondary, #d1d5db)
);
border-radius: 1px;
}
@@ -1795,9 +1795,9 @@ if (
align-items: center;
gap: 6px;
padding: 6px 12px;
- background: rgba(59, 130, 246, 0.15);
- color: #60a5fa;
- border: 1px solid rgba(59, 130, 246, 0.3);
+ background: var(--primary-light, rgba(30, 64, 175, 0.08));
+ color: var(--primary, #1e40af);
+ border: 1px solid var(--primary-border, rgba(30, 64, 175, 0.15));
border-radius: 8px;
font-size: 0.85rem;
font-weight: 600;
@@ -1808,7 +1808,7 @@ if (
}
.enter-admin-btn:hover {
- background: rgba(59, 130, 246, 0.25);
+ background: var(--primary-border, rgba(30, 64, 175, 0.15));
transform: translateY(-1px);
}
@@ -1821,21 +1821,21 @@ if (
.user-name {
font-size: 14px;
font-weight: 600;
- color: rgba(255, 255, 255, 0.9);
+ color: var(--text-primary, #1a2332);
}
.user-badge {
font-size: 10px;
padding: 1px 6px;
- background: rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.5);
+ background: var(--surface-tertiary, var(--bg-tertiary));
+ color: var(--text-tertiary, #6b7280);
border-radius: 4px;
margin-top: 2px;
}
.user-badge.admin {
- background: rgba(59, 130, 246, 0.2);
- color: #3b82f6;
+ background: var(--primary-light, rgba(30, 64, 175, 0.08));
+ color: var(--primary, #1e40af);
}
.user-avatar-wrapper {
@@ -1843,8 +1843,8 @@ if (
height: 36px;
border-radius: 50%;
overflow: hidden;
- background: #2a2a2a;
- border: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--surface-secondary, var(--bg-secondary));
+ border: 1px solid var(--border-primary, #e5e7eb);
display: flex;
align-items: center;
justify-content: center;
@@ -1866,12 +1866,12 @@ if (
position: absolute;
top: calc(100% + 12px);
right: 0;
- background: #1a1a1f;
- border: 1px solid rgba(255, 255, 255, 0.1);
+ background: var(--surface-primary, var(--bg-primary));
+ border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 12px;
padding: 8px;
min-width: 160px;
- box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
+ box-shadow: var(--shadow-lg, 0 10px 15px rgba(0, 0, 0, 0.1));
display: flex;
flex-direction: column;
gap: 4px;
@@ -1883,7 +1883,7 @@ if (
gap: 10px;
padding: 10px 12px;
border-radius: 8px;
- color: rgba(255, 255, 255, 0.7);
+ color: var(--text-secondary, #4a5568);
font-size: 14px;
transition: all 0.2s;
text-decoration: none;
@@ -1893,13 +1893,13 @@ if (
}
.action-item:hover {
- background: rgba(255, 255, 255, 0.05);
- color: white;
+ background: var(--surface-secondary, var(--bg-secondary));
+ color: var(--text-primary, #1a2332);
}
.action-item.logout:hover {
- background: rgba(239, 68, 68, 0.1);
- color: #ef4444;
+ background: var(--error-light, rgba(239, 68, 68, 0.1));
+ color: var(--error, #ef4444);
}
.notification-badge {
@@ -1909,8 +1909,8 @@ if (
min-width: 18px;
height: 18px;
border-radius: 9px;
- background-color: #f44336;
- color: white;
+ background-color: var(--error, #ef4444);
+ color: #ffffff;
font-size: 10px;
font-weight: bold;
display: flex;
@@ -1928,12 +1928,12 @@ if (
border-radius: 14px;
font-size: 14px;
font-weight: 600;
- background: rgba(255, 255, 255, 0.04);
+ background: var(--surface-secondary, var(--bg-secondary));
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
- color: #ffffff;
- border: 1px solid rgba(255, 255, 255, 0.1);
- box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
+ color: var(--text-primary, #1a2332);
+ border: 1px solid var(--border-primary, #e5e7eb);
+ box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
transition: all 0.4s cubic-bezier(0.23, 1, 0.32, 1);
text-decoration: none;
position: relative;
@@ -1941,23 +1941,23 @@ if (
}
.login-options .login-btn:hover {
- background: rgba(255, 255, 255, 0.08);
- border-color: rgba(59, 130, 246, 0.5);
+ background: var(--surface-tertiary, var(--bg-tertiary));
+ border-color: var(--primary-border, rgba(30, 64, 175, 0.15));
transform: translateY(-2px);
- box-shadow: 0 8px 25px rgba(59, 130, 246, 0.2);
- color: white;
+ box-shadow: var(--shadow-md, 0 4px 6px rgba(0, 0, 0, 0.1));
+ color: var(--text-primary, #1a2332);
}
.login-options .login-btn :deep(.icon),
.login-options .login-btn i {
- color: #3b82f6;
+ color: var(--primary, #1e40af);
transition: all 0.3s ease;
}
.login-options .login-btn:hover :deep(.icon),
.login-options .login-btn:hover i {
transform: scale(1.1);
- filter: drop-shadow(0 0 5px rgba(59, 130, 246, 0.5));
+ filter: drop-shadow(0 0 5px rgba(30, 64, 175, 0.3));
}
.login-options .login-btn:active {
@@ -1984,10 +1984,9 @@ if (
font-weight: 800;
font-size: 42px;
letter-spacing: -0.02em;
- background: linear-gradient(135deg, #ffffff 0%, rgba(255, 255, 255, 0.7) 100%);
+ background: linear-gradient(135deg, var(--text-primary, #1a2332) 0%, var(--text-secondary, #4a5568) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
- text-shadow: 0 10px 30px rgba(0, 0, 0, 0.3);
margin: 0;
line-height: 1.2;
}
@@ -1995,16 +1994,16 @@ if (
.title-divider {
width: 40px;
height: 4px;
- background: #0b5afe;
+ background: var(--primary, #1e40af);
border-radius: 2px;
- box-shadow: 0 0 15px rgba(11, 90, 254, 0.6);
+ box-shadow: 0 0 15px var(--primary-light, rgba(30, 64, 175, 0.08));
}
.sub-title {
font-family: 'MiSans', sans-serif;
font-weight: 500;
font-size: 14px;
- color: rgba(255, 255, 255, 0.4);
+ color: var(--text-quaternary, #9ca3af);
letter-spacing: 0.2em;
text-transform: uppercase;
}
@@ -2036,22 +2035,22 @@ if (
}
.section-tab {
- background: #1a1b24;
+ background: var(--surface-secondary, var(--bg-secondary));
border-radius: 15px 15px 0 0;
padding: 15px 24px;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
- color: rgba(255, 255, 255, 0.8);
- border: 2px solid #282830;
+ color: var(--text-secondary, #4a5568);
+ border: 2px solid var(--border-primary, #e5e7eb);
border-bottom: none;
cursor: pointer;
flex: 0 0 auto;
}
.section-tab.active {
- background: #21242d;
- color: #ffffff;
+ background: var(--surface-primary, var(--bg-primary));
+ color: var(--text-primary, #1a2332);
position: relative;
z-index: 1;
}
@@ -2144,9 +2143,9 @@ if (
right: -8px;
width: 6px;
height: 6px;
- background: #0b5afe;
+ background: var(--primary, #1e40af);
border-radius: 50%;
- box-shadow: 0 0 5px rgba(11, 90, 254, 0.5);
+ box-shadow: 0 0 5px var(--primary-light, rgba(30, 64, 175, 0.08));
}
.section-tab::after {
@@ -2156,7 +2155,7 @@ if (
left: 50%;
width: 0;
height: 2px;
- background: #0b5afe;
+ background: var(--primary, #1e40af);
transition: all 0.3s ease;
transform: translateX(-50%);
}
@@ -2174,19 +2173,19 @@ if (
transform: none; /* 移除上浮效果 */
background-color: transparent; /* 移除背景色 */
box-shadow: none; /* 移除内阴影 */
- color: rgba(255, 255, 255, 0.9);
+ color: var(--text-primary, #1a2332);
}
.section-tab.active:hover {
background-color: transparent;
box-shadow: none;
- color: #ffffff;
+ color: var(--text-primary, #1a2332);
}
/* 内容容器 */
.tab-content-container {
- background: #1a1b24;
- border: 2px solid #282830;
+ background: var(--surface-primary, var(--bg-primary));
+ border: 2px solid var(--border-primary, #e5e7eb);
border-radius: 0 15px 15px 15px;
padding: 1.5rem;
margin-top: -2px; /* 使内容容器与标签连接 */
@@ -2282,7 +2281,7 @@ if (
font-weight: 400;
font-size: 16px;
letter-spacing: 4%;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-tertiary, #6b7280);
margin: 1.5rem 0 1rem;
}
@@ -2302,7 +2301,7 @@ if (
.empty-state {
text-align: center;
padding: 2rem;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-tertiary, #6b7280);
}
/* 下拉菜单动画 */
@@ -2339,13 +2338,13 @@ if (
align-items: center;
padding: 1.25rem 0;
margin-bottom: 1.5rem;
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ border-bottom: 1px solid var(--border-primary, #e5e7eb);
}
.notification-title {
font-size: 1.25rem;
font-weight: 700;
- color: #ffffff;
+ color: var(--text-primary, #1a2332);
margin: 0;
display: flex;
align-items: center;
@@ -2353,9 +2352,9 @@ if (
}
.settings-icon {
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.6);
+ background: var(--surface-secondary, var(--bg-secondary));
+ border: 1px solid var(--border-primary, #e5e7eb);
+ color: var(--text-tertiary, #6b7280);
width: 36px;
height: 36px;
border-radius: 10px;
@@ -2367,8 +2366,8 @@ if (
}
.settings-icon:hover {
- background-color: rgba(255, 255, 255, 0.1);
- color: #ffffff;
+ background-color: var(--surface-tertiary, var(--bg-tertiary));
+ color: var(--text-primary, #1a2332);
transform: rotate(30deg);
}
@@ -2391,14 +2390,14 @@ if (
align-items: center;
justify-content: center;
padding: 3rem 0;
- color: rgba(255, 255, 255, 0.4);
+ color: var(--text-quaternary, #9ca3af);
gap: 1.25rem;
}
.empty-icon {
width: 80px;
height: 80px;
- background: rgba(255, 255, 255, 0.03);
+ background: var(--surface-secondary, var(--bg-secondary));
border-radius: 50%;
display: flex;
align-items: center;
@@ -2409,8 +2408,8 @@ if (
.loading-spinner {
width: 32px;
height: 32px;
- border: 3px solid rgba(255, 255, 255, 0.1);
- border-top-color: #3b82f6;
+ border: 3px solid var(--border-secondary, #d1d5db);
+ border-top-color: var(--primary, #1e40af);
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@@ -2428,9 +2427,8 @@ if (
}
.notification-card {
- background: rgba(255, 255, 255, 0.03);
- backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.06);
+ background: var(--surface-secondary, var(--bg-secondary));
+ border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 20px;
padding: 1.25rem;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
@@ -2439,14 +2437,14 @@ if (
}
.notification-card:hover {
- background: rgba(255, 255, 255, 0.06);
- border-color: rgba(255, 255, 255, 0.12);
+ background: var(--surface-tertiary, var(--bg-tertiary));
+ border-color: var(--border-secondary, #d1d5db);
transform: translateY(-2px);
}
.notification-card.unread {
- background: rgba(59, 130, 246, 0.05);
- border-color: rgba(59, 130, 246, 0.2);
+ background: var(--primary-light, rgba(30, 64, 175, 0.08));
+ border-color: var(--primary-border, rgba(30, 64, 175, 0.15));
}
.notification-card-header {
@@ -2461,8 +2459,8 @@ if (
justify-content: center;
width: 40px;
height: 40px;
- background: rgba(59, 130, 246, 0.1);
- color: #3b82f6;
+ background: var(--primary-light, rgba(30, 64, 175, 0.08));
+ color: var(--primary, #1e40af);
border-radius: 12px;
margin-right: 1rem;
flex-shrink: 0;
@@ -2477,13 +2475,13 @@ if (
.notification-title {
font-size: 0.95rem;
font-weight: 600;
- color: #ffffff;
+ color: var(--text-primary, #1a2332);
margin-bottom: 0.25rem;
}
.notification-time {
font-size: 0.75rem;
- color: rgba(255, 255, 255, 0.4);
+ color: var(--text-quaternary, #9ca3af);
}
.notification-card-body {
@@ -2491,7 +2489,7 @@ if (
}
.notification-text {
- color: rgba(255, 255, 255, 0.7);
+ color: var(--text-secondary, #4a5568);
font-size: 0.875rem;
line-height: 1.6;
}
@@ -2504,9 +2502,9 @@ if (
}
.action-button.delete {
- background: rgba(239, 68, 68, 0.05);
- color: #ef4444;
- border: 1px solid rgba(239, 68, 68, 0.1);
+ background: var(--error-light, rgba(239, 68, 68, 0.1));
+ color: var(--error, #ef4444);
+ border: 1px solid var(--error-border, rgba(239, 68, 68, 0.2));
padding: 0.4rem 0.75rem;
border-radius: 8px;
font-size: 0.75rem;
@@ -2517,22 +2515,22 @@ if (
}
.action-button.delete:hover {
- background: rgba(239, 68, 68, 0.1);
- border-color: rgba(239, 68, 68, 0.2);
+ background: rgba(239, 68, 68, 0.15);
+ border-color: rgba(239, 68, 68, 0.3);
}
.notification-actions-bar {
display: flex;
gap: 1rem;
padding: 1rem 0;
- border-top: 1px solid rgba(255, 255, 255, 0.08);
+ border-top: 1px solid var(--border-primary, #e5e7eb);
}
.action-button-large {
flex: 1;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- color: #ffffff;
+ background: var(--surface-secondary, var(--bg-secondary));
+ border: 1px solid var(--border-primary, #e5e7eb);
+ color: var(--text-primary, #1a2332);
padding: 0.75rem;
border-radius: 12px;
font-size: 0.875rem;
@@ -2542,15 +2540,15 @@ if (
}
.action-button-large:hover:not(.disabled) {
- background: rgba(255, 255, 255, 0.1);
+ background: var(--surface-tertiary, var(--bg-tertiary));
}
.action-button-large.danger {
- color: #ef4444;
+ color: var(--error, #ef4444);
}
.action-button-large.danger:hover {
- background: rgba(239, 68, 68, 0.1);
+ background: var(--error-light, rgba(239, 68, 68, 0.1));
}
.action-button-large.disabled {
@@ -2561,8 +2559,8 @@ if (
/* 分页控件样式 */
.notification-pagination {
padding: 15px 0;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
- border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+ border-top: 1px solid var(--border-primary, #e5e7eb);
+ border-bottom: 1px solid var(--border-primary, #e5e7eb);
}
.pagination-info {
@@ -2571,7 +2569,7 @@ if (
}
.pagination-text {
- color: rgba(255, 255, 255, 0.7);
+ color: var(--text-secondary, #4a5568);
font-size: 0.85rem;
}
@@ -2589,7 +2587,7 @@ if (
}
.page-size-selector label {
- color: rgba(255, 255, 255, 0.7);
+ color: var(--text-secondary, #4a5568);
font-size: 0.85rem;
white-space: nowrap;
}
@@ -2605,9 +2603,9 @@ if (
}
.page-nav-button {
- background-color: rgba(255, 255, 255, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.2);
- color: var(--light);
+ background-color: var(--surface-secondary, var(--bg-secondary));
+ border: 1px solid var(--border-primary, #e5e7eb);
+ color: var(--text-primary, #1a2332);
width: 32px;
height: 32px;
border-radius: 6px;
@@ -2620,8 +2618,8 @@ if (
}
.page-nav-button:hover:not(:disabled) {
- background-color: rgba(255, 255, 255, 0.2);
- border-color: rgba(255, 255, 255, 0.3);
+ background-color: var(--surface-tertiary, var(--bg-tertiary));
+ border-color: var(--border-secondary, #d1d5db);
transform: translateY(-1px);
}
@@ -2631,9 +2629,9 @@ if (
}
.page-nav-button:disabled {
- background-color: rgba(255, 255, 255, 0.05);
- border-color: rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.3);
+ background-color: var(--surface-secondary, var(--bg-secondary));
+ border-color: var(--border-primary, #e5e7eb);
+ color: var(--text-disabled, #c5cbd3);
cursor: not-allowed;
}
@@ -2644,9 +2642,9 @@ if (
}
.page-number-button {
- background-color: rgba(255, 255, 255, 0.1);
- border: 1px solid rgba(255, 255, 255, 0.2);
- color: var(--light);
+ background-color: var(--surface-secondary, var(--bg-secondary));
+ border: 1px solid var(--border-primary, #e5e7eb);
+ color: var(--text-primary, #1a2332);
width: 32px;
height: 32px;
border-radius: 6px;
@@ -2662,8 +2660,8 @@ if (
}
.page-number-button:hover:not(:disabled) {
- background-color: rgba(255, 255, 255, 0.2);
- border-color: rgba(255, 255, 255, 0.3);
+ background-color: var(--surface-tertiary, var(--bg-tertiary));
+ border-color: var(--border-secondary, #d1d5db);
transform: translateY(-1px);
}
@@ -2675,23 +2673,23 @@ if (
.page-number-button.active {
background-color: var(--primary);
border-color: var(--primary);
- color: white;
+ color: var(--btn-primary-text, #ffffff);
}
.page-number-button.active:hover {
- background-color: #0952e8;
- border-color: #0952e8;
+ background-color: var(--primary-hover, #1e3a8a);
+ border-color: var(--primary-hover, #1e3a8a);
}
.page-number-button:disabled {
- background-color: rgba(255, 255, 255, 0.05);
- border-color: rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.3);
+ background-color: var(--surface-secondary, var(--bg-secondary));
+ border-color: var(--border-primary, #e5e7eb);
+ color: var(--text-disabled, #c5cbd3);
cursor: not-allowed;
}
.page-ellipsis {
- color: rgba(255, 255, 255, 0.5);
+ color: var(--text-tertiary, #6b7280);
padding: 0 4px;
font-size: 0.85rem;
}
@@ -2703,7 +2701,7 @@ if (
justify-content: center;
gap: 8px;
padding: 10px 0;
- color: rgba(255, 255, 255, 0.7);
+ color: var(--text-secondary, #4a5568);
font-size: 0.85rem;
animation: fade-in 0.3s ease;
}
@@ -2711,7 +2709,7 @@ if (
.loading-spinner {
width: 16px;
height: 16px;
- border: 2px solid rgba(255, 255, 255, 0.2);
+ border: 2px solid var(--border-secondary, #d1d5db);
border-top: 2px solid var(--primary);
border-radius: 50%;
animation: spin 0.8s cubic-bezier(0.4, 0, 0.2, 1) infinite;
@@ -2744,13 +2742,13 @@ if (
justify-content: center;
gap: 10px;
padding: 15px 0;
- border-top: 1px solid rgba(255, 255, 255, 0.1);
+ border-top: 1px solid var(--border-primary, #e5e7eb);
}
.action-button-large {
- background-color: rgba(255, 255, 255, 0.1);
+ background-color: var(--surface-secondary, var(--bg-secondary));
border: none;
- color: var(--light);
+ color: var(--text-primary, #1a2332);
padding: 8px 16px;
border-radius: 6px;
cursor: pointer;
@@ -2759,26 +2757,26 @@ if (
}
.action-button-large:hover {
- background-color: rgba(255, 255, 255, 0.2);
+ background-color: var(--surface-tertiary, var(--bg-tertiary));
}
.action-button-large.danger {
- color: #ef4444;
+ color: var(--error, #ef4444);
}
.action-button-large.danger:hover {
- background-color: rgba(239, 68, 68, 0.2);
+ background-color: var(--error-light, rgba(239, 68, 68, 0.1));
}
.action-button-large.disabled {
- background-color: rgba(255, 255, 255, 0.05);
- color: rgba(255, 255, 255, 0.3);
+ background-color: var(--surface-secondary, var(--bg-secondary));
+ color: var(--text-disabled, #c5cbd3);
cursor: not-allowed;
opacity: 0.5;
}
.action-button-large.disabled:hover {
- background-color: rgba(255, 255, 255, 0.05);
+ background-color: var(--surface-secondary, var(--bg-secondary));
}
/* ==================== 移动端设计 ==================== */
@@ -2787,7 +2785,7 @@ if (
@media (max-width: 768px) {
.home {
padding: 0;
- background-color: #0a0a0f;
+ background-color: var(--bg-primary, #ffffff);
}
.main-content {
@@ -2806,8 +2804,8 @@ if (
align-items: center;
padding: 10px 12px;
margin: 0;
- background: linear-gradient(180deg, rgba(11, 90, 254, 0.08) 0%, transparent 100%);
- border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+ background: linear-gradient(180deg, var(--primary-light, rgba(30, 64, 175, 0.08)) 0%, transparent 100%);
+ border-bottom: 1px solid var(--border-primary, #e5e7eb);
}
.logo-section {
@@ -2827,7 +2825,7 @@ if (
.logo-divider {
height: 28px;
width: 1px;
- background: rgba(255, 255, 255, 0.15);
+ background: var(--border-secondary, #d1d5db);
}
/* 移动端的主页面里不需要写学校名,保持简洁 */
@@ -2852,8 +2850,8 @@ if (
.user-avatar-wrapper {
width: 32px;
height: 32px;
- background: rgba(255, 255, 255, 0.05);
- border-color: rgba(255, 255, 255, 0.1);
+ background: var(--surface-secondary, var(--bg-secondary));
+ border-color: var(--border-primary, #e5e7eb);
}
.user-avatar-placeholder {
@@ -2880,23 +2878,23 @@ if (
border-radius: 12px;
font-size: 14px;
font-weight: 600;
- background: rgba(255, 255, 255, 0.08);
+ background: var(--surface-secondary, var(--bg-secondary));
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
- color: white;
- border: 1px solid rgba(255, 255, 255, 0.1);
- box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
+ color: var(--text-primary, #1a2332);
+ border: 1px solid var(--border-primary, #e5e7eb);
+ box-shadow: var(--shadow-sm, 0 1px 2px rgba(0, 0, 0, 0.05));
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.login-options .login-btn :deep(.icon) {
- color: #3b82f6;
+ color: var(--primary, #1e40af);
}
.login-options .login-btn:active {
transform: scale(0.95);
- background: rgba(255, 255, 255, 0.15);
- border-color: rgba(59, 130, 246, 0.4);
+ background: var(--surface-tertiary, var(--bg-tertiary));
+ border-color: var(--primary-border, rgba(30, 64, 175, 0.15));
}
/* Footer 间距优化 */
@@ -2925,13 +2923,13 @@ if (
gap: 0;
padding: 0 0.5rem;
height: 64px;
- background: rgba(28, 28, 30, 0.9);
+ background: var(--surface-primary, var(--bg-primary));
backdrop-filter: blur(20px) saturate(180%);
-webkit-backdrop-filter: blur(20px) saturate(180%);
- border: 1px solid rgba(255, 255, 255, 0.1);
+ border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 9999px;
z-index: 1000;
- box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
+ box-shadow: var(--shadow-xl, 0 20px 25px rgba(0, 0, 0, 0.1));
}
.section-tab {
@@ -2943,7 +2941,7 @@ if (
padding: 0; /* 移除固定内边距,改用 flex 居中 */
font-size: 10px;
font-weight: 500;
- color: #71717a; /* text-zinc-500 */
+ color: var(--text-tertiary, #6b7280);
display: flex;
flex-direction: column;
align-items: center;
@@ -2968,15 +2966,14 @@ if (
}
.section-tab.active {
- color: #3b82f6 !important; /* text-blue-500 - Force blue */
+ color: var(--primary, #1e40af) !important;
background: transparent !important;
transform: none !important;
- text-shadow: 0 0 12px rgba(59, 130, 246, 0.6); /* Text Glow */
}
/* Prevent hover from turning it white on mobile */
.section-tab.active:hover {
- color: #3b82f6 !important;
+ color: var(--primary, #1e40af) !important;
background: transparent !important;
box-shadow: none !important;
}
@@ -2985,7 +2982,6 @@ if (
opacity: 1;
color: currentColor;
transform: none;
- filter: drop-shadow(0 0 8px rgba(59, 130, 246, 0.5)); /* Icon Glow */
}
.section-tab.active .tab-text {
@@ -3034,10 +3030,10 @@ if (
right: 0;
width: 8px;
height: 8px;
- background: #0b5afe;
+ background: var(--primary, #1e40af);
border-radius: 50%;
- border: 1.5px solid #0a0a0f;
- box-shadow: 0 0 5px rgba(11, 90, 254, 0.4);
+ border: 1.5px solid var(--surface-primary, var(--bg-primary));
+ box-shadow: 0 0 5px var(--primary-light, rgba(30, 64, 175, 0.08));
z-index: 2;
}
@@ -3096,8 +3092,8 @@ if (
padding: 6px 14px;
font-size: 12px;
border-radius: 6px;
- background: rgba(11, 90, 254, 0.15);
- border: 1px solid rgba(11, 90, 254, 0.3);
+ background: var(--primary-light, rgba(30, 64, 175, 0.08));
+ border: 1px solid var(--primary-border, rgba(30, 64, 175, 0.15));
}
}
--
2.45.1.windows.1
@@ -0,0 +1,58 @@
From 7ee5e8b9e961b66bdd85a878407ac72f6b4ea1c9 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 13:59:14 +0800
Subject: [PATCH 51/58] =?UTF-8?q?fix(ci):=20=E8=A7=A3=E5=86=B3=20Run#55=20?=
=?UTF-8?q?Nitro=20=E5=86=8D=E6=AC=A1=20OOM=EF=BC=9A=E6=8F=90=E5=8D=87?=
=?UTF-8?q?=E5=A0=86=E4=B8=8A=E9=99=90=E5=88=B0=2010G=20=E5=B9=B6=E7=A6=81?=
=?UTF-8?q?=E7=94=A8=E5=8E=8B=E7=BC=A9/minify?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- NODE_OPTIONS max-old-space-size 8192 → 10240,新增 --gc-interval=100000 主动 GC
- 构建前 drop_caches 清理页面缓存,降低系统级 RSS 压力
- 禁用 NITRO_MINIFY 与 DISABLE_ROLLDOWN,进一步降低峰值内存
- 构建前后打印内存快照,便于后续排障
---
.gitea/workflows/deploy.yml | 11 +++++++++--
1 file changed, 9 insertions(+), 2 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 0b24984..793a110 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -64,8 +64,10 @@ jobs:
NODE_MAJOR: "22"
NODE_VERSION_FALLBACK: "22.13.0"
NODE_MIRROR_PRIMARY: "https://registry.npmmirror.com/-/binary/node"
- NODE_OPTIONS: "--max-old-space-size=8192 --no-warnings --expose-gc"
+ NODE_OPTIONS: "--max-old-space-size=10240 --no-warnings --expose-gc --gc-interval=100000"
NITRO_PRESET: "node-server"
+ DISABLE_ROLLDOWN: "1"
+ NITRO_MINIFY: "false"
steps:
- name: 安装基础运行依赖(git/curl/ca-certificates/tar/xz-utils,Runner 自带)
run: |
@@ -198,13 +200,18 @@ jobs:
set -euxo pipefail
echo "=== 构建前系统内存状态 ==="
free -h || true
+ sync && echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true
echo "=== 开始执行 nuxt build ==="
- export NODE_OPTIONS="--max-old-space-size=8192 --no-warnings --expose-gc"
+ export NODE_OPTIONS="--max-old-space-size=10240 --no-warnings --expose-gc --gc-interval=100000"
export NITRO_PRESET=node-server
+ export NITRO_MINIFY=false
+ export DISABLE_ROLLDOWN=1
npm run build
echo "=== 构建完成,.output/ 生成结果 ==="
ls -la .output/ 2>/dev/null || true
du -sh .output 2>/dev/null || true
+ echo "=== 构建后内存状态 ==="
+ free -h || true
build-image:
name: 构建并推送容器镜像
--
2.45.1.windows.1
@@ -0,0 +1,41 @@
From bf7c40c4fee2e25c1409d8cb83a7a4979189b572 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 14:04:27 +0800
Subject: [PATCH 52/58] =?UTF-8?q?fix(ci):=20=E5=9B=9E=E6=BB=9A=20NODE=5FOP?=
=?UTF-8?q?TIONS=20=E9=9D=9E=E6=B3=95=20--gc-interval=EF=BC=8C=E8=A7=A3?=
=?UTF-8?q?=E5=86=B3=20Run#56=20=E7=AB=8B=E5=8D=B3=20exit=209?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Node.js NODE_OPTIONS 不允许传 --gc-interval,导致 Run 56 刚启动 Node v0 检测时就 exit code 9 全局失败
- 保留 max-old-space-size=10240、expose-gc、drop_caches、DISABLE_ROLLDOWN=1、NITRO_MINIFY=false 所有有效内存缓解措施
---
.gitea/workflows/deploy.yml | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml
index 793a110..5f46f56 100644
--- a/.gitea/workflows/deploy.yml
+++ b/.gitea/workflows/deploy.yml
@@ -64,7 +64,7 @@ jobs:
NODE_MAJOR: "22"
NODE_VERSION_FALLBACK: "22.13.0"
NODE_MIRROR_PRIMARY: "https://registry.npmmirror.com/-/binary/node"
- NODE_OPTIONS: "--max-old-space-size=10240 --no-warnings --expose-gc --gc-interval=100000"
+ NODE_OPTIONS: "--max-old-space-size=10240 --no-warnings --expose-gc"
NITRO_PRESET: "node-server"
DISABLE_ROLLDOWN: "1"
NITRO_MINIFY: "false"
@@ -202,7 +202,7 @@ jobs:
free -h || true
sync && echo 3 > /proc/sys/vm/drop_caches 2>/dev/null || true
echo "=== 开始执行 nuxt build ==="
- export NODE_OPTIONS="--max-old-space-size=10240 --no-warnings --expose-gc --gc-interval=100000"
+ export NODE_OPTIONS="--max-old-space-size=10240 --no-warnings --expose-gc"
export NITRO_PRESET=node-server
export NITRO_MINIFY=false
export DISABLE_ROLLDOWN=1
--
2.45.1.windows.1
+441
View File
@@ -0,0 +1,441 @@
From b1c3af84de0aa9adaaba07544a917a9f7e7e3853 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 14:12:10 +0800
Subject: [PATCH 53/58] =?UTF-8?q?fix(ui):=20=E8=A1=A5=E9=BD=90=E6=B5=85?=
=?UTF-8?q?=E8=89=B2=E4=B8=BB=E9=A2=98=E9=81=97=E6=BC=8F=EF=BC=9Aindex=20?=
=?UTF-8?q?=E5=90=8E=E6=AE=B5=E5=BC=B9=E7=AA=97=20+=20dashboard=20?=
=?UTF-8?q?=E6=A0=B9=20+=20=E6=94=B9=E5=AF=86=E9=A1=B5=E6=A0=B9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- index.vue 规则弹窗 modal-content/close/rule/ripple/login-required 全改为语义化 var()
- index.vue 年度报告 year-review 卡片/按钮/brand/badge/title 全部变量化
- dashboard.vue admin-layout 根 #0a0a0a/#fff 改为 var(--bg/text-*)
- change-password.vue 改密页 auth-layout 容器、表单区、back-link 全改为浅色变量
---
app/pages/change-password.vue | 25 ++++-----
app/pages/dashboard.vue | 8 +--
app/pages/index.vue | 97 ++++++++++++++++++-----------------
3 files changed, 66 insertions(+), 64 deletions(-)
diff --git a/app/pages/change-password.vue b/app/pages/change-password.vue
index 119e978..506d5e6 100644
--- a/app/pages/change-password.vue
+++ b/app/pages/change-password.vue
@@ -132,7 +132,7 @@ onMounted(async () => {
<style scoped>
.auth-layout {
min-height: 100vh;
- background: #0a0a0a;
+ background: var(--bg-primary, #ffffff);
display: flex;
align-items: center;
justify-content: center;
@@ -142,13 +142,14 @@ onMounted(async () => {
.auth-container {
width: 100%;
max-width: 1200px;
- background: #111111;
+ background: var(--surface-primary, var(--bg-primary, #ffffff));
border-radius: 24px;
- border: 1px solid #1f1f1f;
+ border: 1px solid var(--border-primary, #e5e7eb);
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 600px;
+ box-shadow: 0 10px 40px -10px rgba(15, 23, 42, 0.1);
}
.info-section {
@@ -266,7 +267,7 @@ onMounted(async () => {
display: flex;
align-items: center;
justify-content: center;
- background: #111111;
+ background: var(--surface-primary, var(--bg-primary, #ffffff));
}
.form-container {
@@ -285,13 +286,13 @@ onMounted(async () => {
.form-header h2 {
font-size: 28px;
font-weight: 700;
- color: #ffffff;
+ color: var(--text-primary, #1a2332);
margin: 0 0 8px 0;
}
.form-header p {
font-size: 16px;
- color: #888888;
+ color: var(--text-secondary, #4a5568);
margin: 0;
}
@@ -305,10 +306,10 @@ onMounted(async () => {
align-items: center;
gap: 8px;
padding: 12px 20px;
- background: #1a1a1a;
- border: 1px solid #2a2a2a;
+ background: var(--surface-secondary, var(--bg-secondary, #f7fafc));
+ border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 8px;
- color: #cccccc;
+ color: var(--text-secondary, #4a5568);
text-decoration: none;
font-size: 14px;
font-weight: 500;
@@ -316,9 +317,9 @@ onMounted(async () => {
}
.back-link:hover {
- background: #2a2a2a;
- color: #ffffff;
- border-color: #3a3a3a;
+ background: var(--surface-tertiary, var(--bg-tertiary, #edf2f7));
+ color: var(--text-primary, #1a2332);
+ border-color: var(--border-secondary, #cbd5e1);
}
.back-link svg {
diff --git a/app/pages/dashboard.vue b/app/pages/dashboard.vue
index 95ab7b8..fe6abeb 100644
--- a/app/pages/dashboard.vue
+++ b/app/pages/dashboard.vue
@@ -548,8 +548,8 @@ onUnmounted(() => {
.admin-layout {
display: flex;
min-height: 100vh;
- background: #0a0a0a;
- color: #ffffff;
+ background: var(--bg-primary, #ffffff);
+ color: var(--text-primary, #1a2332);
position: relative;
}
@@ -561,11 +561,11 @@ onUnmounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
- background: #27272a;
+ background: var(--border-primary, #cbd5e1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
- background: #3f3f46;
+ background: var(--border-secondary, #94a3b8);
}
@media (max-width: 768px) {
diff --git a/app/pages/index.vue b/app/pages/index.vue
index 9699dc7..8d450c0 100644
--- a/app/pages/index.vue
+++ b/app/pages/index.vue
@@ -1912,7 +1912,7 @@ if (
background-color: var(--error, #ef4444);
color: #ffffff;
font-size: 10px;
- font-weight: bold;
+ font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
@@ -3179,10 +3179,10 @@ if (
/* 弹窗内容 */
.modal-content {
- background: linear-gradient(135deg, #1a1b24 0%, #121318 100%);
+ background: var(--surface-primary, var(--bg-primary, #ffffff));
border-radius: 20px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+ border: 1px solid var(--border-primary, #e5e7eb);
+ box-shadow: var(--shadow-card, 0 25px 50px -12px rgba(0, 0, 0, 0.12));
max-width: 420px;
width: 90%;
overflow: hidden;
@@ -3206,7 +3206,7 @@ if (
justify-content: space-between;
align-items: center;
padding: 24px 28px 16px;
- border-bottom: 1px solid rgba(255, 255, 255, 0.08);
+ border-bottom: 1px solid var(--border-secondary, #f1f5f9);
}
.modal-header h2 {
@@ -3217,7 +3217,7 @@ if (
sans-serif;
font-size: 20px;
font-weight: 700;
- color: #ffffff;
+ color: var(--text-primary, #1a2332);
margin: 0;
letter-spacing: 0.02em;
}
@@ -3227,9 +3227,9 @@ if (
width: 32px;
height: 32px;
border-radius: 50%;
- background: rgba(255, 255, 255, 0.08);
+ background: var(--surface-tertiary, var(--bg-tertiary, #f1f5f9));
border: none;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-tertiary, #64748b);
font-size: 24px;
line-height: 1;
cursor: pointer;
@@ -3240,8 +3240,8 @@ if (
}
.close-button:hover {
- background: rgba(255, 255, 255, 0.15);
- color: #ffffff;
+ background: var(--border-secondary, #e2e8f0);
+ color: var(--text-primary, #1a2332);
transform: rotate(90deg);
}
@@ -3268,24 +3268,24 @@ if (
gap: 0.5rem;
font-size: 16px;
font-weight: 700;
- color: #ffffff;
+ color: var(--text-primary, #1a2332);
margin: 0;
}
.rules-icon {
- color: #3b82f6;
+ color: var(--primary, #0b5afe);
}
.rules-text {
font-size: 14px;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary, #4a5568);
line-height: 1.6;
margin: 0;
}
.guidelines-content {
font-size: 14px;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary, #4a5568);
line-height: 1.6;
}
@@ -3298,31 +3298,31 @@ if (
.rule-item {
display: flex;
font-size: 14px;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary, #4a5568);
line-height: 1.5;
}
.rule-item span {
margin-right: 0.5rem;
- color: rgba(255, 255, 255, 0.3);
+ color: var(--text-disabled, #94a3b8);
font-weight: 600;
}
/* 年度报告弹窗 */
.year-review-overlay {
backdrop-filter: blur(8px);
- background: rgba(0, 0, 0, 0.4);
+ background: rgba(15, 23, 42, 0.35);
}
.year-review-card {
position: relative;
width: 90%;
max-width: 400px;
- background: #12121a;
+ background: var(--surface-primary, var(--bg-primary, #ffffff));
border-radius: 32px;
overflow: hidden;
- border: 1px solid rgba(255, 255, 255, 0.1);
- box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
+ border: 1px solid var(--border-primary, #e5e7eb);
+ box-shadow: var(--shadow-card, 0 25px 50px -12px rgba(0, 0, 0, 0.12));
animation: card-appear 0.6s cubic-bezier(0.16, 1, 0.3, 1);
}
@@ -3339,7 +3339,7 @@ if (
.card-pattern {
position: absolute;
inset: 0;
- background-image: radial-gradient(rgba(255, 255, 255, 0.05) 1px, transparent 1px);
+ background-image: radial-gradient(rgba(15, 23, 42, 0.06) 1px, transparent 1px);
background-size: 20px 20px;
opacity: 0.5;
}
@@ -3351,9 +3351,9 @@ if (
width: 36px;
height: 36px;
border-radius: 12px;
- background: rgba(255, 255, 255, 0.05);
- border: 1px solid rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.4);
+ background: var(--surface-tertiary, var(--bg-tertiary, #f1f5f9));
+ border: 1px solid var(--border-secondary, #f1f5f9);
+ color: var(--text-tertiary, #64748b);
display: flex;
align-items: center;
justify-content: center;
@@ -3363,8 +3363,8 @@ if (
}
.card-close:hover {
- background: rgba(255, 255, 255, 0.1);
- color: #fff;
+ background: var(--border-secondary, #e2e8f0);
+ color: var(--text-primary, #1a2332);
transform: translateY(2px);
}
@@ -3379,9 +3379,9 @@ if (
display: inline-block;
padding: 4px 12px;
background: rgba(139, 92, 246, 0.1);
- border: 1px solid rgba(139, 92, 246, 0.2);
+ border: 1px solid rgba(139, 92, 246, 0.25);
border-radius: 99px;
- color: #a78bfa;
+ color: #7c3aed;
font-size: 12px;
font-weight: 700;
letter-spacing: 0.1em;
@@ -3405,8 +3405,8 @@ if (
display: flex;
align-items: center;
justify-content: center;
- color: #fff;
- box-shadow: 0 10px 25px -5px rgba(139, 92, 246, 0.5);
+ color: #ffffff;
+ box-shadow: 0 10px 25px -5px rgba(139, 92, 246, 0.35);
z-index: 2;
transform: rotate(-5deg);
}
@@ -3461,14 +3461,14 @@ if (
.card-title {
font-size: 24px;
font-weight: 800;
- color: #fff;
+ color: var(--text-primary, #1a2332);
margin-bottom: 12px;
letter-spacing: -0.01em;
}
.card-description {
font-size: 15px;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--text-secondary, #4a5568);
line-height: 1.6;
margin-bottom: 32px;
}
@@ -3482,10 +3482,10 @@ if (
.btn-primary {
width: 100%;
padding: 16px;
- background: #fff;
+ background: var(--primary, #0b5afe);
border: none;
border-radius: 16px;
- color: #000;
+ color: #ffffff;
font-size: 16px;
font-weight: 700;
display: flex;
@@ -3498,8 +3498,8 @@ if (
.btn-primary:hover {
transform: translateY(-2px);
- box-shadow: 0 10px 20px rgba(255, 255, 255, 0.1);
- background: #f4f4f4;
+ box-shadow: 0 10px 20px rgba(11, 90, 254, 0.2);
+ background: var(--primary-hover, #0043f8);
}
.btn-primary:active {
@@ -3510,9 +3510,9 @@ if (
width: 100%;
padding: 14px;
background: transparent;
- border: 1px solid rgba(255, 255, 255, 0.1);
+ border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 16px;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary, #4a5568);
font-size: 14px;
font-weight: 600;
cursor: pointer;
@@ -3520,9 +3520,9 @@ if (
}
.btn-secondary:hover {
- background: rgba(255, 255, 255, 0.05);
- color: #fff;
- border-color: rgba(255, 255, 255, 0.2);
+ background: var(--surface-tertiary, var(--bg-tertiary, #f1f5f9));
+ color: var(--text-primary, #1a2332);
+ border-color: var(--border-secondary, #d1d5db);
}
@keyframes card-appear {
@@ -3564,7 +3564,7 @@ if (
.ripple-effect {
position: absolute;
border-radius: 50%;
- background-color: rgba(255, 255, 255, 0.3);
+ background-color: rgba(11, 90, 254, 0.25);
transform: scale(0);
animation: ripple 0.6s linear;
pointer-events: none;
@@ -3640,32 +3640,33 @@ if (
text-align: center;
max-width: 400px;
padding: 30px;
- background-color: rgba(30, 41, 59, 0.5);
+ background-color: var(--surface-secondary, var(--bg-secondary, #f7fafc));
border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.1);
- box-shadow: 0 5px 20px rgba(0, 0, 0, 0.2);
+ border: 1px solid var(--border-primary, #e5e7eb);
+ box-shadow: var(--shadow-card, 0 5px 20px rgba(0, 0, 0, 0.08));
}
.login-icon {
font-size: 3rem;
margin-bottom: 15px;
+ color: var(--primary, #0b5afe);
}
.login-required-content h3 {
font-size: 1.5rem;
margin-bottom: 10px;
- color: var(--light);
+ color: var(--text-primary, #1a2332);
}
.login-required-content p {
margin-bottom: 20px;
- color: rgba(255, 255, 255, 0.7);
+ color: var(--text-secondary, #4a5568);
}
.login-button {
background: linear-gradient(180deg, #0043f8 0%, #0075f8 100%);
border: none;
- color: white;
+ color: #ffffff;
padding: 10px 20px;
border-radius: 6px;
font-weight: 600;
--
2.45.1.windows.1
+447
View File
@@ -0,0 +1,447 @@
From 8b0934f6f4d5571dc05867e751eaf25cbc738d3c Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 15:23:26 +0800
Subject: [PATCH 54/58] =?UTF-8?q?fix(ui):=20=E5=BD=BB=E5=BA=95=E4=BF=AE?=
=?UTF-8?q?=E5=A4=8D=E6=B7=B1=E6=B5=85=E8=89=B2=E4=B8=BB=E9=A2=98=E5=88=87?=
=?UTF-8?q?=E6=8D=A2=E5=8F=8A=E5=86=85=E9=83=A8=E9=A1=B5=E9=9D=A2=E6=A0=B7?=
=?UTF-8?q?=E5=BC=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 恢复 variables.css 完整的 ClassicDark/ModernDark 等主题定义
- 恢复 nuxt.config.ts 的主题系统逻辑(默认改为 ClassicLight,保留切换能力)
- 修复 BackupManager / VotersModal / ProfileForm / LyricsModal / RequestForm / SongList / ScheduleList / StatsSlide / ArtistSlide 等数十处组件内部硬编码深色(#1a1a1a / #040e15 等)绕过主题的问题,全部改为使用 CSS 变量 (var(--bg-primary) 等)
- 这样实现了:在浅色模式下,所有页面均正确显示浅色;在深色模式下,又能正常呈现深色设计,且恢复了右上角的深浅色切换能力
---
app/assets/css/variables.css | 94 +++++++++-------------
app/components/Admin/BackupManager.vue | 8 +-
app/components/Admin/VotersModal.vue | 4 +-
app/components/Auth/ProfileForm.vue | 14 ++--
app/components/Songs/RequestForm.vue | 12 +--
app/components/Songs/ScheduleList.vue | 8 +-
app/components/Songs/SongList.vue | 8 +-
app/components/UI/LyricsModal.vue | 4 +-
app/components/year-review/ArtistSlide.vue | 2 +-
app/components/year-review/StatsSlide.vue | 2 +-
nuxt.config.ts | 34 +++-----
11 files changed, 79 insertions(+), 111 deletions(-)
diff --git a/app/assets/css/variables.css b/app/assets/css/variables.css
index 88a6536..30dce2a 100644
--- a/app/assets/css/variables.css
+++ b/app/assets/css/variables.css
@@ -1,13 +1,10 @@
-/* 全局CSS变量 - 统一配色方案(默认浅色ClassicLight,不依赖data-theme也能正常显示) */
+/* 全局CSS变量 - 统一配色方案 */
:root {
- /* 强制浅色基准值 - 即使 data-theme 缺失也保持浅色 */
- color-scheme: light;
-
/* 主色调 */
- --primary: #1e40af;
- --primary-hover: #1e3a8a;
- --primary-light: rgba(30, 64, 175, 0.08);
- --primary-border: rgba(30, 64, 175, 0.15);
+ --primary: #3b82f6;
+ --primary-hover: #2563eb;
+ --primary-light: rgba(59, 130, 246, 0.1);
+ --primary-border: rgba(59, 130, 246, 0.2);
/* 成功色 */
--success: #10b981;
@@ -33,25 +30,25 @@
--info-light: rgba(59, 130, 246, 0.1);
--info-border: rgba(59, 130, 246, 0.2);
- /* 背景色 (默认 ClassicLight 浅色) */
- --bg-primary: #ffffff;
- --bg-secondary: #f5f7fa;
- --bg-tertiary: #edf0f5;
- --bg-quaternary: #d9dee7;
- --bg-hover: #f0f3f8;
-
- /* 文字颜色 (默认 ClassicLight 浅色) */
- --text-primary: #1a2332;
- --text-secondary: #4a5568;
- --text-tertiary: #6b7280;
- --text-quaternary: #9ca3af;
- --text-disabled: #c5cbd3;
-
- /* 边框颜色 (默认 ClassicLight 浅色) */
- --border-primary: #e5e7eb;
- --border-secondary: #d1d5db;
- --border-tertiary: #9ca3af;
- --border-quaternary: #6b7280;
+ /* 背景色 */
+ --bg-primary: #111111;
+ --bg-secondary: #1a1a1a;
+ --bg-tertiary: #2a2a2a;
+ --bg-quaternary: #3a3a3a;
+ --bg-hover: #1f1f1f;
+
+ /* 文字颜色 */
+ --text-primary: #ffffff;
+ --text-secondary: #cccccc;
+ --text-tertiary: #888888;
+ --text-quaternary: #666666;
+ --text-disabled: #444444;
+
+ /* 边框颜色 */
+ --border-primary: #1f1f1f;
+ --border-secondary: #2a2a2a;
+ --border-tertiary: #3a3a3a;
+ --border-quaternary: #4a4a4a;
/* 阴影 */
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
@@ -135,16 +132,16 @@
--card-hover-border: var(--border-tertiary);
--card-shadow: var(--shadow-md);
- /* 按钮颜色 - 浅色基准 */
+ /* 按钮颜色 */
--btn-primary-bg: var(--primary);
--btn-primary-hover: var(--primary-hover);
- --btn-primary-text: #ffffff;
+ --btn-primary-text: var(--text-primary);
--btn-primary-border: var(--primary-border);
- --btn-secondary-bg: #f1f5f9;
- --btn-secondary-hover: #e2e8f0;
- --btn-secondary-text: #0f172a;
- --btn-secondary-border: #cbd5e1;
+ --btn-secondary-bg: #3a3a3a;
+ --btn-secondary-hover: #4a4a4a;
+ --btn-secondary-text: var(--text-primary);
+ --btn-secondary-border: #555555;
--btn-success-bg: var(--success);
--btn-success-hover: var(--success-hover);
@@ -164,12 +161,12 @@
--btn-disabled-bg: var(--bg-tertiary);
--btn-disabled-text: var(--text-disabled);
- /* 输入框颜色 - 浅色基准 */
- --input-bg: #ffffff;
- --input-border: #d1d5db;
+ /* 输入框颜色 */
+ --input-bg: var(--bg-tertiary);
+ --input-border: var(--border-tertiary);
--input-border-focus: var(--primary);
--input-text: var(--text-primary);
- --input-placeholder: #9ca3af;
+ --input-placeholder: var(--text-quaternary);
--input-shadow-focus: 0 0 0 3px var(--primary-light);
/* 模态框颜色 */
@@ -200,27 +197,12 @@
--spinner-primary: var(--primary);
--spinner-secondary: var(--bg-tertiary);
- /* 滚动条颜色 - 浅色基准 */
- --scrollbar-track: #f1f5f9;
- --scrollbar-thumb: #cbd5e1;
- --scrollbar-thumb-hover: #94a3b8;
-
- /* Surface 兼容别名 - 部分按钮/容器使用 surface-xxx */
- --surface-primary: var(--bg-primary);
- --surface-secondary: var(--bg-secondary);
- --surface-tertiary: var(--bg-tertiary);
- --surface-quaternary: var(--bg-quaternary);
-}
-
-/* 防深色兜底:覆盖 prefers-color-scheme: dark 对表单/根元素的自动影响 */
-@media (prefers-color-scheme: dark) {
- :root {
- color-scheme: light;
- }
+ /* 滚动条颜色 */
+ --scrollbar-track: var(--bg-tertiary);
+ --scrollbar-thumb: var(--bg-quaternary);
+ --scrollbar-thumb-hover: #4a4a4a;
}
-/* 浅色主题叠加层(与 data-theme 无关,确保始终为浅色;保留 data-theme 变量覆盖以兼容切换) */
-:root,
:root[data-theme='light'],
:root[data-theme='ClassicLight'],
:root[data-theme='ModernLight'] {
diff --git a/app/components/Admin/BackupManager.vue b/app/components/Admin/BackupManager.vue
index cb9c00a..efe60d0 100644
--- a/app/components/Admin/BackupManager.vue
+++ b/app/components/Admin/BackupManager.vue
@@ -520,8 +520,8 @@ const formatFileSize = (bytes) => {
/* 主容器 */
.backup-manager {
min-height: 100vh;
- background: #1a1a1a;
- color: #e5e5e5;
+ background: var(--bg-primary, #1a1a1a);
+ color: var(--text-primary, #e5e5e5);
padding: 2rem;
position: relative;
}
@@ -535,13 +535,13 @@ const formatFileSize = (bytes) => {
.header h3 {
font-size: 2.5rem;
font-weight: 700;
- color: #ffffff;
+ color: var(--text-primary, #ffffff);
margin: 0 0 1rem 0;
}
.header .description {
font-size: 1.125rem;
- color: #a0a0a0;
+ color: var(--text-secondary, #a0a0a0);
margin: 0;
}
diff --git a/app/components/Admin/VotersModal.vue b/app/components/Admin/VotersModal.vue
index c14b029..d7730f9 100644
--- a/app/components/Admin/VotersModal.vue
+++ b/app/components/Admin/VotersModal.vue
@@ -202,9 +202,9 @@ watch(
}
.modal-content {
- background: #1a1a1a;
+ background: var(--surface-primary, var(--bg-primary, #1a1a1a));
border-radius: 16px;
- border: 1px solid #2a2a2a;
+ border: 1px solid var(--border-primary, #2a2a2a);
width: 100%;
max-width: 500px;
max-height: 80vh;
diff --git a/app/components/Auth/ProfileForm.vue b/app/components/Auth/ProfileForm.vue
index 3c23d2d..1403f16 100644
--- a/app/components/Auth/ProfileForm.vue
+++ b/app/components/Auth/ProfileForm.vue
@@ -224,24 +224,24 @@ const handleUpdateProfile = async () => {
<style scoped>
.custom-select-profile :deep(> .flex) {
padding: 10px 16px;
- background-color: #09090b;
- border: 1px solid #27272a;
+ background-color: var(--input-bg, #09090b);
+ border: 1px solid var(--input-border, #27272a);
border-radius: 0.75rem;
- color: #f4f4f5;
+ color: var(--input-text, #f4f4f5);
font-size: 0.875rem;
line-height: 1.25rem;
height: 46px;
}
.custom-select-profile :deep(> .flex:hover) {
- border-color: #3f3f46;
+ border-color: var(--input-border-focus, #3f3f46);
}
.custom-select-profile :deep(> .border-blue-500\/50) {
- border-color: rgba(34, 197, 94, 0.5) !important;
- --tw-ring-color: rgba(34, 197, 94, 0.5);
+ border-color: var(--primary, rgba(34, 197, 94, 0.5)) !important;
+ --tw-ring-color: var(--primary-light, rgba(34, 197, 94, 0.5));
box-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
- background-color: #09090b !important;
+ background-color: var(--input-bg, #09090b) !important;
}
.custom-select-profile :deep(.text-\[11px\]) {
diff --git a/app/components/Songs/RequestForm.vue b/app/components/Songs/RequestForm.vue
index d28fc42..538f4f5 100644
--- a/app/components/Songs/RequestForm.vue
+++ b/app/components/Songs/RequestForm.vue
@@ -3194,14 +3194,14 @@ defineExpose({
}
.search-input {
- background: #040e15;
- border: 1px solid #242f38;
+ background: var(--input-bg, #040e15);
+ border: 1px solid var(--input-border, #242f38);
border-radius: 8px;
padding: 0.6rem 0.85rem;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 15px;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--input-text, rgba(255, 255, 255, 0.6));
flex: 1;
min-width: 100px; /* 确保输入框不会缩到太小 */
}
@@ -3484,14 +3484,14 @@ defineExpose({
.form-input,
.form-select {
- background: #040e15;
- border: 1px solid #242f38;
+ background: var(--input-bg, #040e15);
+ border: 1px solid var(--input-border, #242f38);
border-radius: 8px;
padding: 0.75rem 1rem;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--input-text, rgba(255, 255, 255, 0.6));
width: 100%;
}
diff --git a/app/components/Songs/ScheduleList.vue b/app/components/Songs/ScheduleList.vue
index ffb3b5e..7df22f8 100644
--- a/app/components/Songs/ScheduleList.vue
+++ b/app/components/Songs/ScheduleList.vue
@@ -2188,9 +2188,9 @@ const vRipple = {
.custom-select:focus,
.custom-input:focus {
outline: none;
- border-color: var(--primary);
- box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
- background: #1a1a20;
+ border-color: var(--input-border-focus, var(--primary));
+ box-shadow: var(--input-shadow-focus, 0 0 0 3px rgba(102, 126, 234, 0.1));
+ background: var(--input-bg, #1a1a20);
}
.btn-icon {
@@ -3168,7 +3168,7 @@ const vRipple = {
/* 日期选择弹窗 */
.date-picker-content {
- background: #1a1a1f;
+ background: var(--surface-primary, var(--bg-primary, #1a1a1f));
border-radius: 20px 20px 0 0;
width: 100%;
max-width: 100%;
diff --git a/app/components/Songs/SongList.vue b/app/components/Songs/SongList.vue
index 7478f2d..f6134f6 100644
--- a/app/components/Songs/SongList.vue
+++ b/app/components/Songs/SongList.vue
@@ -1894,14 +1894,14 @@ const vRipple = {
}
.search-input {
- background: #040e15;
- border: 1px solid #242f38;
+ background: var(--input-bg, #040e15);
+ border: 1px solid var(--input-border, #242f38);
border-radius: 8px;
padding: 0.5rem 1rem;
padding-right: 2.5rem;
font-family: 'MiSans-Demibold', sans-serif;
font-size: 14px;
- color: rgba(255, 255, 255, 0.8);
+ color: var(--input-text, rgba(255, 255, 255, 0.8));
width: 100%;
transition: all 0.2s ease;
}
@@ -2593,7 +2593,7 @@ button:disabled {
}
.semester-dropdown {
- background: #1a1a1f;
+ background: var(--surface-primary, var(--bg-primary, #1a1a1f));
border: none;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
diff --git a/app/components/UI/LyricsModal.vue b/app/components/UI/LyricsModal.vue
index 3451ee1..25c07ac 100644
--- a/app/components/UI/LyricsModal.vue
+++ b/app/components/UI/LyricsModal.vue
@@ -953,8 +953,8 @@ onUnmounted(() => {
position: relative;
width: 100%;
height: 100%;
- background: #000000;
- color: #ffffff;
+ background: var(--bg-primary, #000000);
+ color: var(--text-primary, #ffffff);
overflow: hidden;
display: flex;
flex-direction: column;
diff --git a/app/components/year-review/ArtistSlide.vue b/app/components/year-review/ArtistSlide.vue
index 27d56f1..41a2cd4 100644
--- a/app/components/year-review/ArtistSlide.vue
+++ b/app/components/year-review/ArtistSlide.vue
@@ -37,7 +37,7 @@ defineProps({
align-items: center;
justify-content: center;
overflow: hidden;
- background-color: #1a0b2e;
+ background-color: var(--surface-primary, var(--bg-primary, #1a0b2e));
}
.bg-overlay {
diff --git a/app/components/year-review/StatsSlide.vue b/app/components/year-review/StatsSlide.vue
index 9b474f1..5ab8c2e 100644
--- a/app/components/year-review/StatsSlide.vue
+++ b/app/components/year-review/StatsSlide.vue
@@ -43,7 +43,7 @@ defineProps({
align-items: center;
justify-content: center;
overflow: hidden;
- background-color: #030712; /* gray-950 */
+ background-color: var(--surface-primary, var(--bg-primary, #030712));
}
.bg-gradient {
diff --git a/nuxt.config.ts b/nuxt.config.ts
index 6964a0d..d5ff5f3 100644
--- a/nuxt.config.ts
+++ b/nuxt.config.ts
@@ -194,10 +194,6 @@ export default defineNuxtConfig({
name: 'x-default-theme',
content: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'ClassicLight'
},
- {
- name: 'x-enabled-themes',
- content: process.env.NUXT_PUBLIC_ENABLED_THEMES || 'ClassicLight,ModernLight'
- },
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
{ name: 'apple-mobile-web-app-title', content: 'VoiceHub管理' },
@@ -210,28 +206,18 @@ export default defineNuxtConfig({
tagPosition: 'head',
children: [
`(function(){try{`,
- ` var k='voicehub:theme', d=document, r=d.documentElement, s=null, eff=null, def=null, list=[];`,
- ` try{`,
- ` var mt=d.querySelector('meta[name="x-enabled-themes"]');`,
- ` if(mt && mt.content){list=String(mt.content).split(',').map(function(x){return x.replace(/^\\s+|\\s+$/g,'')}).filter(Boolean)}`,
- ` }catch(e){list=[]}`,
- ` try{`,
- ` var md=d.querySelector('meta[name="x-default-theme"]');`,
- ` if(md && md.content){def=md.content}`,
- ` }catch(e){def=null}`,
- ` if(!def){def='ClassicLight'}`,
- ` if(list.length===0){list=['ClassicLight','ModernLight']}`,
- ` function valid(t){`,
- ` if(list.indexOf(t)!==-1){return t}`,
- ` if(list.indexOf(def)!==-1){return def}`,
- ` return list[0]`,
- ` }`,
+ ` var k='voicehub:theme', d=document, r=d.documentElement, s=null, eff=null;`,
` try{s=localStorage.getItem(k)}catch(e){s=null}`,
- ` if(!s){s=def}`,
- ` else{s=valid(s)}`,
- ` if(typeof s==='string' && (s==='System'||s==='system')){eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
+ ` if(!s){`,
+ ` try{`,
+ ` var meta=document.querySelector('meta[name="x-default-theme"]');`,
+ ` if(meta && meta.content){s=meta.content}`,
+ ` }catch(e){s=null}`,
+ ` if(!s){s='ClassicLight'}`,
+ ` }`,
+ ` if(s==='System'||s==='system'){eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
` else if(typeof s==='string'){eff=s.endsWith('Dark')||s==='dark'?'dark':(s.endsWith('Light')||s==='light'?'light':s)}`,
- ` else{eff='light'}`,
+ ` else{eff='dark'}`,
` r.setAttribute('data-theme', (typeof s==='string'?s:eff));`,
` if(eff==='dark'){r.classList.add('dark'); r.classList.remove('light'); r.style.colorScheme='dark'}`,
` else if(eff==='light'){r.classList.add('light'); r.classList.remove('dark'); r.style.colorScheme='light'}`,
--
2.45.1.windows.1
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+913
View File
@@ -0,0 +1,913 @@
From 78c3d878eac42aaa3ee53cfd6b03791756c10d18 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 16:56:22 +0800
Subject: [PATCH 57/58] =?UTF-8?q?fix(ui):=20=E4=BF=AE=E5=A4=8D=E6=B5=85?=
=?UTF-8?q?=E8=89=B2=E4=B8=BB=E9=A2=98=E4=B8=8B=E7=9A=84=20Tailwind=20CSS?=
=?UTF-8?q?=20=E5=8F=8A=E9=83=A8=E5=88=86=E6=AE=8B=E7=95=99=E7=A1=AC?=
=?UTF-8?q?=E7=BC=96=E7=A0=81?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
app/components/Songs/RequestForm.vue | 86 +++++++++++-----------
app/components/Songs/ScheduleList.vue | 60 +++++++--------
app/components/UserTutorial.vue | 42 +++++------
fix-more-colors.cjs | 102 ++++++++++++++++++++++++++
tailwind.config.ts | 7 ++
5 files changed, 203 insertions(+), 94 deletions(-)
create mode 100644 fix-more-colors.cjs
diff --git a/app/components/Songs/RequestForm.vue b/app/components/Songs/RequestForm.vue
index 8c494ae..5be91bd 100644
--- a/app/components/Songs/RequestForm.vue
+++ b/app/components/Songs/RequestForm.vue
@@ -2944,7 +2944,7 @@ defineExpose({
font-weight: 400;
font-size: 15px;
letter-spacing: 0.04em;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary);
margin-bottom: 0.75rem;
}
@@ -2968,7 +2968,7 @@ defineExpose({
gap: 0.5rem;
font-size: 15px;
font-weight: 700;
- color: rgba(255, 255, 255, 0.9);
+ color: var(--text-primary);
margin-bottom: 1.25rem;
}
@@ -2980,7 +2980,7 @@ defineExpose({
font-family: 'MiSans', sans-serif;
font-size: 13px;
line-height: 1.8;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--text-tertiary);
}
.rule-item {
@@ -2990,7 +2990,7 @@ defineExpose({
.rule-item span {
margin-right: 0.5rem;
- color: rgba(255, 255, 255, 0.3);
+ color: var(--text-disabled);
font-weight: 600;
}
@@ -3025,7 +3025,7 @@ defineExpose({
gap: 2px;
padding: 8px 10px;
border-radius: 12px;
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border-tertiary);
background: rgba(6, 15, 22, 0.9);
backdrop-filter: blur(8px);
min-width: 140px;
@@ -3066,7 +3066,7 @@ defineExpose({
.badge-title {
font-size: 12px;
font-weight: 800;
- color: rgba(255, 255, 255, 0.9);
+ color: var(--text-primary);
}
.submission-status-badge.open .badge-title {
@@ -3080,7 +3080,7 @@ defineExpose({
.badge-subtitle {
font-size: 11px;
font-weight: 600;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary);
}
.login-required-hint {
@@ -3136,7 +3136,7 @@ defineExpose({
min-width: 28px;
padding: 0 10px;
border-radius: 10px;
- border: 1px solid rgba(255, 255, 255, 0.12);
+ border: 1px solid var(--border-primary);
background: rgba(0, 0, 0, 0.25);
color: rgba(255, 255, 255, 0.85);
font-family: 'MiSans', sans-serif;
@@ -3277,7 +3277,7 @@ defineExpose({
.remove-collaborator {
background: none;
border: none;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
@@ -3292,7 +3292,7 @@ defineExpose({
display: flex;
align-items: center;
gap: 0.25rem;
- background: rgba(255, 255, 255, 0.05);
+ background: var(--bg-tertiary);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 6px;
padding: 0.25rem 0.75rem;
@@ -3474,7 +3474,7 @@ defineExpose({
font-weight: 600;
font-size: 15px;
letter-spacing: 0.02em;
- color: rgba(255, 255, 255, 0.9);
+ color: var(--text-primary);
margin-bottom: 0.25rem;
}
@@ -3606,7 +3606,7 @@ defineExpose({
gap: 0.25rem;
background: transparent;
border: none;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--text-tertiary);
padding: 0.2rem 0.4rem;
border-radius: 4px;
font-size: 11px;
@@ -3628,7 +3628,7 @@ defineExpose({
.import-btn {
background: rgba(255, 255, 255, 0.04);
color: rgba(255, 255, 255, 0.7);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border-tertiary);
padding: 0.45rem 0.75rem;
border-radius: 7px;
font-size: 12px;
@@ -3676,7 +3676,7 @@ defineExpose({
.user-name {
font-size: 12px;
- color: rgba(255, 255, 255, 0.9);
+ color: var(--text-primary);
font-weight: 600;
white-space: nowrap;
overflow: hidden;
@@ -3712,10 +3712,10 @@ defineExpose({
.search-type-switch {
display: flex;
- background: rgba(0, 0, 0, 0.2);
+ background: var(--bg-primary);
border-radius: 6px;
padding: 2px;
- border: 1px solid rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-secondary);
}
.radio-label {
@@ -3757,18 +3757,18 @@ defineExpose({
.logout-btn:hover {
color: rgba(255, 255, 255, 0.7);
- background: rgba(255, 255, 255, 0.05);
+ background: var(--bg-tertiary);
}
.platform-btn {
background: rgba(255, 255, 255, 0.08);
- border: 1px solid rgba(255, 255, 255, 0.12);
+ border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 0.45rem 0.85rem;
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 13px;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--text-tertiary);
cursor: pointer;
transition: all 0.3s ease;
white-space: nowrap;
@@ -3787,7 +3787,7 @@ defineExpose({
/* 音源状态显示 */
.source-status-display {
- background: rgba(255, 255, 255, 0.05);
+ background: var(--bg-tertiary);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 1rem;
@@ -3805,13 +3805,13 @@ defineExpose({
.status-title {
font-size: 14px;
font-weight: 600;
- color: rgba(255, 255, 255, 0.9);
+ color: var(--text-primary);
font-family: 'MiSans', sans-serif;
}
.status-summary {
font-size: 12px;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary);
background: rgba(255, 255, 255, 0.1);
padding: 0.25rem 0.5rem;
border-radius: 6px;
@@ -3954,7 +3954,7 @@ defineExpose({
}
.loading-text {
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary);
font-family: 'MiSans', sans-serif;
font-weight: 500;
margin: 0;
@@ -4037,7 +4037,7 @@ defineExpose({
.empty-hint,
.initial-hint {
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary);
font-size: 13px;
margin: 0;
}
@@ -4222,7 +4222,7 @@ defineExpose({
}
.alert-hint {
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary);
font-size: 14px;
margin-top: 0.5rem;
}
@@ -4385,7 +4385,7 @@ defineExpose({
align-items: center;
padding: 1.25rem 1.5rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
- background: rgba(255, 255, 255, 0.02);
+ background: var(--bg-secondary);
}
.modal-header h3 {
@@ -4449,7 +4449,7 @@ defineExpose({
}
.btn-secondary {
- background: rgba(255, 255, 255, 0.05);
+ background: var(--bg-tertiary);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
}
@@ -4516,7 +4516,7 @@ defineExpose({
}
.result-item:hover {
- background: rgba(255, 255, 255, 0.05);
+ background: var(--bg-tertiary);
}
.result-cover {
@@ -4625,7 +4625,7 @@ defineExpose({
}
.result-artist {
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary);
font-size: 13px;
margin: 0.25rem 0;
white-space: nowrap;
@@ -4693,7 +4693,7 @@ defineExpose({
.similar-text {
font-size: 12px;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary);
font-family: 'MiSans', sans-serif;
font-weight: 500;
}
@@ -4787,9 +4787,9 @@ defineExpose({
}
.replay-btn:disabled {
- background: rgba(255, 255, 255, 0.05);
+ background: var(--bg-tertiary);
border-color: rgba(255, 255, 255, 0.1);
- color: rgba(255, 255, 255, 0.3);
+ color: var(--text-disabled);
cursor: not-allowed;
transform: none;
}
@@ -4957,14 +4957,14 @@ defineExpose({
padding: 1.25rem;
background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border-tertiary);
border-radius: 18px;
}
.rules-title {
font-size: 15px;
font-weight: 700;
- color: rgba(255, 255, 255, 0.9);
+ color: var(--text-primary);
margin-bottom: 1.25rem;
letter-spacing: normal;
}
@@ -5009,9 +5009,9 @@ defineExpose({
display: flex;
flex-direction: column;
margin-bottom: 2rem;
- background: rgba(255, 255, 255, 0.02);
+ background: var(--bg-secondary);
backdrop-filter: blur(20px);
- border: 1px solid rgba(255, 255, 255, 0.08);
+ border: 1px solid var(--border-tertiary);
border-radius: 20px;
/* 允许容器内容触发页面滚动 */
touch-action: pan-y;
@@ -5103,7 +5103,7 @@ defineExpose({
}
.search-input {
- background: rgba(255, 255, 255, 0.05);
+ background: var(--bg-tertiary);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 12px;
padding: 0.75rem 1rem;
@@ -5128,7 +5128,7 @@ defineExpose({
}
.platform-selection {
- background: rgba(0, 0, 0, 0.2);
+ background: var(--bg-primary);
padding: 4px;
border-radius: 12px;
margin-bottom: 0.5rem;
@@ -5143,7 +5143,7 @@ defineExpose({
border-radius: 10px;
background: transparent;
border: none;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--text-tertiary);
min-width: auto;
}
@@ -5471,7 +5471,7 @@ defineExpose({
align-items: center;
gap: 0.4rem;
background: rgba(255, 255, 255, 0.08);
- border: 1px solid rgba(255, 255, 255, 0.12);
+ border: 1px solid var(--border-primary);
border-radius: 8px;
padding: 0.6rem 0.8rem;
color: rgba(255, 255, 255, 0.8);
@@ -5520,7 +5520,7 @@ defineExpose({
.video-author {
font-family: 'MiSans', sans-serif;
font-size: 14px;
- color: rgba(255, 255, 255, 0.6);
+ color: var(--text-secondary);
}
.episodes-list {
@@ -5535,7 +5535,7 @@ defineExpose({
align-items: center;
gap: 1rem;
padding: 1rem;
- background: rgba(255, 255, 255, 0.05);
+ background: var(--bg-tertiary);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
cursor: pointer;
@@ -5577,6 +5577,6 @@ defineExpose({
.episode-duration {
font-family: 'MiSans', sans-serif;
font-size: 12px;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--text-tertiary);
}
</style>
diff --git a/app/components/Songs/ScheduleList.vue b/app/components/Songs/ScheduleList.vue
index 9ac7c4f..1d56e3c 100644
--- a/app/components/Songs/ScheduleList.vue
+++ b/app/components/Songs/ScheduleList.vue
@@ -106,7 +106,7 @@
<div v-else-if="!schedules || schedules.length === 0" key="empty-all" class="empty">
<div class="icon mb-4">🎵</div>
<p>暂无排期信息</p>
- <p class="text-sm text-gray">点歌后等待管理员安排播出时间</p>
+ <p class="text-sm text-text-secondary">点歌后等待管理员安排播出时间</p>
</div>
<div v-else-if="currentDateSchedules.length === 0" key="empty-date" class="empty">
@@ -1600,15 +1600,15 @@ const vRipple = {
align-items: center;
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
- background: linear-gradient(135deg, rgba(11, 90, 254, 0.1) 0%, rgba(33, 36, 45, 0.9) 100%);
+ background: var(--bg-secondary);
border-radius: 12px;
- border: 1px solid rgba(11, 90, 254, 0.2);
+ border: 1px solid var(--border-secondary);
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.semester-label {
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
font-size: 15px;
font-weight: 500;
margin-right: 0.75rem;
@@ -1617,10 +1617,10 @@ const vRipple = {
}
.semester-select {
- background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%);
- border: 1px solid rgba(255, 255, 255, 0.25);
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-tertiary);
border-radius: 8px;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
padding: 0.6rem 1rem;
font-size: 14px;
font-weight: 500;
@@ -1649,7 +1649,7 @@ const vRipple = {
.semester-select option {
background: var(--bg-secondary, #1a1d24);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
padding: 0.5rem;
}
@@ -1710,7 +1710,7 @@ const vRipple = {
.date-item.active {
background: var(--bg-tertiary, #21242d);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
font-weight: 600;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(11, 90, 254, 0.2);
@@ -1775,7 +1775,7 @@ const vRipple = {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 20px;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
margin: 0;
}
@@ -1886,7 +1886,7 @@ const vRipple = {
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.1);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
@@ -1964,7 +1964,7 @@ const vRipple = {
margin: 0;
font-size: 18px;
font-weight: 600;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
letter-spacing: 0.5px;
}
@@ -1984,7 +1984,7 @@ const vRipple = {
.playlist-modal-close:hover {
background: rgba(255, 255, 255, 0.1);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
transform: rotate(90deg);
}
@@ -2087,7 +2087,7 @@ const vRipple = {
.user-name {
font-weight: 600;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
font-size: 15px;
}
@@ -2163,7 +2163,7 @@ const vRipple = {
width: 100%;
background: var(--bg-secondary, #141418);
border: 1px solid rgba(255, 255, 255, 0.1);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
padding: 0.75rem 1rem;
border-radius: 10px;
font-size: 14px;
@@ -2210,7 +2210,7 @@ const vRipple = {
.btn-icon:hover {
background: rgba(255, 255, 255, 0.1);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
}
.btn-icon:disabled {
@@ -2421,7 +2421,7 @@ const vRipple = {
.song-name {
font-size: 14px;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -2469,7 +2469,7 @@ const vRipple = {
.btn-primary {
background: var(--primary);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25);
}
@@ -2488,7 +2488,7 @@ const vRipple = {
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
}
.btn-secondary:hover {
@@ -2502,7 +2502,7 @@ const vRipple = {
.btn-ghost:hover {
background: rgba(255, 255, 255, 0.05);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
}
/* 动画定义 */
@@ -2537,7 +2537,7 @@ const vRipple = {
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.1);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
cursor: pointer;
margin-right: 0.5rem;
backdrop-filter: blur(10px);
@@ -2662,7 +2662,7 @@ const vRipple = {
align-items: center;
justify-content: center;
background: linear-gradient(135deg, var(--bg-secondary, #0043f8) 0%, #0075f8 100%);
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
font-size: 28px;
font-weight: bold;
font-family: 'MiSans', sans-serif;
@@ -2725,7 +2725,7 @@ const vRipple = {
font-weight: 600;
font-size: 16px;
letter-spacing: 0.04em;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
margin-bottom: 0.5rem;
display: flex;
align-items: flex-start;
@@ -2824,7 +2824,7 @@ const vRipple = {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 12px;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
opacity: 0.4;
}
@@ -2919,7 +2919,7 @@ const vRipple = {
.date-text {
font-size: 15px;
font-weight: 600;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
white-space: nowrap;
text-shadow:
0 0 10px rgba(255, 255, 255, 0.2),
@@ -2928,7 +2928,7 @@ const vRipple = {
.dropdown-icon {
opacity: 0.5;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
}
.mobile-action-btn {
@@ -3118,7 +3118,7 @@ const vRipple = {
font-weight: 600;
margin-bottom: 2px;
line-height: 1.4;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
letter-spacing: 0.01em;
}
@@ -3378,8 +3378,8 @@ const vRipple = {
.date-nav-btn {
background: rgba(11, 90, 254, 0.1);
- border: 1px solid rgba(11, 90, 254, 0.2);
- color: var(--text-primary, #ffffff);
+ border: 1px solid var(--border-secondary);
+ color: var(--text-primary);
border-radius: 50%;
width: 36px;
height: 36px;
@@ -3403,7 +3403,7 @@ const vRipple = {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
- color: var(--text-primary, #ffffff);
+ color: var(--text-primary);
text-align: center;
flex: 1;
white-space: pre-line;
diff --git a/app/components/UserTutorial.vue b/app/components/UserTutorial.vue
index c42bed2..97f0d43 100644
--- a/app/components/UserTutorial.vue
+++ b/app/components/UserTutorial.vue
@@ -2,11 +2,11 @@
<div class="user-tutorial-container relative">
<div class="tutorial-header flex justify-between items-start">
<div>
- <h3 class="text-lg font-black text-zinc-100 flex items-center gap-2">
- <BookOpen class="w-5 h-5 text-blue-400" />
+ <h3 class="text-lg font-black text-text-primary flex items-center gap-2">
+ <BookOpen class="w-5 h-5 text-primary" />
使用指南
</h3>
- <p class="text-xs text-zinc-500 mt-1">快速了解如何使用 VoiceHub 校园广播系统</p>
+ <p class="text-xs text-text-secondary mt-1">快速了解如何使用 VoiceHub 校园广播系统</p>
</div>
<button
class="start-tour-btn"
@@ -19,7 +19,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div class="tutorial-card">
- <div class="card-icon bg-blue-500/10 text-blue-400">
+ <div class="card-icon bg-primary/10 text-primary">
<Search class="w-6 h-6" />
</div>
<h4 class="card-title">1. 搜索与投稿</h4>
@@ -27,7 +27,7 @@
</div>
<div class="tutorial-card">
- <div class="card-icon bg-emerald-500/10 text-emerald-400">
+ <div class="card-icon bg-success/10 text-success">
<ThumbsUp class="w-6 h-6" />
</div>
<h4 class="card-title">2. 互动与点赞</h4>
@@ -35,7 +35,7 @@
</div>
<div class="tutorial-card">
- <div class="card-icon bg-purple-500/10 text-purple-400">
+ <div class="card-icon bg-[#a855f7]/10 text-[#a855f7]">
<CalendarDays class="w-6 h-6" />
</div>
<h4 class="card-title">3. 关注排期与通知</h4>
@@ -276,25 +276,25 @@ onMounted(() => {
<style scoped>
.user-tutorial-container {
- background: rgba(0, 0, 0, 0.4);
+ background: var(--bg-secondary, rgba(0, 0, 0, 0.4));
border-radius: 16px;
padding: 1.5rem;
margin-top: 1.5rem;
- border: 1px solid rgba(255, 255, 255, 0.05);
+ border: 1px solid var(--border-secondary, rgba(255, 255, 255, 0.05));
backdrop-filter: blur(10px);
}
.tutorial-card {
- background: rgba(255, 255, 255, 0.02);
- border: 1px solid rgba(255, 255, 255, 0.05);
+ background: var(--bg-tertiary, rgba(255, 255, 255, 0.02));
+ border: 1px solid var(--border-secondary, rgba(255, 255, 255, 0.05));
border-radius: 12px;
padding: 1.25rem;
transition: all 0.3s ease;
}
.tutorial-card:hover {
- background: rgba(255, 255, 255, 0.04);
- border-color: rgba(255, 255, 255, 0.1);
+ background: var(--bg-hover, rgba(255, 255, 255, 0.04));
+ border-color: var(--border-tertiary, rgba(255, 255, 255, 0.1));
transform: translateY(-2px);
}
@@ -312,14 +312,14 @@ onMounted(() => {
font-family: 'MiSans', sans-serif;
font-weight: 800;
font-size: 0.95rem;
- color: rgba(255, 255, 255, 0.9);
+ color: var(--text-primary, rgba(255, 255, 255, 0.9));
margin-bottom: 0.5rem;
}
.card-desc {
font-family: 'MiSans', sans-serif;
font-size: 0.8rem;
- color: rgba(255, 255, 255, 0.5);
+ color: var(--text-secondary, rgba(255, 255, 255, 0.5));
line-height: 1.6;
}
@@ -387,14 +387,14 @@ onMounted(() => {
.tour-title {
font-size: 1.1rem;
font-weight: 800;
- color: #f4f4f5;
+ color: var(--text-primary);
margin: 0;
}
.tour-close-btn {
background: none;
border: none;
- color: #71717a;
+ color: var(--text-tertiary);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
@@ -403,11 +403,11 @@ onMounted(() => {
}
.tour-close-btn:hover {
- color: #f4f4f5;
+ color: var(--text-primary);
}
.tour-body p {
- color: #a1a1aa;
+ color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
margin: 0 0 1rem 0;
@@ -420,7 +420,7 @@ onMounted(() => {
}
.tour-progress {
- color: #71717a;
+ color: var(--text-tertiary);
font-size: 0.8rem;
font-weight: 600;
}
@@ -441,7 +441,7 @@ onMounted(() => {
.tour-btn-secondary {
background: var(--bg-tertiary, #27272a);
- color: #d4d4d8;
+ color: var(--text-primary);
border: 1px solid var(--border-secondary, #3f3f46);
}
@@ -451,7 +451,7 @@ onMounted(() => {
.tour-btn-primary {
background: #3b82f6;
- color: white;
+ color: var(--btn-primary-text);
border: none;
}
diff --git a/fix-more-colors.cjs b/fix-more-colors.cjs
new file mode 100644
index 0000000..1cea7e6
--- /dev/null
+++ b/fix-more-colors.cjs
@@ -0,0 +1,102 @@
+const fs = require('fs');
+const path = require('path');
+
+function replaceInFile(filePath) {
+ let content = fs.readFileSync(filePath, 'utf8');
+
+ // In UserTutorial.vue
+ if (filePath.includes('UserTutorial.vue')) {
+ content = content.replace(/text-zinc-100/g, 'text-text-primary');
+ content = content.replace(/text-zinc-500/g, 'text-text-secondary');
+ content = content.replace(/text-blue-400/g, 'text-primary');
+ content = content.replace(/bg-blue-500\/10/g, 'bg-primary\/10');
+ content = content.replace(/text-emerald-400/g, 'text-success');
+ content = content.replace(/bg-emerald-500\/10/g, 'bg-success\/10');
+ content = content.replace(/text-purple-400/g, 'text-[#a855f7]'); // Use exact hex or leave it, maybe text-purple-500
+ content = content.replace(/bg-purple-500\/10/g, 'bg-[#a855f7]\/10');
+ content = content.replace(/rgba\(0,\s*0,\s*0,\s*0\.4\)/g, 'var(--bg-secondary, rgba(0, 0, 0, 0.4))');
+ content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.05\)/g, 'var(--border-secondary, rgba(255, 255, 255, 0.05))');
+ content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.02\)/g, 'var(--bg-tertiary, rgba(255, 255, 255, 0.02))');
+ content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.04\)/g, 'var(--bg-hover, rgba(255, 255, 255, 0.04))');
+ content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.1\)/g, 'var(--border-tertiary, rgba(255, 255, 255, 0.1))');
+ content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.9\)/g, 'var(--text-primary, rgba(255, 255, 255, 0.9))');
+ content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.5\)/g, 'var(--text-secondary, rgba(255, 255, 255, 0.5))');
+
+ // Fix `.tour-popover` which has hardcoded text colors in p tags
+ content = content.replace(/color:\s*#f4f4f5;/g, 'color: var(--text-primary);');
+ content = content.replace(/color:\s*#a1a1aa;/g, 'color: var(--text-secondary);');
+ content = content.replace(/color:\s*#71717a;/g, 'color: var(--text-tertiary);');
+ content = content.replace(/color:\s*#d4d4d8;/g, 'color: var(--text-primary);');
+ content = content.replace(/color:\s*white;/g, 'color: var(--btn-primary-text);');
+ }
+
+ // In ScheduleList.vue
+ if (filePath.includes('ScheduleList.vue')) {
+ content = content.replace(/class="text-sm text-gray"/g, 'class="text-sm text-text-secondary"');
+ content = content.replace(/background: linear-gradient\(135deg, rgba\(11, 90, 254, 0\.1\) 0%, rgba\(33, 36, 45, 0\.9\) 100%\);/g, 'background: var(--bg-secondary);');
+ content = content.replace(/border: 1px solid rgba\(11, 90, 254, 0\.2\);/g, 'border: 1px solid var(--border-secondary);');
+ content = content.replace(/background: linear-gradient\(135deg, rgba\(255, 255, 255, 0\.15\) 0%, rgba\(255, 255, 255, 0\.08\) 100%\);/g, 'background: var(--bg-tertiary);');
+ content = content.replace(/border: 1px solid rgba\(255, 255, 255, 0\.25\);/g, 'border: 1px solid var(--border-tertiary);');
+ content = content.replace(/color: var\(--text-primary, #ffffff\);/g, 'color: var(--text-primary);');
+ // schedule-content empty state
+ // The .empty rule in ScheduleList.vue:
+ content = content.replace(/\.empty \{[\s\S]*?\}/g, (match) => {
+ return match.replace(/color:\s*rgba\(255, 255, 255, 0.5\);/, 'color: var(--text-secondary);')
+ .replace(/color:\s*#a0a0a0;/, 'color: var(--text-secondary);');
+ });
+ content = content.replace(/\.empty-dates \{[\s\S]*?\}/g, (match) => {
+ return match.replace(/color:\s*rgba\(255, 255, 255, 0.5\);/, 'color: var(--text-secondary);');
+ });
+ }
+
+ // In RequestForm.vue
+ if (filePath.includes('RequestForm.vue')) {
+ // search-results-container
+ content = content.replace(/background:\s*rgba\(255, 255, 255, 0.02\);/g, 'background: var(--bg-secondary);');
+ content = content.replace(/border:\s*1px solid rgba\(255, 255, 255, 0.05\);/g, 'border: 1px solid var(--border-secondary);');
+ content = content.replace(/background:\s*rgba\(255, 255, 255, 0.05\);/g, 'background: var(--bg-tertiary);');
+ content = content.replace(/color:\s*rgba\(255, 255, 255, 0.9\);/g, 'color: var(--text-primary);');
+ content = content.replace(/color:\s*rgba\(255, 255, 255, 0.6\);/g, 'color: var(--text-secondary);');
+ content = content.replace(/color:\s*rgba\(255, 255, 255, 0.5\);/g, 'color: var(--text-tertiary);');
+ content = content.replace(/color:\s*rgba\(255, 255, 255, 0.3\);/g, 'color: var(--text-disabled);');
+ content = content.replace(/border:\s*1px solid rgba\(255, 255, 255, 0.08\);/g, 'border: 1px solid var(--border-tertiary);');
+ content = content.replace(/border:\s*1px solid rgba\(255, 255, 255, 0.12\);/g, 'border: 1px solid var(--border-primary);');
+ content = content.replace(/background:\s*rgba\(0, 0, 0, 0.2\);/g, 'background: var(--bg-primary);');
+
+ // submission-status-badge
+ content = content.replace(/\.submission-status-badge\.open \{[\s\S]*?\}/g, (match) => {
+ return match.replace(/background:\s*rgba\(16, 185, 129, 0.1\);/g, 'background: var(--success-light);')
+ .replace(/border:\s*1px solid rgba\(16, 185, 129, 0.2\);/g, 'border: 1px solid var(--success-border);')
+ .replace(/color:\s*#34d399;/g, 'color: var(--success);');
+ });
+ content = content.replace(/\.submission-status-badge\.closed \{[\s\S]*?\}/g, (match) => {
+ return match.replace(/background:\s*rgba\(239, 68, 68, 0.1\);/g, 'background: var(--error-light);')
+ .replace(/border:\s*1px solid rgba\(239, 68, 68, 0.2\);/g, 'border: 1px solid var(--error-border);')
+ .replace(/color:\s*#f87171;/g, 'color: var(--error);');
+ });
+ content = content.replace(/\.badge-title \{[\s\S]*?\}/g, (match) => {
+ return match.replace(/color:\s*rgba\(255, 255, 255, 0.9\);/, 'color: var(--text-primary);');
+ });
+
+ // Rules Section
+ content = content.replace(/\.rules-section \{[\s\S]*?\}/g, (match) => {
+ return match.replace(/background:\s*rgba\(255, 255, 255, 0.02\);/, 'background: var(--bg-secondary);')
+ .replace(/border:\s*1px solid rgba\(255, 255, 255, 0.05\);/, 'border: 1px solid var(--border-secondary);');
+ });
+ content = content.replace(/\.section-title \{[\s\S]*?\}/g, (match) => {
+ return match.replace(/color:\s*rgba\(255, 255, 255, 0.9\);/, 'color: var(--text-primary);');
+ });
+ content = content.replace(/\.guidelines-content \{[\s\S]*?\}/g, (match) => {
+ return match.replace(/color:\s*rgba\(255, 255, 255, 0.6\);/, 'color: var(--text-secondary);')
+ .replace(/background:\s*rgba\(255, 255, 255, 0.02\);/, 'background: var(--bg-tertiary);')
+ .replace(/border:\s*1px solid rgba\(255, 255, 255, 0.05\);/, 'border: 1px solid var(--border-tertiary);');
+ });
+ }
+
+ fs.writeFileSync(filePath, content, 'utf8');
+ console.log(`Processed ${filePath}`);
+}
+
+replaceInFile('app/components/UserTutorial.vue');
+replaceInFile('app/components/Songs/ScheduleList.vue');
+replaceInFile('app/components/Songs/RequestForm.vue');
diff --git a/tailwind.config.ts b/tailwind.config.ts
index ffe4216..444aa45 100644
--- a/tailwind.config.ts
+++ b/tailwind.config.ts
@@ -18,6 +18,13 @@ export default <Config>{
warning: 'var(--warning)',
error: 'var(--error)',
info: 'var(--info)',
+ text: {
+ primary: 'var(--text-primary)',
+ secondary: 'var(--text-secondary)',
+ tertiary: 'var(--text-tertiary)',
+ quaternary: 'var(--text-quaternary)',
+ disabled: 'var(--text-disabled)'
+ },
surface: {
DEFAULT: 'var(--bg-primary)',
muted: 'var(--bg-secondary)',
--
2.45.1.windows.1
+121
View File
@@ -0,0 +1,121 @@
From a0f11ff64a193fd3ecd40a0f04d70953419268f1 Mon Sep 17 00:00:00 2001
From: HenryDu8133 <813367384@qq.com>
Date: Sat, 12 Sep 2026 16:57:53 +0800
Subject: [PATCH 58/58] chore: remove script
---
fix-more-colors.cjs | 102 --------------------------------------------
1 file changed, 102 deletions(-)
delete mode 100644 fix-more-colors.cjs
diff --git a/fix-more-colors.cjs b/fix-more-colors.cjs
deleted file mode 100644
index 1cea7e6..0000000
--- a/fix-more-colors.cjs
+++ /dev/null
@@ -1,102 +0,0 @@
-const fs = require('fs');
-const path = require('path');
-
-function replaceInFile(filePath) {
- let content = fs.readFileSync(filePath, 'utf8');
-
- // In UserTutorial.vue
- if (filePath.includes('UserTutorial.vue')) {
- content = content.replace(/text-zinc-100/g, 'text-text-primary');
- content = content.replace(/text-zinc-500/g, 'text-text-secondary');
- content = content.replace(/text-blue-400/g, 'text-primary');
- content = content.replace(/bg-blue-500\/10/g, 'bg-primary\/10');
- content = content.replace(/text-emerald-400/g, 'text-success');
- content = content.replace(/bg-emerald-500\/10/g, 'bg-success\/10');
- content = content.replace(/text-purple-400/g, 'text-[#a855f7]'); // Use exact hex or leave it, maybe text-purple-500
- content = content.replace(/bg-purple-500\/10/g, 'bg-[#a855f7]\/10');
- content = content.replace(/rgba\(0,\s*0,\s*0,\s*0\.4\)/g, 'var(--bg-secondary, rgba(0, 0, 0, 0.4))');
- content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.05\)/g, 'var(--border-secondary, rgba(255, 255, 255, 0.05))');
- content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.02\)/g, 'var(--bg-tertiary, rgba(255, 255, 255, 0.02))');
- content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.04\)/g, 'var(--bg-hover, rgba(255, 255, 255, 0.04))');
- content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.1\)/g, 'var(--border-tertiary, rgba(255, 255, 255, 0.1))');
- content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.9\)/g, 'var(--text-primary, rgba(255, 255, 255, 0.9))');
- content = content.replace(/rgba\(255,\s*255,\s*255,\s*0\.5\)/g, 'var(--text-secondary, rgba(255, 255, 255, 0.5))');
-
- // Fix `.tour-popover` which has hardcoded text colors in p tags
- content = content.replace(/color:\s*#f4f4f5;/g, 'color: var(--text-primary);');
- content = content.replace(/color:\s*#a1a1aa;/g, 'color: var(--text-secondary);');
- content = content.replace(/color:\s*#71717a;/g, 'color: var(--text-tertiary);');
- content = content.replace(/color:\s*#d4d4d8;/g, 'color: var(--text-primary);');
- content = content.replace(/color:\s*white;/g, 'color: var(--btn-primary-text);');
- }
-
- // In ScheduleList.vue
- if (filePath.includes('ScheduleList.vue')) {
- content = content.replace(/class="text-sm text-gray"/g, 'class="text-sm text-text-secondary"');
- content = content.replace(/background: linear-gradient\(135deg, rgba\(11, 90, 254, 0\.1\) 0%, rgba\(33, 36, 45, 0\.9\) 100%\);/g, 'background: var(--bg-secondary);');
- content = content.replace(/border: 1px solid rgba\(11, 90, 254, 0\.2\);/g, 'border: 1px solid var(--border-secondary);');
- content = content.replace(/background: linear-gradient\(135deg, rgba\(255, 255, 255, 0\.15\) 0%, rgba\(255, 255, 255, 0\.08\) 100%\);/g, 'background: var(--bg-tertiary);');
- content = content.replace(/border: 1px solid rgba\(255, 255, 255, 0\.25\);/g, 'border: 1px solid var(--border-tertiary);');
- content = content.replace(/color: var\(--text-primary, #ffffff\);/g, 'color: var(--text-primary);');
- // schedule-content empty state
- // The .empty rule in ScheduleList.vue:
- content = content.replace(/\.empty \{[\s\S]*?\}/g, (match) => {
- return match.replace(/color:\s*rgba\(255, 255, 255, 0.5\);/, 'color: var(--text-secondary);')
- .replace(/color:\s*#a0a0a0;/, 'color: var(--text-secondary);');
- });
- content = content.replace(/\.empty-dates \{[\s\S]*?\}/g, (match) => {
- return match.replace(/color:\s*rgba\(255, 255, 255, 0.5\);/, 'color: var(--text-secondary);');
- });
- }
-
- // In RequestForm.vue
- if (filePath.includes('RequestForm.vue')) {
- // search-results-container
- content = content.replace(/background:\s*rgba\(255, 255, 255, 0.02\);/g, 'background: var(--bg-secondary);');
- content = content.replace(/border:\s*1px solid rgba\(255, 255, 255, 0.05\);/g, 'border: 1px solid var(--border-secondary);');
- content = content.replace(/background:\s*rgba\(255, 255, 255, 0.05\);/g, 'background: var(--bg-tertiary);');
- content = content.replace(/color:\s*rgba\(255, 255, 255, 0.9\);/g, 'color: var(--text-primary);');
- content = content.replace(/color:\s*rgba\(255, 255, 255, 0.6\);/g, 'color: var(--text-secondary);');
- content = content.replace(/color:\s*rgba\(255, 255, 255, 0.5\);/g, 'color: var(--text-tertiary);');
- content = content.replace(/color:\s*rgba\(255, 255, 255, 0.3\);/g, 'color: var(--text-disabled);');
- content = content.replace(/border:\s*1px solid rgba\(255, 255, 255, 0.08\);/g, 'border: 1px solid var(--border-tertiary);');
- content = content.replace(/border:\s*1px solid rgba\(255, 255, 255, 0.12\);/g, 'border: 1px solid var(--border-primary);');
- content = content.replace(/background:\s*rgba\(0, 0, 0, 0.2\);/g, 'background: var(--bg-primary);');
-
- // submission-status-badge
- content = content.replace(/\.submission-status-badge\.open \{[\s\S]*?\}/g, (match) => {
- return match.replace(/background:\s*rgba\(16, 185, 129, 0.1\);/g, 'background: var(--success-light);')
- .replace(/border:\s*1px solid rgba\(16, 185, 129, 0.2\);/g, 'border: 1px solid var(--success-border);')
- .replace(/color:\s*#34d399;/g, 'color: var(--success);');
- });
- content = content.replace(/\.submission-status-badge\.closed \{[\s\S]*?\}/g, (match) => {
- return match.replace(/background:\s*rgba\(239, 68, 68, 0.1\);/g, 'background: var(--error-light);')
- .replace(/border:\s*1px solid rgba\(239, 68, 68, 0.2\);/g, 'border: 1px solid var(--error-border);')
- .replace(/color:\s*#f87171;/g, 'color: var(--error);');
- });
- content = content.replace(/\.badge-title \{[\s\S]*?\}/g, (match) => {
- return match.replace(/color:\s*rgba\(255, 255, 255, 0.9\);/, 'color: var(--text-primary);');
- });
-
- // Rules Section
- content = content.replace(/\.rules-section \{[\s\S]*?\}/g, (match) => {
- return match.replace(/background:\s*rgba\(255, 255, 255, 0.02\);/, 'background: var(--bg-secondary);')
- .replace(/border:\s*1px solid rgba\(255, 255, 255, 0.05\);/, 'border: 1px solid var(--border-secondary);');
- });
- content = content.replace(/\.section-title \{[\s\S]*?\}/g, (match) => {
- return match.replace(/color:\s*rgba\(255, 255, 255, 0.9\);/, 'color: var(--text-primary);');
- });
- content = content.replace(/\.guidelines-content \{[\s\S]*?\}/g, (match) => {
- return match.replace(/color:\s*rgba\(255, 255, 255, 0.6\);/, 'color: var(--text-secondary);')
- .replace(/background:\s*rgba\(255, 255, 255, 0.02\);/, 'background: var(--bg-tertiary);')
- .replace(/border:\s*1px solid rgba\(255, 255, 255, 0.05\);/, 'border: 1px solid var(--border-tertiary);');
- });
- }
-
- fs.writeFileSync(filePath, content, 'utf8');
- console.log(`Processed ${filePath}`);
-}
-
-replaceInFile('app/components/UserTutorial.vue');
-replaceInFile('app/components/Songs/ScheduleList.vue');
-replaceInFile('app/components/Songs/RequestForm.vue');
--
2.45.1.windows.1