diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..5f46f56 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,698 @@ +name: 构建并部署 BYW VoiceHub +run-name: ${{ github.actor }} 触发部署 · Run ${{ github.run_number }} + +on: + push: + branches: [main, master] + workflow_dispatch: + inputs: + deploy_target: + 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: true + +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 + 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: "" + # ========== 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 + # 关键修复 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" + NODE_OPTIONS: "--max-old-space-size=10240 --no-warnings --expose-gc" + NITRO_PRESET: "node-server" + DISABLE_ROLLDOWN: "1" + NITRO_MINIFY: "false" + steps: + - 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 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 + 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' "$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}" + 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: | + set -euxo pipefail + # 全局 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 + + - 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: | + set -euxo pipefail + npm config set registry https://registry.npmmirror.com || true + npm config set fund false + npm config set audit false + npm ci --legacy-peer-deps + + - 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 + run: | + 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=10240 --no-warnings --expose-gc" + 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: 构建并推送容器镜像 + 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: 系统环境准备(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|'"${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|'"${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 + # 全局 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 + + # ========== 关键修复:宿主机 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 + OLD_MIRRORS_JSON="{}" + if [ -f "${DOCKER_DAEMON_CONF}" ]; then + OLD_MIRRORS_JSON="$(sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || echo "{}")" + fi + 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:" + 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 + docker buildx version || echo "buildx 未安装" + + - 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 + docker version + docker buildx version || echo "buildx 未安装,将使用 docker build --push" + + - name: 计算镜像标签 + 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}/${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 + 内置 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)" + # 用 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\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 + 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 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}" \ + --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 "image=${BUILDKIT_IMAGE}" + rm -f "${BKK_CFG}" + # 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 + echo "Builder 启动成功,buildkitd.toml 已内置 ${GITEA_HOST} Basic Auth 凭据,push 不再走 OAuth2 flow" + + - name: 确认已登录(凭据已注入 buildkitd.toml 与宿主 Docker daemon) + run: | + set -euo pipefail + # 前面「初始化 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: + SKIP_CACHE: ${{ inputs.skip_build_cache }} + IMG_FULL: ${{ steps.meta.outputs.full }} + IMG_LATEST: ${{ steps.meta.outputs.latest }} + OCS_SOURCE_RAW: ${{ github.server_url }}/${{ github.repository }} + OCS_REVISION: ${{ github.sha }} + 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,,}" + + 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 速度几乎相同 + 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 \ + --load \ + --provenance=false \ + "${BUILD_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}" \ + . + + # 断言:两个 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 }} + run: | + set -euxo pipefail + 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 "${IMG_FULL}" 2>/dev/null | head -20 || true + + deploy: + name: 部署到生产服务器 + needs: build-image + if: ${{ !cancelled() && !failure() && (github.event_name == 'push' || inputs.deploy_target == true) }} + runs-on: ubuntu-latest + timeout-minutes: 25 + environment: + name: production + url: ${{ secrets.NUXT_PUBLIC_SITE_URL }} + steps: + - name: 校验部署所需密钥 + env: + DEPLOY_HOST: ${{ secrets.DEPLOY_HOST }} + DEPLOY_USER: ${{ secrets.DEPLOY_USER }} + DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }} + run: | + set -euo pipefail + MISSING=() + [ -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 仓库 -> 设置 -> 工作流 -> 密钥 页面添加(注意是 Repository Secrets,不是 Deploy Keys)" + exit 1 + fi + echo "部署密钥配置完整(DEPLOY_HOST=${DEPLOY_HOST},DEPLOY_USER=${DEPLOY_USER})" + + - name: SSH 滚动部署 + 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 + # 全小写规范(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)" + 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" + + # 关键修复:远端 bash 前置 ENV=val 语法在 dash/sh 远端不认,变量全空。 + # 用 printf + \n 拼接完整 shell 脚本通过 stdin 注入到 ssh 远端 cat, + # 不用 heredoc,避免 YAML 冒号/缩进解析误判。 + ENV_SH="/tmp/deploy_env_$$" + 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 远端 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 + # shellcheck disable=SC1090 + . "$ENV_SH" + rm -f "$ENV_SH" + fi + + DEPLOY_DIR="${DEPLOY_PATH}" + COMPOSE="docker compose" + + echo "==============================================" + echo "Executor : $(whoami)" + 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}" + + # ========== 目录可写性预检查(权限排查友好化) ========== + 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}" \ + | docker login "${GITEA_HOST}" \ + -u "${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" + + # 用 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 前缀)" + 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)" + fi + + if [ ! -f .env ]; then + echo "=================================================================" + echo "WARNING:${DEPLOY_DIR}/.env 不存在" + 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 + + 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 + + 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: \\n' > docker-compose.override.yml" + echo " docker compose up -d" + echo "=================================================================" + REMOTE_SCRIPT_MAIN + + # 清理私钥临时文件 + rm -f "${SSH_KEY_FILE}" diff --git a/app/assets/css/lyric-player.module.css b/app/assets/css/lyric-player.module.css index 9c8d703..6e35018 100644 --- a/app/assets/css/lyric-player.module.css +++ b/app/assets/css/lyric-player.module.css @@ -101,8 +101,8 @@ padding: 1em; /* 为主歌词行增加文字阴影,以提升亮色背景下的可读性 */ text-shadow: - 0 2px 6px var(--mask-60), - 0 0 10px var(--mask-50); + 0 2px 6px rgba(0, 0, 0, 0.6), + 0 0 10px rgba(0, 0, 0, 0.5); & span { display: inline-block; @@ -143,7 +143,7 @@ transition: opacity 0.2s 0.25s; opacity: 0.3; /* 为副歌词行增加轻度文字阴影,避免白字贴白背景 */ - text-shadow: 0 1px 3px var(--mask-50); + text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5); } .disableSpring > * { diff --git a/app/assets/css/main.css b/app/assets/css/main.css index 2b3a7d1..69b6bb1 100644 --- a/app/assets/css/main.css +++ b/app/assets/css/main.css @@ -1,34 +1,21 @@ /* 导入浏览器主题保护样式 */ @import url('./theme-protection.css'); -/* 导入主题变量(亮色覆盖暗色,由 data-theme 控制) */ -@import url('./themes/ClassicDark.css'); -@import url('./themes/ClassicLight.css'); -@import url('./themes/ModernLight.css'); - -@unocss; +@tailwind base; +@tailwind components; +@tailwind utilities; /* 防止浏览器自动应用深色主题 */ :root { color-scheme: light dark; } -/* 全局控件默认强调色 — 跟随主题主色调,但允许显式 accent-* 类覆盖 */ -input[type="checkbox"]:not([class*="accent-"]), -input[type="radio"]:not([class*="accent-"]), -input[type="range"]:not([class*="accent-"]) { - accent-color: var(--accent-color); -} - /* 流体背景与全局样式 */ * { margin: 0; padding: 0; box-sizing: border-box; - /* UnoCSS preflight 未重置 border-style,补齐后 utility 边框与加载转圈才能渲染 */ - border-style: solid; - border-width: 0; } /* 字体显示策略优化 */ @@ -95,9 +82,9 @@ body::before { width: 100%; height: 100%; background: - radial-gradient(circle at 20% 50%, rgba(var(--color-indigo-rgb), 0.03) 0%, transparent 50%), - radial-gradient(circle at 80% 20%, var(--bg-texture-warm-glow) 0%, transparent 50%), - radial-gradient(circle at 40% 80%, rgba(var(--color-indigo-rgb), 0.02) 0%, transparent 50%); + radial-gradient(circle at 20% 50%, rgba(120, 119, 198, 0.03) 0%, transparent 50%), + radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.01) 0%, transparent 50%), + radial-gradient(circle at 40% 80%, rgba(120, 119, 198, 0.02) 0%, transparent 50%); z-index: -1; pointer-events: none; } @@ -246,9 +233,9 @@ body::before { border-radius: var(--radius-xl); padding: var(--spacing-2xl); box-shadow: - 0 4px 6px var(--mask-10), - 0 1px 3px var(--mask-8), - inset 0 1px 0 var(--overlay-5); + 0 4px 6px rgba(0, 0, 0, 0.1), + 0 1px 3px rgba(0, 0, 0, 0.08), + inset 0 1px 0 rgba(255, 255, 255, 0.05); transition: all var(--transition-normal); position: relative; overflow: hidden; @@ -261,16 +248,16 @@ body::before { left: 0; right: 0; height: 1px; - background: linear-gradient(90deg, transparent, var(--overlay-10), transparent); + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent); } .card:hover { transform: translateY(-4px); border-color: var(--card-hover-border); box-shadow: - 0 12px 24px var(--mask-15), - 0 4px 8px var(--mask-10), - inset 0 1px 0 var(--overlay-10); + 0 12px 24px rgba(0, 0, 0, 0.15), + 0 4px 8px rgba(0, 0, 0, 0.1), + inset 0 1px 0 rgba(255, 255, 255, 0.1); } /* 按钮 */ @@ -289,7 +276,7 @@ body::before { font-size: var(--text-sm); position: relative; overflow: hidden; - box-shadow: 0 2px 4px var(--mask-10); + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); } .btn::before { @@ -299,7 +286,7 @@ body::before { left: -100%; width: 100%; height: 100%; - background: linear-gradient(90deg, transparent, var(--overlay-10), transparent); + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent); transition: left 0.5s; } @@ -316,7 +303,7 @@ body::before { .btn-primary:hover { background: linear-gradient(135deg, var(--primary-hover) 0%, var(--primary) 100%); transform: translateY(-1px); - box-shadow: 0 4px 12px var(--btn-primary-shadow); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3); } .btn-secondary { @@ -326,10 +313,10 @@ body::before { } .btn-secondary:hover { - background: linear-gradient(135deg, var(--bg-quaternary) 0%, var(--btn-secondary-hover) 100%); + background: linear-gradient(135deg, var(--bg-quaternary) 0%, #4a4a4a 100%); color: var(--text-primary); transform: translateY(-1px); - box-shadow: 0 4px 12px var(--overlay-10); + box-shadow: 0 4px 12px rgba(255, 255, 255, 0.1); } .btn-outline { @@ -344,7 +331,7 @@ body::before { border-color: var(--primary); color: var(--primary); transform: translateY(-1px); - box-shadow: 0 4px 12px var(--primary-20); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2); } .btn:disabled { @@ -392,7 +379,7 @@ body::before { border-color: var(--input-border-focus); box-shadow: var(--input-shadow-focus), - 0 4px 12px var(--primary-10); + 0 4px 12px rgba(102, 126, 234, 0.1); background: var(--bg-secondary); transform: translateY(-1px); } @@ -485,8 +472,3 @@ body::before { max-width: none !important; } } - -/* 重置 fixed 元素外边距:space-y-* 布局规则会给子元素加 margin-top,导致弹窗遮罩定位偏移 */ -.fixed { - margin: 0 !important; -} diff --git a/app/assets/css/mobile-admin.css b/app/assets/css/mobile-admin.css index daaec4a..21b67dd 100644 --- a/app/assets/css/mobile-admin.css +++ b/app/assets/css/mobile-admin.css @@ -422,7 +422,7 @@ .form-textarea:focus, .search-input:focus { transform: none; - box-shadow: 0 0 0 3px var(--primary-20) !important; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2) !important; } } diff --git a/app/assets/css/print-fix.css b/app/assets/css/print-fix.css index 8aa288d..6ef1703 100644 --- a/app/assets/css/print-fix.css +++ b/app/assets/css/print-fix.css @@ -97,18 +97,29 @@ font-size: 14px !important; } - /* 封面 */ + /* 封面 - 打印模式下固定尺寸,移除圆角阴影,使用 contain 防止裁切 */ .schedule-item-print .cover-section { - width: 40px !important; - height: 40px !important; + width: 48px !important; + height: 48px !important; + aspect-ratio: 1 / 1 !important; flex-shrink: 0 !important; } - .schedule-item-print .song-cover, + .schedule-item-print .song-cover { + width: 100% !important; + height: 100% !important; + object-fit: contain !important; + border-radius: 0 !important; + box-shadow: none !important; + } + .schedule-item-print .cover-placeholder { width: 100% !important; height: 100% !important; - border-radius: 4px !important; + aspect-ratio: 1 / 1 !important; + object-fit: contain !important; + border-radius: 0 !important; + box-shadow: none !important; } /* 歌曲信息 */ @@ -124,11 +135,6 @@ color: #000 !important; margin-bottom: 2px !important; white-space: nowrap !important; - } - - /* 歌名文本块承载裁剪,flex 容器上的 text-overflow 不生效 */ - .schedule-item-print .title-text { - min-width: 0 !important; overflow: hidden !important; text-overflow: ellipsis !important; } diff --git a/app/assets/css/theme-protection.css b/app/assets/css/theme-protection.css index 08d45c0..238230b 100644 --- a/app/assets/css/theme-protection.css +++ b/app/assets/css/theme-protection.css @@ -38,12 +38,12 @@ button { /* 保护选中文本颜色 */ ::selection { - background-color: var(--selection-bg, rgba(79, 70, 229, 0.3)); + background-color: rgba(79, 70, 229, 0.3); color: inherit; } ::-moz-selection { - background-color: var(--selection-bg, rgba(79, 70, 229, 0.3)); + background-color: rgba(79, 70, 229, 0.3); color: inherit; } diff --git a/app/assets/css/transitions.css b/app/assets/css/transitions.css index 2fe3a5e..f1f8a85 100644 --- a/app/assets/css/transitions.css +++ b/app/assets/css/transitions.css @@ -17,11 +17,8 @@ } .page-view { - width: 100%; - min-width: 0; animation: page-view-enter 0.55s cubic-bezier(0.25, 0.46, 0.45, 0.94); - /* will-change 声明 transform/filter 会使本元素成为 fixed 定位包含块,导致未 Teleport 的弹窗遮罩错位,仅保留 opacity */ - will-change: opacity; + will-change: transform, opacity, filter; } .page-view[data-route='index'] { @@ -30,16 +27,6 @@ animation-name: page-view-index-enter; } -/* 强制改密页必须立即稳定居中,避免路由重定向时出现位移。 */ -.page-view[data-route='change-password'] { - min-height: 100dvh; - animation: none !important; - opacity: 1; - transform: none !important; - filter: none !important; - will-change: auto; -} - @keyframes page-view-enter { 0% { opacity: 0; @@ -110,7 +97,7 @@ left: 0; right: 0; bottom: 0; - background: linear-gradient(135deg, var(--bg-primary) 0%, var(--bg-secondary) 100%); + background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%); display: flex; align-items: center; justify-content: center; @@ -120,8 +107,8 @@ .loading-spinner { width: 60px; height: 60px; - border: 3px solid var(--overlay-10); - border-top: 3px solid var(--color-indigo); + border: 3px solid rgba(255, 255, 255, 0.1); + border-top: 3px solid #4f46e5; border-radius: 50%; animation: spin 1s linear infinite; } diff --git a/app/assets/css/variables.css b/app/assets/css/variables.css index f6a61f7..30dce2a 100644 --- a/app/assets/css/variables.css +++ b/app/assets/css/variables.css @@ -1,8 +1,408 @@ -/* 全局样式与媒体查询(设计变量已拆分到 themes/ClassicDark/ 和 themes/ClassicLight/ 子目录) */ - -/* 品牌色 RGB 值(用于 rgba() 动态组合) */ +/* 全局CSS变量 - 统一配色方案 */ :root { - --color-indigo-rgb: 99 102 241; + /* 主色调 */ + --primary: #3b82f6; + --primary-hover: #2563eb; + --primary-light: rgba(59, 130, 246, 0.1); + --primary-border: rgba(59, 130, 246, 0.2); + + /* 成功色 */ + --success: #10b981; + --success-hover: #059669; + --success-light: rgba(16, 185, 129, 0.1); + --success-border: rgba(16, 185, 129, 0.2); + + /* 警告色 */ + --warning: #f59e0b; + --warning-hover: #d97706; + --warning-light: rgba(245, 158, 11, 0.1); + --warning-border: rgba(245, 158, 11, 0.2); + + /* 错误色 */ + --error: #ef4444; + --error-hover: #dc2626; + --error-light: rgba(239, 68, 68, 0.1); + --error-border: rgba(239, 68, 68, 0.2); + + /* 信息色 */ + --info: #3b82f6; + --info-hover: #2563eb; + --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; + + /* 阴影 */ + --shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1); + + /* 圆角 */ + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 12px; + --radius-2xl: 16px; + + /* 间距 */ + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 20px; + --spacing-2xl: 24px; + --spacing-3xl: 32px; + + /* 字体大小 */ + --text-xs: 12px; + --text-sm: 14px; + --text-base: 16px; + --text-lg: 18px; + --text-xl: 20px; + --text-2xl: 24px; + --text-3xl: 30px; + + /* 字体粗细 */ + --font-normal: 400; + --font-medium: 500; + --font-semibold: 600; + --font-bold: 700; + + /* 过渡动画 */ + --transition-fast: 0.15s ease; + --transition-normal: 0.2s ease; + --transition-slow: 0.3s ease; + + /* Z-index层级 */ + --z-dropdown: 100; + --z-sticky: 200; + --z-fixed: 300; + --z-modal-backdrop: 400; + --z-modal: 500; + --z-popover: 600; + --z-tooltip: 700; + --z-toast: 800; + + /* 特殊状态颜色 */ + --status-pending: #fbbf24; + --status-pending-light: rgba(251, 191, 36, 0.1); + --status-approved: #10b981; + --status-approved-light: rgba(16, 185, 129, 0.1); + --status-played: #6b7280; + --status-played-light: rgba(107, 114, 128, 0.1); + + /* 投票相关颜色 */ + --vote-heart: #ef4444; + --vote-heart-hover: #dc2626; + --vote-count: var(--text-tertiary); + + /* 拖拽状态颜色 */ + --drag-over: var(--primary-light); + --drag-over-border: var(--primary); + --dragging-opacity: 0.5; + + /* 表格颜色 */ + --table-header-bg: var(--bg-tertiary); + --table-row-hover: var(--bg-hover); + --table-border: var(--border-secondary); + --table-selected: var(--primary-light); + + /* 卡片颜色 */ + --card-bg: var(--bg-secondary); + --card-border: var(--border-secondary); + --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-border: var(--primary-border); + + --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); + --btn-success-text: var(--text-primary); + --btn-success-border: var(--success-border); + + --btn-warning-bg: var(--warning); + --btn-warning-hover: var(--warning-hover); + --btn-warning-text: var(--text-primary); + --btn-warning-border: var(--warning-border); + + --btn-error-bg: var(--error); + --btn-error-hover: var(--error-hover); + --btn-error-text: var(--text-primary); + --btn-error-border: var(--error-border); + + --btn-disabled-bg: var(--bg-tertiary); + --btn-disabled-text: var(--text-disabled); + + /* 输入框颜色 */ + --input-bg: var(--bg-tertiary); + --input-border: var(--border-tertiary); + --input-border-focus: var(--primary); + --input-text: var(--text-primary); + --input-placeholder: var(--text-quaternary); + --input-shadow-focus: 0 0 0 3px var(--primary-light); + + /* 模态框颜色 */ + --modal-backdrop: rgba(0, 0, 0, 0.7); + --modal-bg: var(--bg-secondary); + --modal-border: var(--border-secondary); + --modal-header-border: var(--border-secondary); + --modal-footer-border: var(--border-secondary); + + /* 通知颜色 */ + --notification-success-bg: var(--success-light); + --notification-success-border: var(--success-border); + --notification-success-text: var(--success); + + --notification-warning-bg: var(--warning-light); + --notification-warning-border: var(--warning-border); + --notification-warning-text: var(--warning); + + --notification-error-bg: var(--error-light); + --notification-error-border: var(--error-border); + --notification-error-text: var(--error); + + --notification-info-bg: var(--info-light); + --notification-info-border: var(--info-border); + --notification-info-text: var(--info); + + /* 加载动画颜色 */ + --spinner-primary: var(--primary); + --spinner-secondary: var(--bg-tertiary); + + /* 滚动条颜色 */ + --scrollbar-track: var(--bg-tertiary); + --scrollbar-thumb: var(--bg-quaternary); + --scrollbar-thumb-hover: #4a4a4a; +} + +:root[data-theme='light'], +:root[data-theme='ClassicLight'], +:root[data-theme='ModernLight'] { + --bg-primary: #f8fafc; + --bg-secondary: #f1f5f9; + --bg-tertiary: #e2e8f0; + --bg-quaternary: #cbd5e1; + --bg-hover: #e2e8f0; + + --text-primary: #0f172a; + --text-secondary: #334155; + --text-tertiary: #475569; + --text-quaternary: #64748b; + --text-disabled: #94a3b8; + + --border-primary: #e2e8f0; + --border-secondary: #cbd5e1; + --border-tertiary: #94a3b8; + --border-quaternary: #64748b; + + --modal-backdrop: rgba(0, 0, 0, 0.4); + --scrollbar-thumb-hover: #94a3b8; + + --btn-secondary-bg: #f1f5f9; + --btn-secondary-hover: #e2e8f0; + --btn-secondary-text: #0f172a; + --btn-secondary-border: #cbd5e1; + + --card-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06); +} + +:root[data-theme='ClassicLight'] { + --primary: #1e40af; + --primary-hover: #1e3a8a; + --primary-light: rgba(30, 64, 175, 0.08); + --primary-border: rgba(30, 64, 175, 0.15); + + --bg-primary: #ffffff; + --bg-secondary: #f5f7fa; + --bg-tertiary: #edf0f5; + --bg-quaternary: #d9dee7; + --bg-hover: #f0f3f8; + + --success: #059669; + --success-hover: #047857; + --success-light: rgba(5, 150, 105, 0.08); + --success-border: rgba(5, 150, 105, 0.15); + + --warning: #d97706; + --warning-hover: #b45309; + --warning-light: rgba(217, 119, 6, 0.08); + --warning-border: rgba(217, 119, 6, 0.15); + + --error: #dc2626; + --error-hover: #b91c1c; + --error-light: rgba(220, 38, 38, 0.08); + --error-border: rgba(220, 38, 38, 0.15); + + --info: #2563eb; + --info-hover: #1d4ed8; + --info-light: rgba(37, 99, 235, 0.08); + --info-border: rgba(37, 99, 235, 0.15); + + --text-primary: #1a2332; + --text-secondary: #4a5568; + --text-tertiary: #6b7280; + --text-quaternary: #9ca3af; + --text-disabled: #c5cbd3; + + --border-primary: #e5e7eb; + --border-secondary: #d1d5db; + --border-tertiary: #9ca3af; + --border-quaternary: #6b7280; + + --input-bg: #ffffff; + --input-border: #d1d5db; + --input-border-focus: #1e40af; + --input-text: #1a2332; + --input-placeholder: #9ca3af; + --input-shadow-focus: 0 0 0 3px rgba(30, 64, 175, 0.1); + + --card-bg: #ffffff; + --card-border: #e5e7eb; + --card-hover-border: #d1d5db; + + --table-header-bg: #f5f7fa; + --table-row-hover: #f9fafb; + --table-border: #e5e7eb; + --table-selected: rgba(30, 64, 175, 0.06); + + --scrollbar-track: #f1f5f9; + --scrollbar-thumb: #cbd5e1; + --scrollbar-thumb-hover: #94a3b8; + + --status-pending: #ca8a04; + --status-pending-light: rgba(202, 138, 4, 0.08); + --status-approved: #059669; + --status-approved-light: rgba(5, 150, 105, 0.08); + --status-played: #6b7280; + --status-played-light: rgba(107, 114, 128, 0.08); + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.04); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.04); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.06), 0 4px 6px -4px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.06), 0 8px 10px -6px rgba(0, 0, 0, 0.04); + + --radius-sm: 3px; + --radius-md: 5px; + --radius-lg: 6px; + --radius-xl: 8px; + --radius-2xl: 10px; +} + +:root[data-theme='ModernLight'] { + --primary: #4f46e5; + --primary-hover: #4338ca; + --primary-light: rgba(79, 70, 229, 0.1); + --primary-border: rgba(79, 70, 229, 0.2); + + --bg-primary: #fafafa; + --bg-secondary: #f4f4f5; + --bg-tertiary: #e4e4e7; + --bg-quaternary: #d4d4d8; + --bg-hover: #eeeeef; + + --success: #16a34a; + --success-hover: #15803d; + --success-light: rgba(22, 163, 74, 0.1); + --success-border: rgba(22, 163, 74, 0.2); + + --warning: #ea580c; + --warning-hover: #c2410c; + --warning-light: rgba(234, 88, 12, 0.1); + --warning-border: rgba(234, 88, 12, 0.2); + + --error: #e11d48; + --error-hover: #be123c; + --error-light: rgba(225, 29, 72, 0.1); + --error-border: rgba(225, 29, 72, 0.2); + + --info: #0ea5e9; + --info-hover: #0284c7; + --info-light: rgba(14, 165, 233, 0.1); + --info-border: rgba(14, 165, 233, 0.2); + + --text-primary: #09090b; + --text-secondary: #3f3f46; + --text-tertiary: #71717a; + --text-quaternary: #a1a1aa; + --text-disabled: #d4d4d8; + + --border-primary: #e4e4e7; + --border-secondary: #d4d4d8; + --border-tertiary: #a1a1aa; + --border-quaternary: #71717a; + + --input-bg: #ffffff; + --input-border: #e4e4e7; + --input-border-focus: #4f46e5; + --input-text: #09090b; + --input-placeholder: #a1a1aa; + --input-shadow-focus: 0 0 0 3px rgba(79, 70, 229, 0.12); + + --card-bg: #ffffff; + --card-border: #e4e4e7; + --card-hover-border: #d4d4d8; + + --table-header-bg: #f4f4f5; + --table-row-hover: #fafafa; + --table-border: #e4e4e7; + --table-selected: rgba(79, 70, 229, 0.08); + + --scrollbar-track: #f4f4f5; + --scrollbar-thumb: #d4d4d8; + --scrollbar-thumb-hover: #a1a1aa; + + --status-pending: #eab308; + --status-pending-light: rgba(234, 179, 8, 0.12); + --status-approved: #22c55e; + --status-approved-light: rgba(34, 197, 94, 0.12); + --status-played: #71717a; + --status-played-light: rgba(113, 113, 122, 0.12); + + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03); + --shadow-md: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.03); + --shadow-lg: 0 8px 24px -4px rgba(0, 0, 0, 0.06), 0 2px 8px -4px rgba(0, 0, 0, 0.04); + --shadow-xl: 0 16px 40px -8px rgba(0, 0, 0, 0.08), 0 4px 16px -8px rgba(0, 0, 0, 0.05); + + --radius-sm: 4px; + --radius-md: 6px; + --radius-lg: 8px; + --radius-xl: 10px; + --radius-2xl: 12px; +} + +/* 深色主题特定调整 */ +@media (prefers-color-scheme: dark) { + :root { + /* 在深色模式下可以进行微调 */ + } } /* 高对比度模式支持 */ diff --git a/app/assets/css/year-review.css b/app/assets/css/year-review.css index 69ce3dc..d8164fa 100644 --- a/app/assets/css/year-review.css +++ b/app/assets/css/year-review.css @@ -97,19 +97,19 @@ /* Gradients */ .bg-gradient-intro { - background: linear-gradient(135deg, var(--panel-bg-dark) 0%, var(--bg-primary) 50%, var(--panel-bg-dark) 100%); + background: linear-gradient(135deg, #111827 0%, #000000 50%, #111827 100%); } .bg-gradient-text-intro { - background-image: linear-gradient(to right, var(--panel-bg), var(--panel-border-light), var(--text-muted)); + background-image: linear-gradient(to right, #ffffff, #e5e7eb, #6b7280); } .bg-gradient-artist { - background: linear-gradient(to bottom right, var(--color-collab-lighter), var(--color-pink), var(--color-orange)); + background: linear-gradient(to bottom right, #e9d5ff, #f472b6, #fb923c); } .bg-gradient-numbers { - background-image: linear-gradient(to bottom, var(--color-accent-light), var(--color-accent-light-hover)); + background-image: linear-gradient(to bottom, #60a5fa, #1d4ed8); } /* Slide Transitions */ diff --git a/app/components/Admin/AdminDatePicker.vue b/app/components/Admin/AdminDatePicker.vue new file mode 100644 index 0000000..b063b06 --- /dev/null +++ b/app/components/Admin/AdminDatePicker.vue @@ -0,0 +1,215 @@ + + + + + diff --git a/app/components/Admin/AdminMultiDatePicker.vue b/app/components/Admin/AdminMultiDatePicker.vue new file mode 100644 index 0000000..1f5ca30 --- /dev/null +++ b/app/components/Admin/AdminMultiDatePicker.vue @@ -0,0 +1,226 @@ + + + + + diff --git a/app/components/Admin/AdminOnboardingTutorial.vue b/app/components/Admin/AdminOnboardingTutorial.vue new file mode 100644 index 0000000..951a8c1 --- /dev/null +++ b/app/components/Admin/AdminOnboardingTutorial.vue @@ -0,0 +1,144 @@ + + + diff --git a/app/components/Admin/ApiKeyManager.vue b/app/components/Admin/ApiKeyManager.vue index 17e5a2e..b20a430 100644 --- a/app/components/Admin/ApiKeyManager.vue +++ b/app/components/Admin/ApiKeyManager.vue @@ -3,53 +3,51 @@
-

{{ locale.title }}

-

{{ locale.desc }}

+

API密钥管理

+

管理开放API的访问密钥,控制第三方应用的访问权限

- +
- +
@@ -91,54 +89,49 @@
-

+

{{ apiKey.name }}

{{ locale.active }}活跃 {{ locale.inactive }}非活跃 {{ locale.expired }}已过期
-

{{ apiKey.description || locale.noDescription }}

+

{{ apiKey.description || '暂无描述' }}

-
+
- {{ locale.creator }}创建者 -

{{ apiKey.creatorName || locale.unknown }}

+

{{ apiKey.creatorName || '未知' }}

- {{ locale.createdAt }}创建时间 -

+

{{ formatDate(apiKey.createdAt).split(' ')[0] }}

- {{ locale.permissions }}权限列表
{{ perm }} @@ -179,7 +172,7 @@
@@ -189,7 +182,7 @@ v-model:current-page="pagination.page" :total-pages="pagination.totalPages" :total-items="pagination.total" - :item-name="locale.itemName" + item-name="个API密钥" @change="loadApiKeys" /> @@ -199,22 +192,19 @@ v-if="showCreateModal || showEditModal || showViewModal || showSuccessModal" class="fixed inset-0 z-[100] flex items-center justify-center p-4" > -
+
-
-

- {{ showCreateModal ? locale.create : locale.edit }} +
+

+ {{ showCreateModal ? '创建API密钥' : '编辑API密钥' }}

- -
-
-
- - {{ getNestedText('songAdminSettings', 'scope') }} -
- -
-
- - -
-
- - -
-
- -
-
- - -
-
-
- {{ getNestedText('songAdminSettings', 'noMatchedUsers') }} -
-
- -
-
-
-
- - -
-
- - {{ getNestedText('songAdminSettings', 'targetSettings') }} -
-

- {{ getNestedText('songAdminSettings', 'desc') }} -

-
-
- -
- - -
-
-
- -
- - -
-
-
- - -
-
-
-
-
-

- {{ locale.excelUpload.dragTitle }} +

+ 拖拽 Excel 文件到此处

-

- {{ locale.excelUpload.or }} {{ locale.excelUpload.chooseFile }} {{ locale.excelUpload.supportedFormats }} +

+ 或 点击选择文件 (支持 .xlsx / + .xls)

- -
-
- - {{ locale.matchType.title }} -
-
- - -
-

- - -

-
-
- {{ locale.fileSpec.title }} + 文件格式规范
-
    +
    • -
      - {{ locale.fileSpec.headerLine }} +
      + 第一行为表头:用户名、姓名、年级、班级、新用户名
    • -
      - - +
      + 用户名 列用于精确匹配现有账户
    • -
      - {{ locale.fileSpec.blankKeepsOriginal }} +
      + 字段留空则保持原值不变,不进行覆盖更新
- - -
-
- - {{ locale.preview.blockersTitle(blockerList.length) }} -
-

- {{ locale.preview.blockersDesc }} -

-
- - {{ blocker.name }}({{ blocker.username }}) - -
-
- 数据预览 ({{ excelPreviewData.length }}条)
- - - +
+
+ 待更新 +
+
+
+ 错误 +
-
+
- - - - + + + + - + @@ -809,10 +487,10 @@
- {{ matchType === 'username' ? locale.preview.matchUser : locale.preview.matchName }} - {{ locale.preview.currentInfo }}{{ locale.preview.afterUpdate }}{{ locale.preview.matchStatus }}匹配用户当前信息更新后匹配状态
@@ -745,63 +443,43 @@ {{ matchType === 'username' ? row.username : row.name }}{{ row.username }} - {{ - matchType === 'username' ? (row.name || '-') : (row.username || '-') + {{ + row.name || '-' }} -
-
- {{ row.currentGrade || '-' }} - / - {{ row.currentClass || '-' }} -
- {{ row.username }} +
+ {{ row.currentGrade || '-' }}年级 {{ row.currentClass || '-' }}
-
- - +
+ {{ + row.newGrade || row.currentGrade || '-' + }} + / + {{ + row.newClass || row.currentClass || '-' + }}
{{ row.error }} - - {{ locale.preview.noChange }} - - {{ locale.preview.ready }} + 已就绪
- {{ locale.preview.moreQueued(filteredPreviewData.length - 10) }} + 以及另外 {{ excelPreviewData.length - 10 }} 条记录已在队列中
@@ -821,55 +499,36 @@
{{ error }}
- - -
-
- {{ updateProgressText }} - {{ updateProgress }}% -
-
-
-
-
-
+
@@ -878,10 +537,8 @@ diff --git a/app/components/Admin/DataAnalysisPanel.vue b/app/components/Admin/DataAnalysisPanel.vue index 4e5b35c..0fa7fb6 100644 --- a/app/components/Admin/DataAnalysisPanel.vue +++ b/app/components/Admin/DataAnalysisPanel.vue @@ -6,19 +6,17 @@ class="flex flex-col items-center justify-center min-h-[60vh] space-y-6" >
-
+
+
- -
-
- +
-

+

{{ loadingSteps[currentLoadingStep] }}

-

{{ locale.loadingDesc }}

+

正在获取最新的统计数据...

@@ -27,18 +25,18 @@ v-else-if="error && !hasInitialData" class="flex flex-col items-center justify-center min-h-[60vh] space-y-6" > -
- +
+
-

{{ locale.loadFailed }}

-

{{ error }}

+

数据加载失败

+

{{ error }}

@@ -48,23 +46,23 @@
-

{{ locale.title }}

-

{{ locale.desc }}

+

数据中心

+

洞察校园声音背后的互动趋势与影响力

-
- {{ locale.realtimeMode }} + 实时模式
@@ -84,46 +82,37 @@
{{ Math.abs(stat.trend) }}%
-
- {{ locale.stable }} +
+ 稳定
-

+

{{ formatNumber(stat.value) }}

-

+

{{ stat.label }}

@@ -133,68 +122,68 @@
- {{ locale.currentActiveUsers }}当前活跃用户
-

{{ realtimeStats.activeUsers }}

- {{ locale.onlineUnit }} +

{{ realtimeStats.activeUsers }}

+ 人在线
-
-
- +
+
+
- {{ locale.todayRequests }}今日累计点播
-

+

{{ realtimeStats.todayRequests }}

- {{ locale.songUnit }} + 首歌曲
- +
-

- - {{ locale.trendTitle }} +

+ + 点歌趋势分析

-

{{ locale.trendDesc }}

+

近 7 日投稿量波动情况

-
+
- {{ locale.countSongs(item.count) }} + {{ item.count }}首
{{ formatDateShort(item.date) }}
-
+
-

{{ locale.noTrendData }}

+

暂无趋势数据

-

- - {{ locale.topSongsTitle }} +

+ + 热门歌曲排行

- | + |
-
+
@@ -301,21 +292,21 @@ v-if="panelStates.topSongs.loading && topSongs.length === 0" class="flex-1 space-y-4" > -
+
- -

+ +

{{ panelStates.topSongs.error }}

{{ i + 1 }}

{{ song.title }}

-

+

{{ song.artist }}

- {{ song.count }} -
- {{ selectedSortBy === 'replay' ? locale.times : locale.likes }} + {{ song.count }} +
+ {{ selectedSortBy === 'replay' ? '次数' : '点赞' }}
-
+
-

{{ locale.noData }}

+

暂无数据

@@ -369,14 +360,14 @@
-

- - {{ locale.activeUsersTitle }} +

+ + 活跃用户排行榜

-
+
@@ -385,81 +376,91 @@ v-if="panelStates.activeUsers.loading && activeUsers.length === 0" class="flex-1 space-y-4" > -
+
- -

+ +

{{ panelStates.activeUsers.error }}

-
-
-
- {{ user.name.charAt(0) }} -
-
-

{{ user.name }}

-

- {{ locale.userActivity(user.contributions, user.likes) }} -

-
-
- {{ user.activityScore }} -

- {{ locale.activity }} -

-
-
- +
+ v-for="(user, i) in activeUsers.slice(0, 5)" + :key="i" + class="relative p-5 bg-zinc-950/30 border border-zinc-800/50 rounded-3xl overflow-hidden group" + > +
+ +
+ {{ user.name.charAt(0) }} +
+
+

{{ user.name }}

+

+ {{ user.contributions }}首投稿 · {{ user.likes }}次点赞 +

+
+
+ {{ user.activityScore }} +

+ 活跃度 +

+
+
+
+
+
-
+
-

{{ locale.noActiveUsers }}

+

暂无活跃用户

-

- - {{ locale.semesterComparisonTitle }} +

+ + 学期对比分析

@@ -469,21 +470,21 @@ v-if="panelStates.semesterComparison.loading && semesterComparison.length === 0" class="flex-1 space-y-4" > -
+
- -

+ +

{{ panelStates.semesterComparison.error }}

- {{ + {{ sem.semester }} {{ locale.currentSemester }}当前学期 - {{ locale.historyBaseline }} + 历史基准
-
{{ sem.totalSongs }}
-

- {{ locale.totalSongs }} +

{{ sem.totalSongs }}
+

+ 歌曲总量

-
{{ sem.totalSchedules }}
-

- {{ locale.totalSchedules }} +

{{ sem.totalSchedules }}
+

+ 排期总数

-
{{ sem.totalRequests }}
-

- {{ locale.totalLikes }} +

{{ sem.totalRequests }}
+

+ 获赞总数

-
+
-

{{ locale.noComparisonData }}

+

暂无对比数据

@@ -540,70 +541,75 @@
-

{{ locale.activeUserDetails }}

-
-
- {{ locale.onlineCount(realtimeStats.activeUsers) }}活跃用户详情

+
+
+ {{ realtimeStats.activeUsers }} 在线
{{ user.name.charAt(0) }}
-
{{ user.name }}
-
+
{{ user.name }}
+
@{{ user.username }}
-
+
{{ user.lastActive }}
+
+ 及其他 {{ realtimeStats.activeUsersList.length - 5 }} 位用户 +
-
+
-

{{ locale.noOnlineUsers }}

+

暂无在线用户

-
- {{ locale.syncingRealtime }} + 实时数据同步中 - +
@@ -612,8 +618,7 @@ + + diff --git a/app/components/Admin/NotificationSender.vue b/app/components/Admin/NotificationSender.vue index 6572371..23bd005 100644 --- a/app/components/Admin/NotificationSender.vue +++ b/app/components/Admin/NotificationSender.vue @@ -2,82 +2,45 @@
-

{{ locale.title }}

-

{{ locale.desc }}

+

向用户发送通知

+

即时推送系统公告、活动信息或重要提醒至指定群体

-
+
- 通知标题
- 通知内容