feat: 迁移自定义功能至 1.5.9.9 版本
包含音乐源限制、CI 防 OOM 优化、数据库连接池优化、管理端权限及 UI 细节定制等功能
This commit is contained in:
@@ -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: <OLD_TAG_FROM_ABOVE>\\n' > docker-compose.override.yml"
|
||||
echo " docker compose up -d"
|
||||
echo "================================================================="
|
||||
REMOTE_SCRIPT_MAIN
|
||||
|
||||
# 清理私钥临时文件
|
||||
rm -f "${SSH_KEY_FILE}"
|
||||
@@ -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 > * {
|
||||
|
||||
+20
-38
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
/* 在深色模式下可以进行微调 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 高对比度模式支持 */
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
<template>
|
||||
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
|
||||
<div class="flex items-center" @touchstart.stop>
|
||||
<button
|
||||
:disabled="isFirstDateVisible"
|
||||
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
|
||||
@click="scrollDates('left')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15,18 9,12 15,6" /></svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref="dateSelector"
|
||||
class="flex-1 flex overflow-x-auto scrollbar-hide gap-2 px-2 py-1 scroll-smooth overscroll-x-contain"
|
||||
style="overscroll-behavior-x: contain; touch-action: pan-x"
|
||||
@wheel="handleWheel"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<button
|
||||
v-for="date in availableDates"
|
||||
:key="date.value"
|
||||
:data-date="date.value"
|
||||
:class="[
|
||||
'flex flex-col items-center justify-center min-w-[64px] h-16 rounded-lg transition-all duration-200 border',
|
||||
modelValue === date.value
|
||||
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'bg-zinc-900 border-zinc-800 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 hover:border-zinc-700'
|
||||
]"
|
||||
@click="selectDate(date.value)"
|
||||
>
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider opacity-80">{{ date.weekday }}</span>
|
||||
<span class="text-lg font-black leading-none my-0.5">{{ date.day }}</span>
|
||||
<span class="text-[9px] font-bold opacity-60">{{ date.month }}月</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="isLastDateVisible"
|
||||
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
|
||||
@click="scrollDates('right')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
|
||||
</button>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="flex items-center border-l border-zinc-800 ml-1 pl-1">
|
||||
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click="scrollToToday">
|
||||
<CircleDot class="w-5 h-5" />
|
||||
</button>
|
||||
<button class="p-2 text-zinc-500 hover:text-blue-400 transition-colors" title="选择特定日期" @click="openManualDatePicker">
|
||||
<CalendarIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动日期选择弹窗 -->
|
||||
<div
|
||||
v-if="showManualDatePicker"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden" @click.stop>
|
||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
|
||||
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">选择日期</h3>
|
||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showManualDatePicker = false">
|
||||
<CloseIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<input
|
||||
v-model="manualSelectedDate"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-200 focus:outline-none focus:border-blue-500 transition-colors"
|
||||
type="date"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<button class="flex-1 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider" @click="showManualDatePicker = false">取消</button>
|
||||
<button class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider" @click="confirmManualDate">确认</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
||||
import { CircleDot, Calendar as CalendarIcon, X as CloseIcon } from '@lucide/vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: String,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const dateSelector = ref(null)
|
||||
const isFirstDateVisible = ref(false)
|
||||
const isLastDateVisible = ref(false)
|
||||
const dateRange = ref({ start: -15, end: 15 })
|
||||
const showManualDatePicker = ref(false)
|
||||
const manualSelectedDate = ref('')
|
||||
|
||||
const availableDates = computed(() => {
|
||||
const dates = []
|
||||
const today = new Date()
|
||||
for (let i = dateRange.value.start; i <= dateRange.value.end; i++) {
|
||||
const date = new Date(today)
|
||||
date.setDate(today.getDate() + i)
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
dates.push({
|
||||
value: dateStr,
|
||||
day: date.getDate().toString().padStart(2, '0'),
|
||||
month: (date.getMonth() + 1).toString().padStart(2, '0'),
|
||||
weekday: weekdays[date.getDay()],
|
||||
isToday: i === 0
|
||||
})
|
||||
}
|
||||
return dates
|
||||
})
|
||||
|
||||
const selectDate = (dateValue) => {
|
||||
emit('update:modelValue', dateValue)
|
||||
emit('change', dateValue)
|
||||
}
|
||||
|
||||
const scrollToDateElement = (behavior = 'smooth') => {
|
||||
if (!dateSelector.value || !props.modelValue) return
|
||||
const el = dateSelector.value.querySelector(`[data-date="${props.modelValue}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior, block: 'nearest', inline: 'center' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleWheel = (event) => {
|
||||
event.preventDefault()
|
||||
if (!dateSelector.value) return
|
||||
const scrollAmount = 200
|
||||
const currentScroll = dateSelector.value.scrollLeft
|
||||
dateSelector.value.scrollTo({
|
||||
left: event.deltaY > 0 ? currentScroll + scrollAmount : currentScroll - scrollAmount,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
const scrollDates = (direction) => {
|
||||
if (!dateSelector.value) return
|
||||
const scrollAmount = 200
|
||||
const currentScroll = dateSelector.value.scrollLeft
|
||||
dateSelector.value.scrollTo({
|
||||
left: direction === 'right' ? currentScroll + scrollAmount : currentScroll - scrollAmount,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!dateSelector.value) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = dateSelector.value
|
||||
if (scrollLeft < 50) {
|
||||
const oldScrollWidth = scrollWidth
|
||||
dateRange.value.start -= 7
|
||||
nextTick(() => {
|
||||
const newScrollWidth = dateSelector.value.scrollWidth
|
||||
dateSelector.value.scrollLeft += newScrollWidth - oldScrollWidth
|
||||
})
|
||||
}
|
||||
if (scrollWidth - scrollLeft - clientWidth < 50) {
|
||||
dateRange.value.end += 7
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToToday = () => {
|
||||
const todayStr = new Date().toISOString().split('T')[0]
|
||||
if (props.modelValue !== todayStr) {
|
||||
selectDate(todayStr)
|
||||
}
|
||||
if (dateRange.value.start > 0 || dateRange.value.end < 0) {
|
||||
dateRange.value.start = -15
|
||||
dateRange.value.end = 15
|
||||
}
|
||||
nextTick(() => scrollToDateElement('smooth'))
|
||||
}
|
||||
|
||||
const openManualDatePicker = () => {
|
||||
manualSelectedDate.value = props.modelValue
|
||||
showManualDatePicker.value = true
|
||||
}
|
||||
|
||||
const confirmManualDate = () => {
|
||||
if (manualSelectedDate.value && manualSelectedDate.value !== props.modelValue) {
|
||||
selectDate(manualSelectedDate.value)
|
||||
}
|
||||
showManualDatePicker.value = false
|
||||
nextTick(() => scrollToDateElement('smooth'))
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, () => {
|
||||
nextTick(() => scrollToDateElement('smooth'))
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => scrollToDateElement('auto'))
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,226 @@
|
||||
<template>
|
||||
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
|
||||
<div class="flex items-center" @touchstart.stop>
|
||||
<button
|
||||
:disabled="isFirstDateVisible"
|
||||
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
|
||||
@click="scrollDates('left')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15,18 9,12 15,6" /></svg>
|
||||
</button>
|
||||
|
||||
<div
|
||||
ref="dateSelector"
|
||||
class="flex-1 flex overflow-x-auto scrollbar-hide gap-2 px-2 py-1 scroll-smooth overscroll-x-contain"
|
||||
style="overscroll-behavior-x: contain; touch-action: pan-x"
|
||||
@wheel="handleWheel"
|
||||
@scroll="handleScroll"
|
||||
>
|
||||
<button
|
||||
v-for="date in availableDates"
|
||||
:key="date.value"
|
||||
:data-date="date.value"
|
||||
:class="[
|
||||
'flex flex-col items-center justify-center min-w-[64px] h-16 rounded-lg transition-all duration-200 border',
|
||||
modelValue.includes(date.value)
|
||||
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'bg-zinc-900 border-zinc-800 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 hover:border-zinc-700'
|
||||
]"
|
||||
@click="toggleDate(date.value)"
|
||||
>
|
||||
<span class="text-[10px] font-bold uppercase tracking-wider opacity-80">{{ date.weekday }}</span>
|
||||
<span class="text-lg font-black leading-none my-0.5">{{ date.day }}</span>
|
||||
<span class="text-[9px] font-bold opacity-60">{{ date.month }}月</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="isLastDateVisible"
|
||||
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
|
||||
@click="scrollDates('right')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
|
||||
</button>
|
||||
|
||||
<!-- 操作按钮组 -->
|
||||
<div class="flex items-center border-l border-zinc-800 ml-1 pl-1">
|
||||
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click="scrollToToday">
|
||||
<CircleDot class="w-5 h-5" />
|
||||
</button>
|
||||
<button class="p-2 text-zinc-500 hover:text-blue-400 transition-colors" title="选择特定日期" @click="openManualDatePicker">
|
||||
<CalendarIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动日期选择弹窗 -->
|
||||
<div
|
||||
v-if="showManualDatePicker"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden" @click.stop>
|
||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
|
||||
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">选择日期</h3>
|
||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showManualDatePicker = false">
|
||||
<CloseIcon class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<input
|
||||
v-model="manualSelectedDate"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-200 focus:outline-none focus:border-blue-500 transition-colors"
|
||||
type="date"
|
||||
>
|
||||
<div class="flex gap-3">
|
||||
<button class="flex-1 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider" @click="showManualDatePicker = false">取消</button>
|
||||
<button class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider" @click="confirmManualDate">确认添加</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
||||
import { CircleDot, Calendar as CalendarIcon, X as CloseIcon } from '@lucide/vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue', 'change'])
|
||||
|
||||
const dateSelector = ref(null)
|
||||
const isFirstDateVisible = ref(false)
|
||||
const isLastDateVisible = ref(false)
|
||||
const dateRange = ref({ start: 0, end: 30 }) // 默认展示今天起30天
|
||||
const showManualDatePicker = ref(false)
|
||||
const manualSelectedDate = ref('')
|
||||
|
||||
const availableDates = computed(() => {
|
||||
const dates = []
|
||||
const today = new Date()
|
||||
for (let i = dateRange.value.start; i <= dateRange.value.end; i++) {
|
||||
const date = new Date(today)
|
||||
date.setDate(today.getDate() + i)
|
||||
const dateStr = date.toISOString().split('T')[0]
|
||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
||||
dates.push({
|
||||
value: dateStr,
|
||||
day: date.getDate().toString().padStart(2, '0'),
|
||||
month: (date.getMonth() + 1).toString().padStart(2, '0'),
|
||||
weekday: weekdays[date.getDay()],
|
||||
isToday: i === 0
|
||||
})
|
||||
}
|
||||
return dates
|
||||
})
|
||||
|
||||
const toggleDate = (dateValue) => {
|
||||
const newValue = [...props.modelValue]
|
||||
const index = newValue.indexOf(dateValue)
|
||||
if (index > -1) {
|
||||
newValue.splice(index, 1)
|
||||
} else {
|
||||
newValue.push(dateValue)
|
||||
newValue.sort() // 保持日期顺序
|
||||
}
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
}
|
||||
|
||||
const handleWheel = (event) => {
|
||||
event.preventDefault()
|
||||
if (!dateSelector.value) return
|
||||
const scrollAmount = 200
|
||||
const currentScroll = dateSelector.value.scrollLeft
|
||||
dateSelector.value.scrollTo({
|
||||
left: event.deltaY > 0 ? currentScroll + scrollAmount : currentScroll - scrollAmount,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
const scrollDates = (direction) => {
|
||||
if (!dateSelector.value) return
|
||||
const scrollAmount = 200
|
||||
const currentScroll = dateSelector.value.scrollLeft
|
||||
dateSelector.value.scrollTo({
|
||||
left: direction === 'right' ? currentScroll + scrollAmount : currentScroll - scrollAmount,
|
||||
behavior: 'smooth'
|
||||
})
|
||||
}
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!dateSelector.value) return
|
||||
const { scrollLeft, scrollWidth, clientWidth } = dateSelector.value
|
||||
if (scrollLeft < 50) {
|
||||
const oldScrollWidth = scrollWidth
|
||||
dateRange.value.start -= 7
|
||||
nextTick(() => {
|
||||
const newScrollWidth = dateSelector.value.scrollWidth
|
||||
dateSelector.value.scrollLeft += newScrollWidth - oldScrollWidth
|
||||
})
|
||||
}
|
||||
if (scrollWidth - scrollLeft - clientWidth < 50) {
|
||||
dateRange.value.end += 7
|
||||
}
|
||||
}
|
||||
|
||||
const scrollToToday = () => {
|
||||
if (dateRange.value.start > 0 || dateRange.value.end < 0) {
|
||||
dateRange.value.start = -15
|
||||
dateRange.value.end = 15
|
||||
}
|
||||
nextTick(() => {
|
||||
if (!dateSelector.value) return
|
||||
const todayStr = new Date().toISOString().split('T')[0]
|
||||
const el = dateSelector.value.querySelector(`[data-date="${todayStr}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const openManualDatePicker = () => {
|
||||
manualSelectedDate.value = new Date().toISOString().split('T')[0]
|
||||
showManualDatePicker.value = true
|
||||
}
|
||||
|
||||
const confirmManualDate = () => {
|
||||
if (manualSelectedDate.value) {
|
||||
const newValue = [...props.modelValue]
|
||||
if (!newValue.includes(manualSelectedDate.value)) {
|
||||
newValue.push(manualSelectedDate.value)
|
||||
newValue.sort()
|
||||
emit('update:modelValue', newValue)
|
||||
emit('change', newValue)
|
||||
}
|
||||
}
|
||||
showManualDatePicker.value = false
|
||||
nextTick(() => {
|
||||
if (!dateSelector.value) return
|
||||
const el = dateSelector.value.querySelector(`[data-date="${manualSelectedDate.value}"]`)
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
nextTick(() => scrollToToday())
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,144 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<div
|
||||
v-if="visible"
|
||||
class="fixed inset-0 z-[70] bg-black/70 backdrop-blur-sm px-4 py-6 flex items-end sm:items-center justify-center"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-xl rounded-2xl border border-zinc-800 bg-zinc-950 shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div class="px-5 py-4 border-b border-zinc-800 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p class="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-black">后台引导</p>
|
||||
<h3 class="text-lg font-black text-zinc-100 mt-1">{{ currentStep.title }}</h3>
|
||||
</div>
|
||||
<button
|
||||
class="h-9 w-9 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900 transition-colors"
|
||||
@click="handleClose"
|
||||
>
|
||||
<X :size="16" class="mx-auto" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-5 space-y-4">
|
||||
<p class="text-sm leading-relaxed text-zinc-300">{{ currentStep.description }}</p>
|
||||
<div class="rounded-xl border border-blue-500/20 bg-blue-500/5 p-3">
|
||||
<p class="text-xs text-zinc-400">
|
||||
建议操作:
|
||||
<span class="text-zinc-200 font-semibold">{{ currentStep.actionHint }}</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="px-5 py-4 border-t border-zinc-800 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
|
||||
>
|
||||
<p class="text-xs text-zinc-500 font-semibold">{{ stepIndex + 1 }} / {{ steps.length }}</p>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
v-if="stepIndex > 0"
|
||||
class="px-3.5 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-sm"
|
||||
@click="prevStep"
|
||||
>
|
||||
上一步
|
||||
</button>
|
||||
<button
|
||||
class="px-3.5 py-2 rounded-lg border border-zinc-700 text-zinc-200 hover:bg-zinc-900 text-sm"
|
||||
@click="goCurrentStep"
|
||||
>
|
||||
前往此功能
|
||||
</button>
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold"
|
||||
@click="nextStep"
|
||||
>
|
||||
{{ stepIndex === steps.length - 1 ? '完成引导' : '下一步' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { X } from '@lucide/vue'
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['navigate', 'close', 'complete'])
|
||||
|
||||
const steps = [
|
||||
{
|
||||
tab: 'overview',
|
||||
title: '先看数据概览',
|
||||
description: '这里会显示待处理歌曲、播放数据和系统运行状态,建议每次值班先看这一页。',
|
||||
actionHint: '进入“数据概览”快速确认今日状态。'
|
||||
},
|
||||
{
|
||||
tab: 'songs',
|
||||
title: '处理歌曲审核',
|
||||
description: '在歌曲管理中可试听、拒绝或保留歌曲,确保内容合规后再进入排期流程。',
|
||||
actionHint: '优先处理新提交和被举报的歌曲。'
|
||||
},
|
||||
{
|
||||
tab: 'schedule',
|
||||
title: '安排节目排期',
|
||||
description: '将审核通过的歌曲拖拽到目标日期与时段,调整顺序后发布,用户即可看到最新排期。',
|
||||
actionHint: '排期完成后记得点击发布,避免用户看到旧数据。'
|
||||
},
|
||||
{
|
||||
tab: 'site-config',
|
||||
title: '维护站点配置',
|
||||
description: '可统一修改站点标题、版本号和投稿规则,保存后会对前台页面立即生效。',
|
||||
actionHint: '发布新版本时,建议同步更新“站点版本号”。'
|
||||
},
|
||||
{
|
||||
tab: 'workflow-tutorial',
|
||||
title: '查看完整流程教程',
|
||||
description: '如果需要标准值班流程和FAQ,可在“流程与教程”页面查看详细图文说明。',
|
||||
actionHint: '把该页面作为新人培训材料。'
|
||||
}
|
||||
]
|
||||
|
||||
const stepIndex = ref(0)
|
||||
const currentStep = computed(() => steps[stepIndex.value])
|
||||
|
||||
watch(
|
||||
() => props.visible,
|
||||
(visible) => {
|
||||
if (visible) {
|
||||
stepIndex.value = 0
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
const goCurrentStep = () => {
|
||||
emit('navigate', currentStep.value.tab)
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (stepIndex.value > 0) {
|
||||
stepIndex.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
const nextStep = () => {
|
||||
goCurrentStep()
|
||||
if (stepIndex.value < steps.length - 1) {
|
||||
stepIndex.value += 1
|
||||
return
|
||||
}
|
||||
emit('complete')
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,8 +2,8 @@
|
||||
<div class="backup-manager">
|
||||
<!-- 标题 -->
|
||||
<div class="header">
|
||||
<h3>{{ locale.title }}</h3>
|
||||
<p class="description">{{ locale.desc }}</p>
|
||||
<h3>数据库备份</h3>
|
||||
<p class="description">导出和导入数据库备份</p>
|
||||
</div>
|
||||
|
||||
<!-- 主要功能区 -->
|
||||
@@ -17,15 +17,15 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h4>{{ locale.exportTitle }}</h4>
|
||||
<p>{{ locale.exportDesc }}</p>
|
||||
<h4>导出备份</h4>
|
||||
<p>创建数据库备份文件</p>
|
||||
<button
|
||||
:disabled="createLoading"
|
||||
class="action-btn primary"
|
||||
@click="showCreateModal = true"
|
||||
>
|
||||
<span v-if="createLoading">{{ locale.exporting }}</span>
|
||||
<span v-else>{{ locale.startExport }}</span>
|
||||
<span v-if="createLoading">导出中...</span>
|
||||
<span v-else>开始导出</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,25 +39,25 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
<h4>{{ locale.importTitle }}</h4>
|
||||
<p>{{ locale.importDesc }}</p>
|
||||
<h4>导入备份</h4>
|
||||
<p>从备份文件恢复数据</p>
|
||||
<button
|
||||
:disabled="uploadLoading"
|
||||
class="action-btn secondary"
|
||||
@click="showUploadModal = true"
|
||||
>
|
||||
<span v-if="uploadLoading">{{ locale.importing }}</span>
|
||||
<span v-else>{{ locale.chooseFile }}</span>
|
||||
<span v-if="uploadLoading">导入中...</span>
|
||||
<span v-else>选择文件</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建备份模态框 -->
|
||||
<div v-if="showCreateModal" class="backupmanager-modal-overlay" @click="showCreateModal = false">
|
||||
<div v-if="showCreateModal" class="modal-overlay" @click="showCreateModal = false">
|
||||
<div class="modal" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>{{ locale.createTitle }}</h3>
|
||||
<h3>创建数据库备份</h3>
|
||||
<button class="close-btn" @click="showCreateModal = false">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<line x1="18" x2="6" y1="6" y2="18" />
|
||||
@@ -67,43 +67,43 @@
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="form-group">
|
||||
<label>{{ locale.backupType }}</label>
|
||||
<label>备份类型</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-option">
|
||||
<input v-model="createForm.tables" type="radio" value="all" >
|
||||
<span>{{ locale.fullBackup }}</span>
|
||||
<small>{{ locale.fullBackupDesc }}</small>
|
||||
<span>完整备份(推荐)</span>
|
||||
<small>备份所有数据表</small>
|
||||
</label>
|
||||
<label class="radio-option">
|
||||
<input v-model="createForm.tables" type="radio" value="users" >
|
||||
<span>{{ locale.userDataOnly }}</span>
|
||||
<small>{{ locale.userDataOnlyDesc }}</small>
|
||||
<span>仅用户数据</span>
|
||||
<small>只备份用户相关数据</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label class="checkbox-option">
|
||||
<input v-model="createForm.includeSystemData" type="checkbox" >
|
||||
<span>{{ locale.includeSystemSettings }}</span>
|
||||
<small>{{ locale.includeSystemSettingsDesc }}</small>
|
||||
<span>包含系统设置</span>
|
||||
<small>包含系统配置和设置数据</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="action-btn secondary" @click="showCreateModal = false">{{ locale.cancel }}</button>
|
||||
<button class="action-btn secondary" @click="showCreateModal = false">取消</button>
|
||||
<button :disabled="createLoading" class="action-btn primary" @click="createBackup">
|
||||
<span v-if="createLoading">{{ locale.creating }}</span>
|
||||
<span v-else>{{ locale.createBackup }}</span>
|
||||
<span v-if="createLoading">创建中...</span>
|
||||
<span v-else>创建备份</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 导入备份模态框 -->
|
||||
<div v-if="showUploadModal" class="backupmanager-modal-overlay" @click="showUploadModal = false">
|
||||
<div v-if="showUploadModal" class="modal-overlay" @click="showUploadModal = false">
|
||||
<div class="modal" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3>{{ locale.importFileTitle }}</h3>
|
||||
<h3>导入备份文件</h3>
|
||||
<button class="close-btn" @click="showUploadModal = false">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<line x1="18" x2="6" y1="6" y2="18" />
|
||||
@@ -126,8 +126,8 @@
|
||||
<polyline points="17,8 12,3 7,8" />
|
||||
<line x1="12" x2="12" y1="3" y2="15" />
|
||||
</svg>
|
||||
<h4>{{ locale.uploadTitle }}</h4>
|
||||
<p>{{ locale.uploadDesc }}</p>
|
||||
<h4>选择或拖拽备份文件</h4>
|
||||
<p>支持 .json 格式的备份文件,最大 100MB</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
accept=".json,application/json"
|
||||
@@ -161,17 +161,17 @@
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>{{ locale.restoreMode }}</label>
|
||||
<label>恢复模式</label>
|
||||
<div class="radio-group">
|
||||
<label class="radio-option">
|
||||
<input v-model="restoreForm.mode" type="radio" value="merge" >
|
||||
<span>{{ locale.mergeMode }}</span>
|
||||
<small>{{ locale.mergeModeDesc }}</small>
|
||||
<span>合并模式(推荐)</span>
|
||||
<small>更新现有记录,添加新记录</small>
|
||||
</label>
|
||||
<label class="radio-option">
|
||||
<input v-model="restoreForm.mode" type="radio" value="replace" >
|
||||
<span>{{ locale.replaceMode }}</span>
|
||||
<small>{{ locale.replaceModeDesc }}</small>
|
||||
<span>替换模式</span>
|
||||
<small>先清空数据,然后导入备份</small>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
@@ -179,8 +179,8 @@
|
||||
<div v-if="restoreForm.mode === 'replace'" class="form-group">
|
||||
<label class="checkbox-option danger">
|
||||
<input v-model="restoreForm.clearExisting" type="checkbox" >
|
||||
<span>{{ locale.confirmClear }}</span>
|
||||
<small>{{ locale.confirmClearDesc }}</small>
|
||||
<span>我确认要清空现有数据</span>
|
||||
<small>此操作不可逆,请谨慎操作</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -190,8 +190,8 @@
|
||||
>
|
||||
<label class="checkbox-option">
|
||||
<input v-model="restoreForm.overwriteSuperAdmin" type="checkbox" >
|
||||
<span>{{ locale.overwriteSuperAdmin }}</span>
|
||||
<small>{{ locale.overwriteSuperAdminDesc }}</small>
|
||||
<span>覆盖备份中的超级管理员账号数据</span>
|
||||
<small>关闭时将保留当前超级管理员及其第三方绑定、2FA相关数据</small>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
@@ -204,13 +204,13 @@
|
||||
<line x1="12" x2="12.01" y1="17" y2="17" />
|
||||
</svg>
|
||||
<div>
|
||||
<h4>{{ locale.warningTitle }}</h4>
|
||||
<p>{{ locale.warningDesc }}</p>
|
||||
<h4>注意</h4>
|
||||
<p>导入备份将会影响现有数据,请确保您了解操作的后果。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="action-btn secondary" @click="showUploadModal = false">{{ locale.cancel }}</button>
|
||||
<button class="action-btn secondary" @click="showUploadModal = false">取消</button>
|
||||
<button
|
||||
:disabled="
|
||||
!selectedFile ||
|
||||
@@ -220,8 +220,8 @@
|
||||
class="action-btn primary"
|
||||
@click="uploadFile"
|
||||
>
|
||||
<span v-if="uploadLoading">{{ locale.importing }}</span>
|
||||
<span v-else>{{ locale.startImport }}</span>
|
||||
<span v-if="uploadLoading">导入中...</span>
|
||||
<span v-else>开始导入</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -230,13 +230,8 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { ref } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => admin.value?.backupManager || {})
|
||||
const { msg: getLocaleText } = useLocaleText(locale)
|
||||
|
||||
// 响应式数据
|
||||
const createLoading = ref(false)
|
||||
@@ -310,14 +305,14 @@ const createBackup = async () => {
|
||||
// 显示成功消息
|
||||
const sizeText = backup.size ? ` (${formatFileSize(backup.size)})` : ''
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(getLocaleText('downloaded', backup.filename, sizeText), 'success')
|
||||
window.$showNotification(`备份文件已下载: ${backup.filename}${sizeText}`, 'success')
|
||||
}
|
||||
|
||||
console.log('✅ 文件下载成功:', backup.filename)
|
||||
} catch (downloadError) {
|
||||
console.error('下载失败:', downloadError)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(getLocaleText('fileDownloadFailed', downloadError.message), 'error')
|
||||
window.$showNotification('文件下载失败: ' + downloadError.message, 'error')
|
||||
}
|
||||
}
|
||||
} else if (backup.downloadMode === 'file' && backup.filename) {
|
||||
@@ -353,20 +348,20 @@ const createBackup = async () => {
|
||||
// 显示成功消息
|
||||
const sizeText = backup.size ? ` (${formatFileSize(backup.size)})` : ''
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(getLocaleText('downloaded', backup.filename, sizeText), 'success')
|
||||
window.$showNotification(`备份文件已下载: ${backup.filename}${sizeText}`, 'success')
|
||||
}
|
||||
|
||||
console.log('✅ 文件下载成功:', backup.filename)
|
||||
} catch (downloadError) {
|
||||
console.error('文件下载失败:', downloadError)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(getLocaleText('fileDownloadFailed', downloadError.message), 'error')
|
||||
window.$showNotification('文件下载失败: ' + downloadError.message, 'error')
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.error('无效的下载模式或缺少数据')
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(locale.value?.invalidResponse || '服务器返回了无效响应', 'error')
|
||||
window.$showNotification('备份创建失败:无效的响应格式', 'error')
|
||||
}
|
||||
showCreateModal.value = false
|
||||
return
|
||||
@@ -374,16 +369,16 @@ const createBackup = async () => {
|
||||
} else {
|
||||
console.error('服务器响应格式错误:', response)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(locale.value?.serverResponseInvalid || '服务器响应格式无效', 'error')
|
||||
window.$showNotification('备份创建失败:服务器响应格式错误', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
showCreateModal.value = false
|
||||
} catch (error) {
|
||||
console.error('创建备份失败:', error)
|
||||
const errorMessage = error.data?.message || error.message || locale.value?.unknownError || '未知错误'
|
||||
const errorMessage = error.data?.message || error.message || '未知错误'
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(getLocaleText('createFailed', errorMessage), 'error')
|
||||
window.$showNotification('创建备份失败: ' + errorMessage, 'error')
|
||||
}
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
@@ -404,7 +399,7 @@ const parseBackupSuperAdmin = async (file) => {
|
||||
hasSuperAdminInBackup.value = false
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(locale.value?.parseFailed || '备份文件解析失败', 'error')
|
||||
window.$showNotification('无法解析备份文件,请检查文件格式是否正确。', 'error')
|
||||
}
|
||||
console.error('解析备份文件失败:', error)
|
||||
}
|
||||
@@ -463,7 +458,7 @@ const uploadFile = async () => {
|
||||
// 显示成功通知
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(
|
||||
getLocaleText('restoreSuccess', response.details?.tablesProcessed || 0, response.details?.recordsRestored || 0),
|
||||
`备份恢复成功!处理了 ${response.details?.tablesProcessed || 0} 个表,恢复了 ${response.details?.recordsRestored || 0} 条记录`,
|
||||
'success'
|
||||
)
|
||||
|
||||
@@ -471,7 +466,7 @@ const uploadFile = async () => {
|
||||
if (response.details?.errors && response.details.errors.length > 0) {
|
||||
setTimeout(() => {
|
||||
window.$showNotification(
|
||||
getLocaleText('restoreWarning', response.details.errors.length),
|
||||
`恢复过程中发生了 ${response.details.errors.length} 个错误`,
|
||||
'warning'
|
||||
)
|
||||
}, 1000)
|
||||
@@ -479,7 +474,7 @@ const uploadFile = async () => {
|
||||
|
||||
// 显示即将重定向的通知
|
||||
setTimeout(() => {
|
||||
window.$showNotification(locale.value?.restoreCompleteRedirect || '恢复完成,即将刷新页面', 'info')
|
||||
window.$showNotification('数据库恢复完成,3秒后将返回首页重新登录', 'info')
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
@@ -498,13 +493,13 @@ const uploadFile = async () => {
|
||||
}, 5000)
|
||||
} else {
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(getLocaleText('importFailed', response.message || locale.value.unknownError), 'error')
|
||||
window.$showNotification('备份导入失败: ' + (response.message || '未知错误'), 'error')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('导入备份失败:', error)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(getLocaleText('importFailed', error.data?.message || error.message), 'error')
|
||||
window.$showNotification('导入备份失败: ' + (error.data?.message || error.message), 'error')
|
||||
}
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
@@ -525,8 +520,8 @@ const formatFileSize = (bytes) => {
|
||||
/* 主容器 */
|
||||
.backup-manager {
|
||||
min-height: 100vh;
|
||||
background: var(--panel-bg-deep);
|
||||
color: var(--panel-border-light);
|
||||
background: var(--bg-primary, #1a1a1a);
|
||||
color: var(--text-primary, #e5e5e5);
|
||||
padding: 2rem;
|
||||
position: relative;
|
||||
}
|
||||
@@ -540,13 +535,13 @@ const formatFileSize = (bytes) => {
|
||||
.header h3 {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
margin: 0 0 1rem 0;
|
||||
}
|
||||
|
||||
.header .description {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-muted-dark);
|
||||
color: var(--text-secondary, #a0a0a0);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -561,8 +556,8 @@ const formatFileSize = (bytes) => {
|
||||
|
||||
/* 操作卡片 */
|
||||
.action-card {
|
||||
background: var(--panel-border);
|
||||
border: 1px solid var(--panel-border-dark);
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
border: 1px solid var(--border-secondary, #404040);
|
||||
border-radius: 12px;
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
@@ -570,8 +565,8 @@ const formatFileSize = (bytes) => {
|
||||
}
|
||||
|
||||
.action-card:hover {
|
||||
border-color: var(--panel-border-dark);
|
||||
background: var(--panel-border-subtle);
|
||||
border-color: #606060;
|
||||
background: var(--bg-tertiary, #2f2f2f);
|
||||
}
|
||||
|
||||
.action-card .card-content {
|
||||
@@ -587,12 +582,12 @@ const formatFileSize = (bytes) => {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
margin: 0 auto 1.5rem;
|
||||
background: var(--panel-border-dark);
|
||||
background: #404040;
|
||||
border-radius: 12px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
}
|
||||
|
||||
.action-icon svg {
|
||||
@@ -604,12 +599,12 @@ const formatFileSize = (bytes) => {
|
||||
.action-card h4 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
.action-card p {
|
||||
color: var(--text-muted-dark);
|
||||
color: #a0a0a0;
|
||||
font-size: 1rem;
|
||||
margin: 0 0 2rem 0;
|
||||
line-height: 1.6;
|
||||
@@ -634,24 +629,24 @@ const formatFileSize = (bytes) => {
|
||||
}
|
||||
|
||||
.action-btn.primary {
|
||||
background: var(--color-accent);
|
||||
background: #4a90e2;
|
||||
}
|
||||
|
||||
.action-btn.primary:hover {
|
||||
background: var(--color-accent);
|
||||
background: #357abd;
|
||||
}
|
||||
|
||||
.action-btn.secondary {
|
||||
background: var(--panel-border-dark);
|
||||
background: #606060;
|
||||
}
|
||||
|
||||
.action-btn.secondary:hover {
|
||||
background: var(--panel-border-dark);
|
||||
background: #505050;
|
||||
}
|
||||
|
||||
.action-btn:disabled {
|
||||
background: var(--panel-border-dark) !important;
|
||||
color: var(--text-muted) !important;
|
||||
background: #404040 !important;
|
||||
color: #808080 !important;
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
@@ -662,13 +657,13 @@ const formatFileSize = (bytes) => {
|
||||
}
|
||||
|
||||
/* 模态框样式 */
|
||||
.backupmanager-modal-overlay {
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--modal-overlay);
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -677,8 +672,8 @@ const formatFileSize = (bytes) => {
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--panel-border);
|
||||
border: 1px solid var(--panel-border-dark);
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
border: 1px solid var(--border-secondary, #404040);
|
||||
border-radius: 12px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
@@ -696,19 +691,19 @@ const formatFileSize = (bytes) => {
|
||||
|
||||
.current-status h4 {
|
||||
margin: 0 0 15px 0;
|
||||
color: var(--color-accent);
|
||||
color: #4a90e2;
|
||||
font-size: 18px;
|
||||
}
|
||||
|
||||
.progress-stats p {
|
||||
margin: 5px 0;
|
||||
color: var(--text-muted-dark);
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
width: 100%;
|
||||
height: 8px;
|
||||
background: var(--panel-border-dark);
|
||||
background: #404040;
|
||||
border-radius: 4px;
|
||||
margin: 15px 0;
|
||||
overflow: hidden;
|
||||
@@ -716,7 +711,7 @@ const formatFileSize = (bytes) => {
|
||||
|
||||
.progress-fill {
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, var(--color-accent), var(--color-accent));
|
||||
background: linear-gradient(90deg, #4a90e2, #357abd);
|
||||
border-radius: 4px;
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
@@ -729,7 +724,7 @@ const formatFileSize = (bytes) => {
|
||||
.progress-details h5,
|
||||
.progress-errors h5 {
|
||||
margin: 0 0 10px 0;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
}
|
||||
@@ -738,17 +733,17 @@ const formatFileSize = (bytes) => {
|
||||
.errors-list {
|
||||
max-height: 150px;
|
||||
overflow-y: auto;
|
||||
border: 1px solid var(--panel-border-dark);
|
||||
border: 1px solid #505050;
|
||||
border-radius: 6px;
|
||||
padding: 10px;
|
||||
background: var(--text-primary);
|
||||
background: var(--bg-quaternary, #333333);
|
||||
}
|
||||
|
||||
.detail-item {
|
||||
padding: 4px 0;
|
||||
color: var(--text-muted-dark);
|
||||
color: #a0a0a0;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--panel-border-dark);
|
||||
border-bottom: 1px solid var(--border-secondary, #404040);
|
||||
}
|
||||
|
||||
.detail-item:last-child {
|
||||
@@ -757,9 +752,9 @@ const formatFileSize = (bytes) => {
|
||||
|
||||
.error-item {
|
||||
padding: 4px 0;
|
||||
color: var(--color-error-light);
|
||||
color: #ff6b6b;
|
||||
font-size: 13px;
|
||||
border-bottom: 1px solid var(--panel-bg-darker);
|
||||
border-bottom: 1px solid var(--border-secondary, #4a2a2a);
|
||||
}
|
||||
|
||||
.error-item:last-child {
|
||||
@@ -777,14 +772,14 @@ const formatFileSize = (bytes) => {
|
||||
.modal-title {
|
||||
font-size: 1.75rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: var(--panel-border-dark);
|
||||
background: #404040;
|
||||
border: none;
|
||||
color: var(--text-muted-dark);
|
||||
color: #a0a0a0;
|
||||
cursor: pointer;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
@@ -792,8 +787,8 @@ const formatFileSize = (bytes) => {
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--panel-border-dark);
|
||||
color: var(--text-primary);
|
||||
background: #505050;
|
||||
color: var(--text-primary, #ffffff);
|
||||
}
|
||||
|
||||
.close-btn svg {
|
||||
@@ -807,11 +802,11 @@ const formatFileSize = (bytes) => {
|
||||
|
||||
.modal-footer {
|
||||
padding: 1.5rem 2rem;
|
||||
border-top: 1px solid var(--panel-border-dark);
|
||||
border-top: 1px solid var(--border-secondary, #404040);
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
justify-content: flex-end;
|
||||
background: var(--panel-border-subtle);
|
||||
background: var(--bg-tertiary, #252525);
|
||||
}
|
||||
|
||||
.modal-footer button {
|
||||
@@ -827,7 +822,7 @@ const formatFileSize = (bytes) => {
|
||||
display: block;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@@ -843,8 +838,8 @@ const formatFileSize = (bytes) => {
|
||||
align-items: flex-start;
|
||||
gap: 1rem;
|
||||
padding: 1.25rem;
|
||||
background: var(--text-primary);
|
||||
border: 1px solid var(--panel-border-dark);
|
||||
background: var(--bg-quaternary, #333333);
|
||||
border: 1px solid #505050;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
@@ -852,8 +847,8 @@ const formatFileSize = (bytes) => {
|
||||
|
||||
.radio-option:hover,
|
||||
.checkbox-option:hover {
|
||||
border-color: var(--panel-border-dark);
|
||||
background: var(--panel-border-dark);
|
||||
border-color: #606060;
|
||||
background: #383838;
|
||||
}
|
||||
|
||||
.radio-option input[type='radio'],
|
||||
@@ -861,7 +856,7 @@ const formatFileSize = (bytes) => {
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
margin: 0;
|
||||
accent-color: var(--color-accent);
|
||||
accent-color: #4a90e2;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -875,7 +870,7 @@ const formatFileSize = (bytes) => {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -883,21 +878,21 @@ const formatFileSize = (bytes) => {
|
||||
.checkbox-option small {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted-dark);
|
||||
color: #a0a0a0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.checkbox-option.danger {
|
||||
border-color: var(--color-error-hover);
|
||||
background: var(--backup-manager-danger-bg);
|
||||
border-color: #d32f2f;
|
||||
background: rgba(211, 47, 47, 0.1);
|
||||
}
|
||||
|
||||
.checkbox-option.danger span {
|
||||
color: var(--color-error-light);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.checkbox-option.danger small {
|
||||
color: var(--color-error-light);
|
||||
color: #ff8a80;
|
||||
}
|
||||
|
||||
/* 文件上传区域 */
|
||||
@@ -906,37 +901,37 @@ const formatFileSize = (bytes) => {
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 2px dashed var(--panel-border-dark);
|
||||
border: 2px dashed #505050;
|
||||
border-radius: 8px;
|
||||
padding: 3rem 2rem;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: var(--text-primary);
|
||||
background: var(--bg-quaternary, #333333);
|
||||
}
|
||||
|
||||
.upload-area:hover,
|
||||
.upload-area.drag-over {
|
||||
border-color: var(--color-accent);
|
||||
background: var(--panel-border-dark);
|
||||
border-color: var(--border-secondary, #4a90e2);
|
||||
background: #383838;
|
||||
}
|
||||
|
||||
.upload-area svg {
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
color: var(--color-accent);
|
||||
color: #4a90e2;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.upload-area h4 {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.upload-area p {
|
||||
color: var(--text-muted-dark);
|
||||
color: #a0a0a0;
|
||||
font-size: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
@@ -946,8 +941,8 @@ const formatFileSize = (bytes) => {
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1.25rem;
|
||||
background: var(--text-primary);
|
||||
border: 1px solid var(--panel-border-dark);
|
||||
background: var(--bg-quaternary, #333333);
|
||||
border: 1px solid #505050;
|
||||
border-radius: 8px;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
@@ -962,7 +957,7 @@ const formatFileSize = (bytes) => {
|
||||
.file-info svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--color-accent);
|
||||
color: #4a90e2;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -974,28 +969,28 @@ const formatFileSize = (bytes) => {
|
||||
display: block;
|
||||
font-size: 1rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.file-size {
|
||||
display: block;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted-dark);
|
||||
color: #a0a0a0;
|
||||
}
|
||||
|
||||
.remove-file-btn {
|
||||
background: var(--backup-manager-file-remove-bg);
|
||||
background: rgba(211, 47, 47, 0.2);
|
||||
border: none;
|
||||
padding: 0.75rem;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
color: var(--color-error-light);
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.remove-file-btn:hover {
|
||||
background: var(--backup-manager-file-remove-bg-hover);
|
||||
background: rgba(211, 47, 47, 0.3);
|
||||
}
|
||||
|
||||
.remove-file-btn svg {
|
||||
@@ -1005,8 +1000,8 @@ const formatFileSize = (bytes) => {
|
||||
|
||||
/* 警告框 */
|
||||
.warning-box {
|
||||
background: var(--backup-manager-warning-box-bg);
|
||||
border: 1px solid var(--color-orange);
|
||||
background: rgba(255, 152, 0, 0.1);
|
||||
border: 1px solid #ff9800;
|
||||
border-radius: 8px;
|
||||
padding: 1.5rem;
|
||||
display: flex;
|
||||
@@ -1018,7 +1013,7 @@ const formatFileSize = (bytes) => {
|
||||
.warning-box svg {
|
||||
width: 1.5rem;
|
||||
height: 1.5rem;
|
||||
color: var(--color-orange);
|
||||
color: #ff9800;
|
||||
flex-shrink: 0;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
@@ -1028,14 +1023,14 @@ const formatFileSize = (bytes) => {
|
||||
}
|
||||
|
||||
.warning-box h4 {
|
||||
color: var(--color-orange);
|
||||
color: #ff9800;
|
||||
font-weight: 600;
|
||||
margin: 0 0 0.5rem 0;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.warning-box p {
|
||||
color: var(--color-orange);
|
||||
color: #ffb74d;
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.5;
|
||||
@@ -1050,7 +1045,7 @@ const formatFileSize = (bytes) => {
|
||||
border-radius: 8px;
|
||||
color: white;
|
||||
font-weight: 500;
|
||||
box-shadow: 0 4px 12px var(--mask-30);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||
z-index: 1001;
|
||||
animation: slideInRight 0.4s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
display: flex;
|
||||
@@ -1061,23 +1056,23 @@ const formatFileSize = (bytes) => {
|
||||
}
|
||||
|
||||
.notification.success {
|
||||
background: var(--color-success);
|
||||
border: 1px solid var(--color-success);
|
||||
background: var(--bg-tertiary, #2e7d32);
|
||||
border: 1px solid var(--border-secondary, #4caf50);
|
||||
}
|
||||
|
||||
.notification.error {
|
||||
background: var(--color-error-hover);
|
||||
border: 1px solid var(--color-error);
|
||||
background: #d32f2f;
|
||||
border: 1px solid #f44336;
|
||||
}
|
||||
|
||||
.notification.warning {
|
||||
background: var(--color-orange);
|
||||
border: 1px solid var(--color-orange);
|
||||
background: #f57c00;
|
||||
border: 1px solid #ff9800;
|
||||
}
|
||||
|
||||
.notification.info {
|
||||
background: var(--color-accent-hover);
|
||||
border: 1px solid var(--color-accent);
|
||||
background: var(--bg-secondary, #1976d2);
|
||||
border: 1px solid var(--border-secondary, #2196f3);
|
||||
}
|
||||
|
||||
@keyframes slideInRight {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -5,104 +5,104 @@
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-text-primary tracking-tight flex items-center gap-3">
|
||||
{{ locale.title }}
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
歌曲黑名单管理
|
||||
</h2>
|
||||
<p class="text-xs text-text-tertiary mt-1 font-medium">
|
||||
{{ locale.desc }}
|
||||
<p class="text-xs text-zinc-500 mt-1 font-medium">
|
||||
禁止特定歌曲或包含关键词的歌曲被点播,维护广播内容的健康与合规
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加黑名单项表单 -->
|
||||
<section class="bg-bg-secondary-40 border border-border-secondary rounded-xl p-8 shadow-xl">
|
||||
<h3 class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em] mb-6 px-1">
|
||||
{{ locale.addTitle }}
|
||||
<section class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-8 shadow-xl">
|
||||
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] mb-6 px-1">
|
||||
添加黑名单项
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6 items-end">
|
||||
<div class="xl:col-span-2 space-y-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.type }}</label
|
||||
<div class="xl:col-span-3 space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>封禁类型</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-1 p-1 bg-bg-primary border border-border-secondary rounded-lg">
|
||||
<div class="flex p-1 bg-zinc-950 border border-zinc-800 rounded-lg gap-1">
|
||||
<button
|
||||
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="newItem.type === 'SONG' ? 'bg-bg-tertiary text-primary' : 'text-text-disabled'"
|
||||
@click="selectType('SONG')"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="newItem.type === 'SONG' ? 'bg-zinc-800 text-blue-400' : 'text-zinc-600'"
|
||||
@click="newItem.type = 'SONG'"
|
||||
>
|
||||
<Music :size="12" /> {{ locale.song }}
|
||||
<Music :size="12" /> 歌曲
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="newItem.type === 'KEYWORD' ? 'bg-bg-tertiary text-info' : 'text-text-disabled'"
|
||||
@click="selectType('KEYWORD')"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="newItem.type === 'KEYWORD' ? 'bg-zinc-800 text-purple-400' : 'text-zinc-600'"
|
||||
@click="newItem.type = 'KEYWORD'"
|
||||
>
|
||||
<Type :size="12" /> {{ locale.keyword }}
|
||||
<Type :size="12" /> 关键词
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="newItem.type === 'LANGUAGE' ? 'bg-bg-tertiary text-warning' : 'text-text-disabled'"
|
||||
@click="selectType('LANGUAGE')"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="newItem.type === 'LANGUAGE' ? 'bg-zinc-800 text-amber-400' : 'text-zinc-600'"
|
||||
@click="newItem.type = 'LANGUAGE'"
|
||||
>
|
||||
<Languages :size="12" /> {{ locale.language }}
|
||||
<Languages :size="12" /> 语种
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="newItem.type === 'GENRE' ? 'bg-bg-tertiary text-success' : 'text-text-disabled'"
|
||||
@click="selectType('GENRE')"
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="newItem.type === 'GENRE' ? 'bg-zinc-800 text-emerald-400' : 'text-zinc-600'"
|
||||
@click="newItem.type = 'GENRE'"
|
||||
>
|
||||
<AudioLines :size="12" /> {{ locale.genre }}
|
||||
<Disc3 :size="12" /> 曲风
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="xl:col-span-4 space-y-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1">
|
||||
{{ valueLabel }}
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
|
||||
{{ typeValueLabel }}
|
||||
</label>
|
||||
<input
|
||||
v-if="newItem.type === 'SONG' || newItem.type === 'KEYWORD'"
|
||||
v-model="newItem.value"
|
||||
type="text"
|
||||
:placeholder="
|
||||
newItem.type === 'SONG' ? locale.songPlaceholder : locale.keywordPlaceholder
|
||||
"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 placeholder:text-text-secondary transition-all"
|
||||
:placeholder="typePlaceholder"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
|
||||
>
|
||||
<CustomSelect
|
||||
v-else
|
||||
v-model="newItem.value"
|
||||
:options="typeValueOptions"
|
||||
/>
|
||||
<p
|
||||
v-if="newItem.type === 'LANGUAGE' || newItem.type === 'GENRE'"
|
||||
class="text-[10px] text-text-secondary px-1 leading-relaxed"
|
||||
>
|
||||
{{ newItem.type === 'LANGUAGE' ? locale.languageHint : locale.genreHint }}
|
||||
</div>
|
||||
|
||||
<div v-if="newItem.type === 'LANGUAGE' || newItem.type === 'GENRE'" class="xl:col-span-5 xl:col-start-auto space-y-1">
|
||||
<p class="text-[10px] text-zinc-600 font-bold leading-relaxed px-1">
|
||||
<template v-if="newItem.type === 'LANGUAGE'">
|
||||
💡 支持多语种匹配:中文 / 英文 / 日文 / 韩文 / 俄文 / 阿拉伯文 等,支持逗号分隔,例如:<span class="text-amber-400">日文,韩文</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
💡 支持曲风关键词,可逗号分隔,例如:<span class="text-emerald-400">DJ,摇滚,rap</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="xl:col-span-4 space-y-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.reason }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>封禁原因 (可选)</label
|
||||
>
|
||||
<input
|
||||
v-model="newItem.reason"
|
||||
type="text"
|
||||
:placeholder="locale.reasonPlaceholder"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 placeholder:text-text-secondary transition-all"
|
||||
placeholder="简述加入黑名单的原因"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="xl:col-span-2">
|
||||
<button
|
||||
:disabled="!newItem.value || loading"
|
||||
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs font-black rounded-lg shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
|
||||
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
@click="addBlacklistItem"
|
||||
>
|
||||
<Plus v-if="!loading" :size="16" />
|
||||
<AppSpinner :size="16" />
|
||||
{{ loading ? locale.processing : locale.addButton }}
|
||||
<div
|
||||
v-else
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
/>
|
||||
{{ loading ? '处理中...' : '添加到黑名单' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,30 +110,28 @@
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<div
|
||||
class="bg-bg-secondary-20 border border-border-secondary rounded-xl p-4 flex flex-col md:flex-row gap-4 items-center"
|
||||
class="bg-zinc-900/20 border border-zinc-800 rounded-xl p-4 flex flex-col md:flex-row gap-4 items-center"
|
||||
>
|
||||
<div class="flex-1 relative w-full">
|
||||
<Search class="absolute left-4 top-1/2 -translate-y-1/2 text-text-secondary" :size="18" />
|
||||
<Search class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700" :size="18" />
|
||||
<input
|
||||
v-model="filters.search"
|
||||
type="text"
|
||||
:placeholder="locale.searchPlaceholder"
|
||||
class="w-full bg-bg-primary-50 border border-border-secondary rounded-lg pl-12 pr-4 py-3 text-sm text-text-primary focus:outline-none focus:border-primary-30 placeholder:text-text-secondary transition-all"
|
||||
placeholder="在黑名单中搜索..."
|
||||
class="w-full bg-zinc-950/50 border border-zinc-800 rounded-lg pl-12 pr-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
|
||||
@input="debounceSearch"
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full md:w-auto">
|
||||
<CustomSelect
|
||||
:label="locale.typeFilter"
|
||||
v-model="filters.type"
|
||||
:options="typeOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
label="类型筛选"
|
||||
:value="typeFilterLabel"
|
||||
:options="['全部类型', '具体歌曲', '关键词', '语种', '曲风']"
|
||||
class="flex-1 md:w-48"
|
||||
@change="() => { pagination.page = 1; loadBlacklist(); }"
|
||||
@change="handleTypeFilterChange"
|
||||
/>
|
||||
<button
|
||||
class="p-3 bg-bg-primary border border-border-secondary rounded-lg text-text-disabled hover:text-primary transition-all active:scale-95 flex items-center justify-center"
|
||||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-600 hover:text-blue-400 transition-all active:scale-95"
|
||||
@click="loadBlacklist"
|
||||
>
|
||||
<Filter :size="18" />
|
||||
@@ -144,8 +142,8 @@
|
||||
<!-- 黑名单列表 -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<h4 class="text-[10px] font-black text-text-disabled uppercase tracking-widest">
|
||||
{{ locale.listTitle(pagination.total) }}
|
||||
<h4 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
|
||||
黑名单列表 ({{ pagination.total }} 项)
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
@@ -161,64 +159,67 @@
|
||||
<div
|
||||
v-for="item in blacklist"
|
||||
:key="item.id"
|
||||
class="group flex flex-col lg:flex-row lg:items-center gap-6 p-6 bg-bg-secondary-30 border rounded-xl transition-all hover:border-border-tertiary"
|
||||
:class="[item.isActive ? 'border-border-secondary' : 'border-border-secondary-40 opacity-60']"
|
||||
class="group flex flex-col lg:flex-row lg:items-center gap-6 p-6 bg-zinc-900/30 border rounded-xl transition-all hover:border-zinc-700"
|
||||
:class="[item.isActive ? 'border-zinc-800' : 'border-zinc-800/40 opacity-60']"
|
||||
>
|
||||
<div class="flex-1 flex items-start gap-5">
|
||||
<div
|
||||
class="shrink-0 w-12 h-12 rounded-lg flex items-center justify-center border transition-all"
|
||||
:class="getTypeMeta(item.type).boxClass"
|
||||
:class="getTypeIconClass(item.type)"
|
||||
>
|
||||
<component :is="getTypeMeta(item.type).icon" :size="22" />
|
||||
<Music v-if="item.type === 'SONG'" :size="22" />
|
||||
<Type v-else-if="item.type === 'KEYWORD'" :size="22" />
|
||||
<Languages v-else-if="item.type === 'LANGUAGE'" :size="22" />
|
||||
<Disc3 v-else :size="22" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5 min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded border"
|
||||
:class="getTypeMeta(item.type).badgeClass"
|
||||
:class="getTypeBadgeClass(item.type)"
|
||||
>
|
||||
{{ getTypeMeta(item.type).label }}
|
||||
{{ getTypeLabel(item.type) }}
|
||||
</span>
|
||||
<h5 class="text-base font-black text-text-primary truncate tracking-tight">
|
||||
<h5 class="text-base font-black text-zinc-100 truncate tracking-tight">
|
||||
{{ item.value }}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2 text-text-tertiary font-bold text-[11px]">
|
||||
<MessageSquare :size="12" class="text-text-secondary" />
|
||||
{{ locale.reasonPrefix }} {{ item.reason || locale.noReason }}
|
||||
<div class="flex items-center gap-2 text-zinc-500 font-bold text-[11px]">
|
||||
<MessageSquare :size="12" class="text-zinc-700" />
|
||||
原因: {{ item.reason || '未填写原因' }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 text-text-disabled font-bold text-[10px] uppercase tracking-tighter"
|
||||
class="flex items-center gap-2 text-zinc-600 font-bold text-[10px] uppercase tracking-tighter"
|
||||
>
|
||||
<Clock :size="11" class="text-text-primary" />
|
||||
{{ locale.createdAt }} {{ formatDate(item.createdAt) }}
|
||||
<Clock :size="11" class="text-zinc-800" />
|
||||
创建时间: {{ formatDate(item.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-end gap-3 pt-4 lg:pt-0 border-t lg:border-t-0 border-border-secondary-50"
|
||||
class="flex items-center justify-end gap-3 pt-4 lg:pt-0 border-t lg:border-t-0 border-zinc-800/50"
|
||||
>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
|
||||
:class="[
|
||||
item.isActive
|
||||
? 'bg-bg-tertiary text-text-tertiary hover:text-warning hover:bg-warning-5 hover:border-warning-10 border border-transparent'
|
||||
: 'bg-success text-text-primary shadow-lg shadow-[var(--success-glow-20)]'
|
||||
? 'bg-zinc-800 text-zinc-500 hover:text-amber-500 hover:bg-amber-500/5 hover:border-amber-500/10 border border-transparent'
|
||||
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
|
||||
]"
|
||||
@click="toggleItemStatus(item)"
|
||||
>
|
||||
<Power :size="12" />
|
||||
{{ item.isActive ? locale.disable : locale.enable }}
|
||||
{{ item.isActive ? '禁用' : '启用' }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="p-3 bg-bg-primary border border-border-secondary rounded-xl text-text-secondary hover:text-error hover:border-error-30 transition-all active:scale-95"
|
||||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-700 hover:text-red-500 hover:border-red-500/30 transition-all active:scale-95"
|
||||
@click="deleteItem(item)"
|
||||
>
|
||||
<Trash2 :size="16" />
|
||||
@@ -232,24 +233,26 @@
|
||||
v-if="loading && blacklist.length === 0"
|
||||
class="py-20 flex flex-col items-center justify-center space-y-4"
|
||||
>
|
||||
<AppSpinner :size="48" />
|
||||
<p class="text-text-tertiary text-xs font-bold uppercase tracking-widest">{{ locale.loading }}</p>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-zinc-800 border-t-blue-500 rounded-full animate-spin"
|
||||
/>
|
||||
<p class="text-zinc-500 text-xs font-bold uppercase tracking-widest">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="blacklist.length === 0"
|
||||
class="py-20 flex flex-col items-center justify-center text-center space-y-4 bg-bg-secondary-10 border border-border-secondary-40 border-dashed rounded-xl"
|
||||
class="py-20 flex flex-col items-center justify-center text-center space-y-4 bg-zinc-900/10 border border-zinc-800/40 border-dashed rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-lg bg-bg-primary border border-border-secondary flex items-center justify-center text-text-primary shadow-xl"
|
||||
class="w-16 h-16 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-zinc-800 shadow-xl"
|
||||
>
|
||||
<Ban :size="32" />
|
||||
</div>
|
||||
<div class="space-y-1 px-4">
|
||||
<h6 class="text-sm font-bold text-text-disabled">{{ locale.emptyTitle }}</h6>
|
||||
<p class="text-[10px] text-text-secondary font-bold uppercase tracking-widest">
|
||||
{{ locale.emptyDesc }}
|
||||
<h6 class="text-sm font-bold text-zinc-600">未找到匹配的黑名单项</h6>
|
||||
<p class="text-[10px] text-zinc-700 font-bold uppercase tracking-widest">
|
||||
请尝试调整筛选条件或添加新项目
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -259,19 +262,19 @@
|
||||
<div v-if="pagination.pages > 1" class="flex items-center justify-center gap-4 mt-8">
|
||||
<button
|
||||
:disabled="pagination.page <= 1 || loading"
|
||||
class="p-3 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-text-primary disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95 flex items-center justify-center"
|
||||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 hover:text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
|
||||
@click="changePage(pagination.page - 1)"
|
||||
>
|
||||
<ChevronLeft :size="20" />
|
||||
</button>
|
||||
<div
|
||||
class="px-6 py-3 bg-bg-secondary-50 border border-border-secondary rounded-lg text-[11px] font-black text-text-tertiary uppercase tracking-widest"
|
||||
class="px-6 py-3 bg-zinc-900/50 border border-zinc-800 rounded-lg text-[11px] font-black text-zinc-400 uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.pagination(pagination.page, pagination.pages) }}
|
||||
第 {{ pagination.page }} 页 / 共 {{ pagination.pages }} 页
|
||||
</div>
|
||||
<button
|
||||
:disabled="pagination.page >= pagination.pages || loading"
|
||||
class="p-3 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-text-primary disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95 flex items-center justify-center"
|
||||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 hover:text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
|
||||
@click="changePage(pagination.page + 1)"
|
||||
>
|
||||
<ChevronRight :size="20" />
|
||||
@@ -290,37 +293,39 @@
|
||||
>
|
||||
<div
|
||||
v-if="showDeleteDialog"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300"
|
||||
class="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex flex-col items-center py-8 space-y-6 text-center px-8">
|
||||
<div
|
||||
class="w-16 h-16 rounded-lg bg-error-10 text-error flex items-center justify-center border border-error-20 shadow-2xl shadow-[var(--error-glow-10)]"
|
||||
class="w-16 h-16 rounded-lg bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/20 shadow-2xl shadow-red-900/10"
|
||||
>
|
||||
<Trash2 :size="28" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-lg font-bold text-text-primary">{{ locale.removeTitle }}</h4>
|
||||
<p class="text-xs text-text-tertiary leading-relaxed">
|
||||
{{ locale.removeMessage(deleteTargetItem?.value || '') }}
|
||||
<h4 class="text-lg font-bold text-zinc-100">确认从黑名单移除</h4>
|
||||
<p class="text-xs text-zinc-500 leading-relaxed">
|
||||
确定要移除
|
||||
<span class="text-zinc-300 font-bold">"{{ deleteTargetItem?.value }}"</span>
|
||||
吗?移除后,该项目将恢复为正常状态。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full pt-4">
|
||||
<button
|
||||
class="flex-1 px-4 py-4 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary text-[10px] font-black rounded-lg transition-all uppercase tracking-widest"
|
||||
class="flex-1 px-4 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase tracking-widest"
|
||||
@click="showDeleteDialog = false"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="flex-1 px-4 py-4 bg-error hover:bg-error text-text-primary text-[10px] font-black rounded-lg shadow-xl shadow-[var(--error-glow-20)] transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50"
|
||||
class="flex-1 px-4 py-4 bg-red-600 hover:bg-red-500 text-white text-[10px] font-black rounded-lg shadow-xl shadow-red-900/20 transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ loading ? locale.deleting : locale.confirmRemove }}
|
||||
{{ loading ? '删除中...' : '确认移除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -330,15 +335,12 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { onMounted, reactive, ref, computed } from 'vue'
|
||||
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
|
||||
import {
|
||||
ShieldAlert,
|
||||
Music,
|
||||
Type,
|
||||
Languages,
|
||||
AudioLines,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
@@ -349,30 +351,14 @@ import {
|
||||
Ban,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown
|
||||
ChevronDown,
|
||||
Languages,
|
||||
Disc3
|
||||
} from '@lucide/vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import {
|
||||
BLACKLIST_LANGUAGE_VALUES,
|
||||
BLACKLIST_GENRE_VALUES,
|
||||
BLACKLIST_LANGUAGE_LABEL_EN,
|
||||
BLACKLIST_GENRE_LABEL_EN
|
||||
} from '~/utils/blacklist'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
const { admin, currentLocale } = useLocale()
|
||||
const locale = computed(() => {
|
||||
const base = admin.value?.blacklist || {}
|
||||
return {
|
||||
...base,
|
||||
listTitle: (total) => formatLocaleValue(base.listTitle, total),
|
||||
pagination: (page, pages) => formatLocaleValue(base.pagination, page, pages),
|
||||
removeMessage: (value) => formatLocaleValue(base.removeMessage, value),
|
||||
statusSuccess: (statusText) => formatLocaleValue(base.statusSuccess, statusText)
|
||||
}
|
||||
})
|
||||
|
||||
const blacklist = ref([])
|
||||
const loading = ref(false)
|
||||
@@ -401,80 +387,66 @@ const pagination = reactive({
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const typeOptions = computed(() => [
|
||||
{ label: locale.value?.allTypes || 'All Types', value: '' },
|
||||
{ label: locale.value?.song || 'Song', value: 'SONG' },
|
||||
{ label: locale.value?.keyword || 'Keyword', value: 'KEYWORD' },
|
||||
{ label: locale.value?.language || 'Language', value: 'LANGUAGE' },
|
||||
{ label: locale.value?.genre || 'Genre', value: 'GENRE' }
|
||||
])
|
||||
const typeFilterLabel = computed(() => {
|
||||
if (filters.type === 'SONG') return '具体歌曲'
|
||||
if (filters.type === 'KEYWORD') return '关键词'
|
||||
if (filters.type === 'LANGUAGE') return '语种'
|
||||
if (filters.type === 'GENRE') return '曲风'
|
||||
return '全部类型'
|
||||
})
|
||||
|
||||
// 类型 → 图标/徽章/文案元数据
|
||||
const getTypeMeta = (type) => {
|
||||
const typeValueLabel = computed(() => {
|
||||
if (newItem.type === 'SONG') return '歌曲名称 - 艺术家'
|
||||
if (newItem.type === 'KEYWORD') return '关键词内容'
|
||||
if (newItem.type === 'LANGUAGE') return '语种名称'
|
||||
if (newItem.type === 'GENRE') return '曲风名称'
|
||||
return '内容'
|
||||
})
|
||||
|
||||
const typePlaceholder = computed(() => {
|
||||
if (newItem.type === 'SONG') return '例如: 歌曲名 - 艺术家'
|
||||
if (newItem.type === 'KEYWORD') return '例如: 敏感词、特定风格'
|
||||
if (newItem.type === 'LANGUAGE') return '例如: 日文 或 中文,英文,日文'
|
||||
if (newItem.type === 'GENRE') return '例如: DJ 或 流行,摇滚,rap'
|
||||
return '请输入内容'
|
||||
})
|
||||
|
||||
const getTypeLabel = (type: string) => {
|
||||
if (type === 'SONG') return '具体歌曲'
|
||||
if (type === 'KEYWORD') return '关键词'
|
||||
if (type === 'LANGUAGE') return '语种'
|
||||
if (type === 'GENRE') return '曲风'
|
||||
return type
|
||||
}
|
||||
|
||||
const getTypeIconClass = (type: string) => {
|
||||
switch (type) {
|
||||
case 'SONG':
|
||||
return {
|
||||
icon: Music,
|
||||
label: locale.value?.song || 'Song',
|
||||
boxClass: 'bg-primary-hover-10 text-primary border-primary-20 shadow-lg shadow-[var(--primary-glow-5)]',
|
||||
badgeClass: 'bg-primary-hover-10 text-primary border-primary-20'
|
||||
}
|
||||
return 'bg-blue-600/10 text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/5'
|
||||
case 'KEYWORD':
|
||||
return 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5'
|
||||
case 'LANGUAGE':
|
||||
return {
|
||||
icon: Languages,
|
||||
label: locale.value?.language || 'Language',
|
||||
boxClass: 'bg-warning-10 text-warning border-warning-20 shadow-lg shadow-[var(--warning-glow-5)]',
|
||||
badgeClass: 'bg-warning-10 text-warning border-warning-20'
|
||||
}
|
||||
return 'bg-amber-600/10 text-amber-500 border-amber-500/20 shadow-lg shadow-amber-900/5'
|
||||
case 'GENRE':
|
||||
return {
|
||||
icon: AudioLines,
|
||||
label: locale.value?.genre || 'Genre',
|
||||
boxClass: 'bg-success-10 text-success border-success-20 shadow-lg shadow-[var(--success-glow-5)]',
|
||||
badgeClass: 'bg-success-10 text-success border-success-20'
|
||||
}
|
||||
return 'bg-emerald-600/10 text-emerald-500 border-emerald-500/20 shadow-lg shadow-emerald-900/5'
|
||||
default:
|
||||
return {
|
||||
icon: Type,
|
||||
label: locale.value?.keyword || 'Keyword',
|
||||
boxClass: 'bg-info-10 text-info border-info-20 shadow-lg shadow-[var(--info-glow-5)]',
|
||||
badgeClass: 'bg-info-10 text-info border-info-20'
|
||||
}
|
||||
return 'bg-zinc-600/10 text-zinc-500 border-zinc-500/20'
|
||||
}
|
||||
}
|
||||
|
||||
// 语种/曲风候选值选项(存储值为规范中文标签,英文界面附注显示名)
|
||||
const localeValueLabel = (value, enMap) =>
|
||||
currentLocale.value === 'en-US' ? `${enMap[value] || value} (${value})` : value
|
||||
|
||||
const typeValueOptions = computed(() => {
|
||||
if (newItem.type === 'LANGUAGE') {
|
||||
return BLACKLIST_LANGUAGE_VALUES.map((value) => ({
|
||||
value,
|
||||
label: localeValueLabel(value, BLACKLIST_LANGUAGE_LABEL_EN)
|
||||
}))
|
||||
const getTypeBadgeClass = (type: string) => {
|
||||
switch (type) {
|
||||
case 'SONG':
|
||||
return 'bg-blue-600/10 text-blue-400 border-blue-500/20'
|
||||
case 'KEYWORD':
|
||||
return 'bg-purple-600/10 text-purple-400 border-purple-500/20'
|
||||
case 'LANGUAGE':
|
||||
return 'bg-amber-600/10 text-amber-400 border-amber-500/20'
|
||||
case 'GENRE':
|
||||
return 'bg-emerald-600/10 text-emerald-400 border-emerald-500/20'
|
||||
default:
|
||||
return 'bg-zinc-600/10 text-zinc-400 border-zinc-500/20'
|
||||
}
|
||||
if (newItem.type === 'GENRE') {
|
||||
return BLACKLIST_GENRE_VALUES.map((value) => ({
|
||||
value,
|
||||
label: localeValueLabel(value, BLACKLIST_GENRE_LABEL_EN)
|
||||
}))
|
||||
}
|
||||
return []
|
||||
})
|
||||
|
||||
const valueLabel = computed(() => {
|
||||
if (newItem.type === 'SONG') return locale.value?.songLabel
|
||||
if (newItem.type === 'KEYWORD') return locale.value?.keywordLabel
|
||||
if (newItem.type === 'LANGUAGE') return locale.value?.languageLabel
|
||||
return locale.value?.genreLabel
|
||||
})
|
||||
|
||||
// 切换类型时清空已选值
|
||||
const selectType = (type) => {
|
||||
if (newItem.type === type) return
|
||||
newItem.type = type
|
||||
newItem.value = ''
|
||||
}
|
||||
|
||||
let searchTimeout = null
|
||||
@@ -499,16 +471,24 @@ const loadBlacklist = async () => {
|
||||
blacklist.value = response.blacklist
|
||||
Object.assign(pagination, response.pagination)
|
||||
} catch (err) {
|
||||
const fetchFailed = locale.value?.fetchFailed || 'Failed to load blacklist'
|
||||
error.value = fetchFailed
|
||||
error.value = '获取黑名单失败'
|
||||
console.error('获取黑名单失败:', err)
|
||||
showNotification(`${fetchFailed}: ${getErrorMessage(err)}`, 'error')
|
||||
showNotification('获取黑名单失败: ' + (err.data?.message || err.message), 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理类型筛选变更
|
||||
const handleTypeFilterChange = (label) => {
|
||||
if (label === '具体歌曲') filters.type = 'SONG'
|
||||
else if (label === '关键词') filters.type = 'KEYWORD'
|
||||
else if (label === '语种') filters.type = 'LANGUAGE'
|
||||
else if (label === '曲风') filters.type = 'GENRE'
|
||||
else filters.type = ''
|
||||
|
||||
pagination.page = 1
|
||||
loadBlacklist()
|
||||
}
|
||||
|
||||
// 添加黑名单项
|
||||
const addBlacklistItem = async () => {
|
||||
@@ -529,7 +509,7 @@ const addBlacklistItem = async () => {
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
|
||||
showNotification(locale.value.addSuccess, 'success')
|
||||
showNotification('黑名单项添加成功', 'success')
|
||||
|
||||
// 重置表单
|
||||
newItem.value = ''
|
||||
@@ -539,9 +519,9 @@ const addBlacklistItem = async () => {
|
||||
pagination.page = 1
|
||||
await loadBlacklist()
|
||||
} catch (err) {
|
||||
error.value = getErrorMessage(err) || locale.value.addFailed
|
||||
error.value = err.data?.message || '添加黑名单项失败'
|
||||
console.error('添加黑名单项失败:', err)
|
||||
showNotification(locale.value.addFailedPrefix + error.value, 'error')
|
||||
showNotification('添加失败: ' + error.value, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -562,11 +542,11 @@ const toggleItemStatus = async (item) => {
|
||||
})
|
||||
|
||||
item.isActive = !item.isActive
|
||||
showNotification(locale.value.statusSuccess(item.isActive ? locale.value.enable : locale.value.disable), 'success')
|
||||
showNotification(`黑名单项已${item.isActive ? '启用' : '禁用'}`, 'success')
|
||||
} catch (err) {
|
||||
error.value = locale.value.updateFailed
|
||||
error.value = '更新状态失败'
|
||||
console.error('更新状态失败:', err)
|
||||
showNotification(locale.value.updateFailed, 'error')
|
||||
showNotification('更新状态失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -591,12 +571,12 @@ const confirmDelete = async () => {
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
|
||||
showNotification(locale.value.deleteSuccess, 'success')
|
||||
showNotification('黑名单项删除成功', 'success')
|
||||
await loadBlacklist()
|
||||
} catch (err) {
|
||||
error.value = locale.value.deleteFailed
|
||||
error.value = '删除失败'
|
||||
console.error('删除失败:', err)
|
||||
showNotification(`${locale.value.deleteFailed}: ${getErrorMessage(err)}`, 'error')
|
||||
showNotification('删除失败: ' + err.message, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
showDeleteDialog.value = false
|
||||
@@ -622,9 +602,7 @@ const changePage = (page) => {
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
const date = new Date(dateString)
|
||||
if (Number.isNaN(date.getTime())) return '-'
|
||||
return date.toLocaleString(currentLocale.value, {
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -667,7 +645,7 @@ onMounted(() => {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--panel-bg-alt);
|
||||
background: var(--bg-tertiary, #27272a);
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,277 @@
|
||||
<template>
|
||||
<div class="max-w-[1200px] mx-auto space-y-6 pb-24 px-2">
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">更新日志</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1 font-medium">管理前台“更新日志”页面的发布内容</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="flex items-center gap-2 px-5 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="loading"
|
||||
@click="load"
|
||||
>
|
||||
<RefreshCw :size="14" /> 刷新
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
@click="openCreate"
|
||||
>
|
||||
<Plus :size="14" /> 新建
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
|
||||
<div class="w-8 h-8 border-4 border-zinc-800 border-t-blue-500 rounded-full animate-spin mb-4" />
|
||||
<p class="text-zinc-500 text-sm">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-3">
|
||||
<div
|
||||
v-for="item in entries"
|
||||
:key="item.id"
|
||||
class="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-5 flex flex-col md:flex-row md:items-center md:justify-between gap-4"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<div class="flex items-center gap-2 flex-wrap">
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest"
|
||||
:class="item.published ? 'bg-emerald-500/10 text-emerald-300' : 'bg-zinc-800/50 text-zinc-400'"
|
||||
>
|
||||
{{ item.published ? '已发布' : '草稿' }}
|
||||
</span>
|
||||
<span class="px-2.5 py-1 rounded-lg bg-blue-500/10 text-blue-300 text-[10px] font-black">
|
||||
v{{ item.version }}
|
||||
</span>
|
||||
<p class="text-sm font-black text-zinc-100 truncate">{{ item.title }}</p>
|
||||
</div>
|
||||
<p class="text-[10px] text-zinc-500 mt-2">
|
||||
{{ formatDate(item.publishedAt || item.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-xs font-bold"
|
||||
@click="openEdit(item)"
|
||||
>
|
||||
<Pencil :size="14" class="inline-block mr-1" /> 编辑
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-xs font-bold"
|
||||
:disabled="savingId === item.id"
|
||||
@click="togglePublish(item)"
|
||||
>
|
||||
<UploadCloud :size="14" class="inline-block mr-1" />
|
||||
{{ item.published ? '取消发布' : '发布' }}
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-2 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 text-xs font-bold disabled:opacity-50"
|
||||
:disabled="savingId === item.id"
|
||||
@click="remove(item)"
|
||||
>
|
||||
<Trash2 :size="14" class="inline-block mr-1" /> 删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="entries.length === 0" class="py-20 text-center text-zinc-500">
|
||||
暂无更新日志
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Teleport to="body">
|
||||
<div v-if="showModal" class="fixed inset-0 z-[80] bg-black/60 backdrop-blur-sm p-4 flex items-end sm:items-center justify-center">
|
||||
<div class="w-full max-w-2xl bg-zinc-950 border border-zinc-800 rounded-2xl overflow-hidden">
|
||||
<div class="px-5 py-4 border-b border-zinc-800 flex items-center justify-between">
|
||||
<p class="text-sm font-black text-zinc-100">{{ editingId ? '编辑更新日志' : '新建更新日志' }}</p>
|
||||
<button
|
||||
class="h-9 w-9 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900 transition-colors"
|
||||
@click="closeModal"
|
||||
>
|
||||
<X :size="16" class="mx-auto" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-5 space-y-4">
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">版本号</label>
|
||||
<input v-model="form.version" type="text" class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all" placeholder="如:2.4.1" >
|
||||
</div>
|
||||
<div>
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">标题</label>
|
||||
<input v-model="form.title" type="text" class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all" placeholder="如:投稿体验优化" >
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">内容</label>
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
:rows="10"
|
||||
class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all font-mono"
|
||||
placeholder="支持纯文本(建议用分点)。"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between gap-4 p-3 rounded-xl border border-zinc-800 bg-zinc-900/40">
|
||||
<div>
|
||||
<p class="text-xs font-bold text-zinc-200">立即发布</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-0.5">发布后前台更新日志页面可见</p>
|
||||
</div>
|
||||
<input v-model="form.published" type="checkbox" class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer" >
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-5 py-4 border-t border-zinc-800 flex items-center justify-end gap-3">
|
||||
<button
|
||||
class="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-sm font-bold"
|
||||
@click="closeModal"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="px-5 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-black disabled:opacity-50"
|
||||
:disabled="savingModal"
|
||||
@click="save"
|
||||
>
|
||||
{{ savingModal ? '保存中...' : '保存' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Plus, RefreshCw, Pencil, UploadCloud, Trash2, X } from '@lucide/vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const { showToast } = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const entries = ref([])
|
||||
const savingId = ref(null)
|
||||
|
||||
const showModal = ref(false)
|
||||
const savingModal = ref(false)
|
||||
const editingId = ref(null)
|
||||
const form = ref({ version: '', title: '', content: '', published: false })
|
||||
|
||||
const formatDate = (value) => {
|
||||
if (!value) return ''
|
||||
const d = new Date(value)
|
||||
if (Number.isNaN(d.getTime())) return ''
|
||||
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await $fetch('/api/admin/changelog', { credentials: 'include', query: { t: Date.now() } })
|
||||
entries.value = Array.isArray(data) ? data : []
|
||||
} catch (e) {
|
||||
showToast('加载更新日志失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const openCreate = () => {
|
||||
editingId.value = null
|
||||
form.value = { version: '', title: '', content: '', published: false }
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const openEdit = (item) => {
|
||||
editingId.value = item.id
|
||||
form.value = {
|
||||
version: item.version || '',
|
||||
title: item.title || '',
|
||||
content: item.content || '',
|
||||
published: !!item.published
|
||||
}
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
}
|
||||
|
||||
const save = async () => {
|
||||
const payload = {
|
||||
version: (form.value.version || '').trim(),
|
||||
title: (form.value.title || '').trim(),
|
||||
content: (form.value.content || '').trim(),
|
||||
published: !!form.value.published
|
||||
}
|
||||
|
||||
if (!payload.version || !payload.title || !payload.content) {
|
||||
showToast('请填写版本号、标题与内容', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
savingModal.value = true
|
||||
if (editingId.value) {
|
||||
await $fetch(`/api/admin/changelog/${editingId.value}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
body: payload
|
||||
})
|
||||
showToast('已保存', 'success')
|
||||
} else {
|
||||
await $fetch('/api/admin/changelog', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
body: payload
|
||||
})
|
||||
showToast('已创建', 'success')
|
||||
}
|
||||
showModal.value = false
|
||||
await load()
|
||||
} catch (e) {
|
||||
showToast('保存失败', 'error')
|
||||
} finally {
|
||||
savingModal.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const togglePublish = async (item) => {
|
||||
try {
|
||||
savingId.value = item.id
|
||||
await $fetch(`/api/admin/changelog/${item.id}`, {
|
||||
method: 'PUT',
|
||||
credentials: 'include',
|
||||
body: { published: !item.published }
|
||||
})
|
||||
await load()
|
||||
} catch {
|
||||
showToast('更新发布状态失败', 'error')
|
||||
} finally {
|
||||
savingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const remove = async (item) => {
|
||||
try {
|
||||
savingId.value = item.id
|
||||
await $fetch(`/api/admin/changelog/${item.id}`, {
|
||||
method: 'DELETE',
|
||||
credentials: 'include'
|
||||
})
|
||||
await load()
|
||||
showToast('已删除', 'success')
|
||||
} catch {
|
||||
showToast('删除失败', 'error')
|
||||
} finally {
|
||||
savingId.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -2,9 +2,9 @@
|
||||
<div class="max-w-[1200px] mx-auto space-y-8 pb-20 px-2">
|
||||
<!-- 页面标题 -->
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
|
||||
<p class="text-xs text-text-tertiary font-medium">
|
||||
{{ locale.desc }}
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">数据库操作</h2>
|
||||
<p class="text-xs text-zinc-500 font-medium">
|
||||
执行系统底层维护任务,包括备份、恢复及全局数据重置
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -14,53 +14,49 @@
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
:class="[
|
||||
'group relative bg-bg-secondary-40 border border-border-secondary rounded-2xl p-8 transition-all hover:border-border-tertiary hover:shadow-2xl hover:shadow-[0_25px_50px_var(--shadow-color-deep)]',
|
||||
card.isDanger ? 'hover:border-error-20' : ''
|
||||
'group relative bg-zinc-900/40 border border-zinc-800 rounded-2xl p-8 transition-all hover:border-zinc-700 hover:shadow-2xl hover:shadow-black/40',
|
||||
card.isDanger ? 'hover:border-rose-500/20' : ''
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col h-full space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
:class="[
|
||||
'p-3.5 rounded-2xl bg-bg-primary border border-border-secondary transition-all flex items-center justify-center',
|
||||
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 transition-all',
|
||||
card.isDanger
|
||||
? 'text-error border-error-10'
|
||||
: card.colorClass === 'primary'
|
||||
? 'text-primary border-primary-20'
|
||||
: card.colorClass === 'warning'
|
||||
? 'text-warning border-warning-20'
|
||||
: 'text-success border-success-20'
|
||||
? 'text-rose-500 border-rose-500/10'
|
||||
: `text-${card.color}-500 border-${card.color}-500/10 shadow-lg`
|
||||
]"
|
||||
>
|
||||
<component :is="card.icon" class="w-6 h-6" />
|
||||
</div>
|
||||
<span
|
||||
v-if="card.isDanger"
|
||||
class="px-2 py-0.5 bg-error-10 text-error text-[9px] font-black uppercase tracking-widest border border-error-20 rounded"
|
||||
>{{ locale.highRisk }}</span
|
||||
class="px-2 py-0.5 bg-rose-500/10 text-rose-500 text-[9px] font-black uppercase tracking-widest border border-rose-500/20 rounded"
|
||||
>高风险操作</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-2">
|
||||
<h3 class="text-lg font-bold text-text-primary group-hover:text-primary transition-colors">
|
||||
<h3 class="text-lg font-bold text-zinc-100 group-hover:text-blue-400 transition-colors">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="text-xs text-text-tertiary leading-relaxed font-medium">
|
||||
<p class="text-xs text-zinc-500 leading-relaxed font-medium">
|
||||
{{ card.desc }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="isLoading(card.id)"
|
||||
class="w-full py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all active:scale-95 bg-bg-primary border border-border-secondary text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary hover:border-border-tertiary"
|
||||
class="w-full py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all active:scale-95 bg-zinc-950 border border-zinc-800 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 hover:border-zinc-700"
|
||||
:class="
|
||||
card.isDanger
|
||||
? 'bg-bg-primary border border-error-30 text-error hover:bg-error hover:text-text-primary hover:border-error shadow-lg shadow-[var(--error-glow-5)]'
|
||||
? 'bg-zinc-950 border border-rose-900/30 text-rose-500 hover:bg-rose-600 hover:text-white hover:border-rose-600 shadow-lg shadow-rose-900/5'
|
||||
: ''
|
||||
"
|
||||
@click="openModal(card.id)"
|
||||
>
|
||||
<span v-if="isLoading(card.id)">{{ locale.executing }}</span>
|
||||
<span v-if="isLoading(card.id)">执行中...</span>
|
||||
<span v-else>{{ card.btnText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -69,19 +65,19 @@
|
||||
<div
|
||||
:class="[
|
||||
'absolute -right-4 -bottom-4 w-32 h-32 blur-3xl opacity-0 group-hover:opacity-100 transition-opacity rounded-full pointer-events-none',
|
||||
card.isDanger ? 'bg-error-5' : card.colorClass === 'primary' ? 'bg-primary-5' : card.colorClass === 'warning' ? 'bg-warning-5' : 'bg-success-5'
|
||||
card.isDanger ? 'bg-rose-500/5' : `bg-${card.color}-500/5`
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 维护建议 -->
|
||||
<div class="bg-primary-hover-5 border border-primary-10 rounded-xl p-5 flex items-start gap-4">
|
||||
<AlertCircle class="text-primary shrink-0 mt-0.5 w-[18px] h-[18px]" />
|
||||
<div class="bg-blue-600/5 border border-blue-500/10 rounded-xl p-5 flex items-start gap-4">
|
||||
<AlertCircle class="text-blue-500 shrink-0 mt-0.5 w-[18px] h-[18px]" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-[11px] font-bold text-text-secondary">{{ locale.maintenanceTitle }}</p>
|
||||
<p class="text-[10px] text-text-tertiary leading-relaxed">
|
||||
{{ locale.maintenanceDesc }}
|
||||
<p class="text-[11px] font-bold text-zinc-300">数据库维护建议</p>
|
||||
<p class="text-[10px] text-zinc-500 leading-relaxed">
|
||||
建议每周进行一次全量备份。在执行“恢复备份”或“重置数据库”前,请务必先创建一份当前的数据备份,以免误操作造成不可挽回的损失。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -91,14 +87,14 @@
|
||||
v-if="activeModal === 'backup'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.backupTitle }}</h3>
|
||||
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">创建数据库备份</h3>
|
||||
<button
|
||||
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
|
||||
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
@@ -106,52 +102,52 @@
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1">
|
||||
{{ locale.selectContent }}
|
||||
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
|
||||
选择包含的内容
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
v-for="(item, i) in backupOptions"
|
||||
:key="i"
|
||||
class="flex items-start gap-4 p-4 bg-bg-primary-50 border border-border-secondary rounded-xl cursor-pointer hover:border-border-tertiary transition-all group"
|
||||
class="flex items-start gap-4 p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl cursor-pointer hover:border-zinc-700 transition-all group"
|
||||
>
|
||||
<div class="shrink-0 mt-0.5">
|
||||
<input
|
||||
v-model="createForm[item.key]"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-border-secondary bg-bg-secondary"
|
||||
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="text-xs font-bold text-text-primary group-hover:text-primary transition-colors"
|
||||
class="text-xs font-bold text-zinc-200 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-[10px] text-text-disabled font-medium mt-0.5">{{ item.desc }}</p>
|
||||
<p class="text-[10px] text-zinc-600 font-medium mt-0.5">{{ item.desc }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 bg-primary-5 border border-primary-10 rounded-xl">
|
||||
<p class="text-[10px] text-text-tertiary text-center italic">
|
||||
{{ locale.backupHint }}
|
||||
<div class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-xl">
|
||||
<p class="text-[10px] text-zinc-500 text-center italic">
|
||||
备份文件将以 .json 格式生成并自动下载
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
|
||||
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
|
||||
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="createLoading"
|
||||
class="px-8 py-2 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="px-8 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="createBackup"
|
||||
>
|
||||
{{ createLoading ? locale.exporting : locale.startExport }}
|
||||
{{ createLoading ? '正在导出...' : '开始导出' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,14 +158,14 @@
|
||||
v-if="activeModal === 'restore'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.restoreTitle }}</h3>
|
||||
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">恢复数据库备份</h3>
|
||||
<button
|
||||
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
|
||||
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
@@ -177,19 +173,19 @@
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div
|
||||
class="border-2 border-dashed border-border-secondary rounded-2xl p-10 flex flex-col items-center justify-center text-center group hover:border-success-50 hover:bg-success-5 transition-all cursor-pointer"
|
||||
class="border-2 border-dashed border-zinc-800 rounded-2xl p-10 flex flex-col items-center justify-center text-center group hover:border-emerald-500/50 hover:bg-emerald-500/5 transition-all cursor-pointer"
|
||||
@click="$refs.fileInput.click()"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleFileDrop"
|
||||
>
|
||||
<Upload
|
||||
class="w-8 h-8 text-text-secondary mb-4 group-hover:text-success transition-colors"
|
||||
class="w-8 h-8 text-zinc-700 mb-4 group-hover:text-emerald-500 transition-colors"
|
||||
/>
|
||||
<h5 class="text-sm font-bold text-text-secondary">
|
||||
{{ selectedFile ? selectedFile.name : locale.selectFile }}
|
||||
<h5 class="text-sm font-bold text-zinc-300">
|
||||
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
|
||||
</h5>
|
||||
<p class="text-[10px] text-text-disabled font-bold uppercase mt-1 tracking-widest">
|
||||
{{ locale.fileHint }}
|
||||
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
|
||||
仅支持 VoiceHub 导出的 .json 格式
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
@@ -201,54 +197,54 @@
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.restoreMode }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>恢复模式</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
:class="[
|
||||
'p-4 border rounded-xl text-left transition-all',
|
||||
restoreForm.mode === 'merge'
|
||||
? 'bg-bg-primary border-success-30'
|
||||
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
|
||||
? 'bg-zinc-950 border-emerald-500/30'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
@click="restoreForm.mode = 'merge'"
|
||||
>
|
||||
<h6
|
||||
:class="[
|
||||
'text-xs font-bold',
|
||||
restoreForm.mode === 'merge' ? 'text-success' : 'text-text-tertiary'
|
||||
restoreForm.mode === 'merge' ? 'text-emerald-400' : 'text-zinc-500'
|
||||
]"
|
||||
>
|
||||
{{ locale.restoreModes?.merge?.title ?? '合并恢复' }}
|
||||
增量模式
|
||||
</h6>
|
||||
<p class="text-[9px] text-text-disabled uppercase mt-0.5">{{ locale.restoreModes?.merge?.desc ?? '保留现有数据并合并备份内容' }}</p>
|
||||
<p class="text-[9px] text-zinc-600 uppercase mt-0.5">仅导入不重复的新记录</p>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'p-4 border rounded-xl text-left transition-all',
|
||||
restoreForm.mode === 'replace'
|
||||
? 'bg-bg-primary border-success-30'
|
||||
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
|
||||
? 'bg-zinc-950 border-emerald-500/30'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
@click="restoreForm.mode = 'replace'"
|
||||
>
|
||||
<h6
|
||||
:class="[
|
||||
'text-xs font-bold',
|
||||
restoreForm.mode === 'replace' ? 'text-success' : 'text-text-tertiary'
|
||||
restoreForm.mode === 'replace' ? 'text-emerald-400' : 'text-zinc-500'
|
||||
]"
|
||||
>
|
||||
{{ locale.restoreModes?.replace?.title ?? '覆盖恢复' }}
|
||||
覆盖模式
|
||||
</h6>
|
||||
<p class="text-[9px] text-text-disabled uppercase mt-0.5">{{ locale.restoreModes?.replace?.desc ?? '清除现有数据后恢复备份内容' }}</p>
|
||||
<p class="text-[9px] text-zinc-600 uppercase mt-0.5">清空现有表后完整恢复</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="restoreForm.mode === 'replace' && hasSuperAdminInBackup"
|
||||
class="p-4 bg-bg-primary border border-border-secondary rounded-xl"
|
||||
class="p-4 bg-zinc-950 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
@@ -257,36 +253,36 @@
|
||||
class="mt-0.5 accent-emerald-500"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-text-primary">{{ locale.overwriteSuperAdmin }}</p>
|
||||
<p class="text-[10px] text-text-tertiary mt-1">
|
||||
{{ locale.overwriteSuperAdminDesc }}
|
||||
<p class="text-xs font-bold text-zinc-200">覆盖备份中的超级管理员账号数据</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-1">
|
||||
关闭时将保留当前超级管理员账号及其第三方绑定、2FA等关联数据
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-4 bg-warning-5 border border-warning-10 rounded-xl flex items-start gap-3"
|
||||
class="p-4 bg-amber-500/5 border border-amber-500/10 rounded-xl flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle class="text-warning shrink-0 mt-0.5 w-4 h-4" />
|
||||
<p class="text-[10px] text-text-tertiary leading-normal font-medium">
|
||||
{{ locale.replaceWarning }}
|
||||
<AlertCircle class="text-amber-500 shrink-0 mt-0.5 w-4 h-4" />
|
||||
<p class="text-[10px] text-zinc-500 leading-normal font-medium">
|
||||
注意:覆盖模式将永久清空当前数据库中对应的表内容。此操作将导致现有会话中断。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
|
||||
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
|
||||
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="uploadLoading || !selectedFile"
|
||||
class="px-8 py-2 bg-success hover:bg-success text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="px-8 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="restoreBackup"
|
||||
>
|
||||
{{ uploadLoading ? restoreProgress || locale.restoring : locale.confirmRestore }}
|
||||
{{ uploadLoading ? restoreProgress || '正在恢复...' : '确认并开始恢复' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -297,14 +293,14 @@
|
||||
v-if="activeModal === 'reset-seq'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight">{{ locale.resetSequenceTitle }}</h3>
|
||||
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">重置数据表序列</h3>
|
||||
<button
|
||||
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
|
||||
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
@@ -312,47 +308,47 @@
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.selectTargetTable }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>选择目标表</label
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="sequenceForm.table"
|
||||
:options="locale.tableOptions || defaultTableOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
:options="tableOptions"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-bg-primary-50 border border-border-secondary rounded-2xl space-y-4">
|
||||
<div class="p-6 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-warning-10 text-warning flex items-center justify-center"
|
||||
class="w-8 h-8 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center"
|
||||
>
|
||||
<AlertCircle class="w-4 h-4" />
|
||||
</div>
|
||||
<h6 class="text-xs font-bold text-text-secondary uppercase tracking-widest">
|
||||
{{ locale.sequenceHelpTitle }}
|
||||
<h6 class="text-xs font-bold text-zinc-300 uppercase tracking-widest">
|
||||
什么是重置序列?
|
||||
</h6>
|
||||
</div>
|
||||
<p class="text-[11px] text-text-tertiary leading-relaxed font-medium">
|
||||
{{ locale.sequenceHelpDesc }}
|
||||
<p class="text-[11px] text-zinc-500 leading-relaxed font-medium">
|
||||
如果您的数据表 ID
|
||||
出现了断档或在手动操作数据库后无法自增,重置序列可以将数据库底层的计数器更新为当前 ID
|
||||
最大值 +1,从而解决 ID 冲突导致的写入失败问题。此操作不会修改任何现有数据。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex gap-3 justify-end">
|
||||
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors uppercase tracking-widest"
|
||||
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="sequenceLoading || !sequenceForm.table"
|
||||
class="px-8 py-2 bg-warning hover:bg-warning text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="px-8 py-2 bg-amber-600 hover:bg-amber-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="resetSequence"
|
||||
>
|
||||
{{ sequenceLoading ? locale.resetting : locale.executeReset }}
|
||||
{{ sequenceLoading ? '正在重置...' : '执行重置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -363,14 +359,14 @@
|
||||
v-if="activeModal === 'reset-db'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
class="relative bg-bg-secondary border border-border-secondary rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-border-secondary flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-error tracking-tight">{{ locale.dangerResetTitle }}</h3>
|
||||
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-rose-500 tracking-tight">危险操作:重置数据库</h3>
|
||||
<button
|
||||
class="p-2 hover:bg-bg-tertiary rounded-xl transition-colors text-text-tertiary hover:text-text-primary"
|
||||
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
@@ -378,120 +374,74 @@
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div
|
||||
class="p-6 bg-error-10 border border-error-20 rounded-2xl flex flex-col items-center text-center"
|
||||
class="p-6 bg-rose-600/10 border border-rose-500/20 rounded-2xl flex flex-col items-center text-center"
|
||||
>
|
||||
<Trash2 class="text-error mb-4 w-12 h-12" />
|
||||
<h4 class="text-lg font-black text-error tracking-tight">
|
||||
{{ locale.dangerResetHeading }}
|
||||
<Trash2 class="text-rose-500 mb-4 w-12 h-12" />
|
||||
<h4 class="text-lg font-black text-rose-500 tracking-tight">
|
||||
您正在执行极其危险的操作!
|
||||
</h4>
|
||||
<p class="text-xs text-text-tertiary mt-2 font-medium leading-relaxed">
|
||||
{{ locale.dangerResetPrefix }}
|
||||
<span class="text-text-secondary font-bold"
|
||||
>{{ locale.dangerResetScope }}</span
|
||||
<p class="text-xs text-zinc-500 mt-2 font-medium leading-relaxed">
|
||||
重置操作将永久删除系统中的所有
|
||||
<span class="text-zinc-300 font-bold"
|
||||
>歌曲、投稿记录、排期文件、通知、日志及除您以外的用户账号</span
|
||||
>。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label
|
||||
class="text-[11px] font-black text-error-80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
|
||||
class="text-[11px] font-black text-rose-500/80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
{{ locale.confirmCodeLabel }}
|
||||
请输入以下代码以确认操作
|
||||
</label>
|
||||
<div
|
||||
class="bg-bg-primary border border-error-30 rounded-xl px-4 py-3 font-mono text-[10px] text-error text-center select-all"
|
||||
class="bg-zinc-950 border border-rose-900/30 rounded-xl px-4 py-3 font-mono text-[10px] text-rose-400 text-center select-all"
|
||||
>
|
||||
{{ CONFIRM_CODE }}
|
||||
</div>
|
||||
<input
|
||||
v-model="resetConfirmText"
|
||||
type="text"
|
||||
:placeholder="locale.confirmCodePlaceholder"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-sm text-text-primary focus:outline-none focus:border-error-40 text-center font-mono placeholder:text-text-secondary"
|
||||
placeholder="在此输入上述代码..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-rose-500/40 text-center font-mono placeholder:text-zinc-700"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||
<button
|
||||
class="py-3 bg-bg-secondary hover:bg-bg-tertiary text-text-tertiary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
class="py-3 bg-zinc-900 hover:bg-zinc-800 text-zinc-500 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="resetConfirmText !== CONFIRM_CODE || resetLoading"
|
||||
class="py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg disabled:bg-bg-tertiary disabled:text-text-disabled disabled:cursor-not-allowed disabled:border-border-tertiary"
|
||||
class="py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg disabled:bg-zinc-800 disabled:text-zinc-600 disabled:cursor-not-allowed disabled:border-zinc-700"
|
||||
:class="
|
||||
resetConfirmText === CONFIRM_CODE
|
||||
? 'bg-error hover:bg-error text-text-primary shadow-[var(--error-glow-20)] active:scale-95'
|
||||
? 'bg-rose-600 hover:bg-rose-500 text-white shadow-rose-900/20 active:scale-95'
|
||||
: ''
|
||||
"
|
||||
@click="resetDatabase"
|
||||
>
|
||||
{{ resetLoading ? locale.resetting : locale.confirmResetDatabase }}
|
||||
{{ resetLoading ? '正在重置...' : '确认彻底重置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 自动备份设置 -->
|
||||
<BackupAutoSettings
|
||||
:visible="activeModal === 'auto-backup'"
|
||||
@close="activeModal = 'none'"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import { Download, Upload, RotateCw, Trash2, AlertCircle, CloudUpload, X } from '@lucide/vue'
|
||||
import { ref } from 'vue'
|
||||
import { Download, Upload, RotateCw, Trash2, AlertCircle, X } from '@lucide/vue'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import BackupAutoSettings from '~/components/Admin/BackupAutoSettings.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
const auth = useAuth()
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => admin.value?.databaseManager || {})
|
||||
const defaultTableOptions = [
|
||||
{ label: '重置所有表', value: 'all' },
|
||||
{ label: '歌曲表', value: 'Song' },
|
||||
{ label: '用户表', value: 'User' },
|
||||
{ label: '用户身份表', value: 'UserIdentity' },
|
||||
{ label: '用户状态日志表', value: 'UserStatusLog' },
|
||||
{ label: '投票表', value: 'Vote' },
|
||||
{ label: '排期表', value: 'Schedule' },
|
||||
{ label: '通知表', value: 'Notification' },
|
||||
{ label: '通知设置表', value: 'NotificationSettings' },
|
||||
{ label: '播放时段表', value: 'PlayTime' },
|
||||
{ label: '学期表', value: 'Semester' },
|
||||
{ label: '系统设置表', value: 'SystemSettings' },
|
||||
{ label: '歌曲黑名单表', value: 'SongBlacklist' },
|
||||
{ label: '歌曲重播申请表', value: 'SongReplayRequest' },
|
||||
{ label: '投稿时段表', value: 'RequestTime' },
|
||||
{ label: '邮件模板表', value: 'EmailTemplate' }
|
||||
]
|
||||
const getMessage = (key) => locale.value?.messages?.[key] ?? `数据库操作:${key}`
|
||||
const getLogMessage = (key) => locale.value?.logs?.[key] || key
|
||||
const formatString = (value, args) => {
|
||||
if (typeof value !== 'string') return value
|
||||
return value.replace(/{(\d+)}/g, (match, index) =>
|
||||
args[index] !== undefined ? String(args[index]) : match
|
||||
)
|
||||
}
|
||||
const getErrorMessage = (key, ...args) => {
|
||||
const message = locale.value?.errors?.[key]
|
||||
if (typeof message === 'function') return message(...args)
|
||||
return formatString(message, args) || args.find(Boolean) || key
|
||||
}
|
||||
const getProgressMessage = (key, ...args) => {
|
||||
const message = locale.value?.progress?.[key]
|
||||
if (typeof message === 'function') return message(...args)
|
||||
return formatString(message, args) || `数据库操作进行中:${key}`
|
||||
}
|
||||
|
||||
// 状态
|
||||
const activeModal = ref('none')
|
||||
@@ -506,68 +456,60 @@ const hasSuperAdminInBackup = ref(false)
|
||||
const CONFIRM_CODE = 'CONFIRM-DATABASE-RESET-OPERATION'
|
||||
|
||||
// 卡片配置
|
||||
const cards = computed(() => [
|
||||
const cards = [
|
||||
{
|
||||
id: 'backup',
|
||||
title: locale.value?.cards?.backup?.title ?? '备份数据库',
|
||||
desc: locale.value?.cards?.backup?.desc ?? '导出系统数据备份文件',
|
||||
title: '创建备份',
|
||||
desc: '导出当前数据库的所有数据到文件,用于安全备份或迁移。',
|
||||
icon: Download,
|
||||
colorClass: 'primary',
|
||||
btnText: locale.value?.cards?.backup?.button ?? '创建备份'
|
||||
color: 'blue',
|
||||
btnText: '创建备份文件'
|
||||
},
|
||||
{
|
||||
id: 'restore',
|
||||
title: locale.value?.cards?.restore?.title ?? '恢复数据库',
|
||||
desc: locale.value?.cards?.restore?.desc ?? '从备份文件恢复系统数据',
|
||||
title: '恢复备份',
|
||||
desc: '从之前导出的备份文件中恢复系统数据。',
|
||||
icon: Upload,
|
||||
colorClass: 'success',
|
||||
btnText: locale.value?.cards?.restore?.button ?? '恢复备份'
|
||||
color: 'emerald',
|
||||
btnText: '选择备份文件'
|
||||
},
|
||||
{
|
||||
id: 'reset-seq',
|
||||
title: locale.value?.cards?.resetSeq?.title ?? '修复数据序列',
|
||||
desc: locale.value?.cards?.resetSeq?.desc ?? '修复数据表自增序列',
|
||||
title: '重置序列',
|
||||
desc: '修复数据表的自增ID序列,确保新记录的ID从正确值开始。',
|
||||
icon: RotateCw,
|
||||
colorClass: 'warning',
|
||||
btnText: locale.value?.cards?.resetSeq?.button ?? '修复序列'
|
||||
color: 'amber',
|
||||
btnText: '开始重置序列'
|
||||
},
|
||||
{
|
||||
id: 'reset-db',
|
||||
title: locale.value?.cards?.resetDb?.title ?? '重置数据库',
|
||||
desc: locale.value?.cards?.resetDb?.desc ?? '清空除管理员外的系统数据',
|
||||
title: '重置数据库',
|
||||
desc: '清空除管理员账号外的所有系统数据。此操作不可撤销。',
|
||||
icon: Trash2,
|
||||
colorClass: 'error',
|
||||
btnText: locale.value?.cards?.resetDb?.button ?? '重置数据库',
|
||||
color: 'rose',
|
||||
btnText: '立即重置数据库',
|
||||
isDanger: true
|
||||
},
|
||||
{
|
||||
id: 'auto-backup',
|
||||
title: locale.value?.cards?.autoBackup?.title ?? '自动备份',
|
||||
desc: locale.value?.cards?.autoBackup?.desc ?? '配置多种备份方式,通过外部触发实现定时自动备份',
|
||||
icon: CloudUpload,
|
||||
colorClass: 'success',
|
||||
btnText: locale.value?.cards?.autoBackup?.button ?? '配置自动备份'
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
// 备份选项
|
||||
const backupOptions = computed(() => [
|
||||
const backupOptions = [
|
||||
{
|
||||
key: 'includeSongs',
|
||||
label: locale.value?.backupOptions?.songs?.label ?? '歌曲与排期数据',
|
||||
desc: locale.value?.backupOptions?.songs?.desc ?? '包含歌曲、投稿及排期数据'
|
||||
label: '歌曲与排期数据',
|
||||
desc: '包含所有歌曲库、用户投稿记录及历史播音排期'
|
||||
},
|
||||
{
|
||||
key: 'includeSystemData',
|
||||
label: locale.value?.backupOptions?.system?.label ?? '系统配置',
|
||||
desc: locale.value?.backupOptions?.system?.desc ?? '包含站点设置和全局参数'
|
||||
label: '系统配置信息',
|
||||
desc: '包含站点设置、黑名单、播出时段等全局参数'
|
||||
},
|
||||
{
|
||||
key: 'includeUsers',
|
||||
label: locale.value?.backupOptions?.users?.label ?? '用户账户',
|
||||
desc: locale.value?.backupOptions?.users?.desc ?? '包含用户账户和权限数据'
|
||||
label: '用户账户数据',
|
||||
desc: '包含所有注册用户的权限、偏好设置(不含管理员敏感信息)'
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
// 表单数据
|
||||
const createForm = ref({
|
||||
@@ -582,9 +524,37 @@ const restoreForm = ref({
|
||||
})
|
||||
|
||||
const sequenceForm = ref({
|
||||
table: 'all'
|
||||
table: '重置所有表 (All)'
|
||||
})
|
||||
|
||||
const tableOptions = [
|
||||
'重置所有表 (All)',
|
||||
'歌曲表 (Song)',
|
||||
'用户表 (User)',
|
||||
'投票表 (Vote)',
|
||||
'排期表 (Schedule)',
|
||||
'通知表 (Notification)',
|
||||
'通知设置表 (NotificationSettings)',
|
||||
'播放时段表 (PlayTime)',
|
||||
'学期表 (Semester)',
|
||||
'系统设置表 (SystemSettings)',
|
||||
'歌曲黑名单表 (SongBlacklist)'
|
||||
]
|
||||
|
||||
const labelToValueMap = {
|
||||
'重置所有表 (All)': 'all',
|
||||
'歌曲表 (Song)': 'Song',
|
||||
'用户表 (User)': 'User',
|
||||
'投票表 (Vote)': 'Vote',
|
||||
'排期表 (Schedule)': 'Schedule',
|
||||
'通知表 (Notification)': 'Notification',
|
||||
'通知设置表 (NotificationSettings)': 'NotificationSettings',
|
||||
'播放时段表 (PlayTime)': 'PlayTime',
|
||||
'学期表 (Semester)': 'Semester',
|
||||
'系统设置表 (SystemSettings)': 'SystemSettings',
|
||||
'歌曲黑名单表 (SongBlacklist)': 'SongBlacklist'
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const isLoading = (id) => {
|
||||
if (id === 'backup') return createLoading.value
|
||||
@@ -614,8 +584,8 @@ const parseBackupSuperAdmin = async (file) => {
|
||||
} catch (error) {
|
||||
hasSuperAdminInBackup.value = false
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
showNotification(getMessage('parseBackupFailed'), 'error')
|
||||
console.error(getLogMessage('parseBackupFailed'), error)
|
||||
showNotification('无法解析备份文件,请检查文件格式是否正确。', 'error')
|
||||
console.error('解析备份文件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -625,7 +595,7 @@ const handleFileSelect = async (event) => {
|
||||
selectedFile.value = file
|
||||
await parseBackupSuperAdmin(file)
|
||||
} else {
|
||||
showNotification(getMessage('invalidJsonFile'), 'error')
|
||||
showNotification('请选择有效的JSON备份文件', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -635,7 +605,7 @@ const handleFileDrop = async (event) => {
|
||||
selectedFile.value = file
|
||||
await parseBackupSuperAdmin(file)
|
||||
} else {
|
||||
showNotification(getMessage('invalidJsonFile'), 'error')
|
||||
showNotification('请选择有效的JSON备份文件', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -649,11 +619,11 @@ const createBackup = async () => {
|
||||
} else if (createForm.value.includeUsers) {
|
||||
tables = 'users'
|
||||
} else if (createForm.value.includeSongs) {
|
||||
tables = ['songs', 'scheduleSongPool']
|
||||
tables = 'songs'
|
||||
} else if (createForm.value.includeSystemData) {
|
||||
tables = ['systemSettings']
|
||||
} else {
|
||||
throw new Error(getErrorMessage('noBackupContent'))
|
||||
throw new Error('请至少选择一项备份内容')
|
||||
}
|
||||
|
||||
const response = await $fetch('/api/admin/backup/export', {
|
||||
@@ -677,7 +647,7 @@ const createBackup = async () => {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
showNotification(getMessage('backupDownloaded'), 'success')
|
||||
showNotification('备份文件已下载', 'success')
|
||||
activeModal.value = 'none'
|
||||
} else if (response.backup.downloadMode === 'file' && response.backup.filename) {
|
||||
const downloadUrl = `/api/admin/backup/download/${response.backup.filename}`
|
||||
@@ -685,7 +655,7 @@ const createBackup = async () => {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
})
|
||||
if (!downloadResponse.ok) throw new Error(getErrorMessage('downloadFailed', downloadResponse.status))
|
||||
if (!downloadResponse.ok) throw new Error(`下载失败: ${downloadResponse.status}`)
|
||||
const blob = await downloadResponse.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
@@ -695,25 +665,25 @@ const createBackup = async () => {
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
showNotification(getMessage('backupDownloaded'), 'success')
|
||||
showNotification('备份文件已下载', 'success')
|
||||
activeModal.value = 'none'
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || getErrorMessage('createBackupFailed'))
|
||||
throw new Error(response.message || '备份创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(getLogMessage('createBackupFailed'), error)
|
||||
showNotification(getErrorMessage('createBackupFailedWithMessage', error?.message), 'error')
|
||||
console.error('创建备份失败:', error)
|
||||
showNotification('创建备份失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const restoreBackup = async () => {
|
||||
if (!selectedFile.value) return showNotification(getMessage('selectBackupFile'), 'error')
|
||||
if (!selectedFile.value) return showNotification('请选择备份文件', 'error')
|
||||
|
||||
uploadLoading.value = true
|
||||
restoreProgress.value = getProgressMessage('readingFile')
|
||||
restoreProgress.value = '正在读取文件...'
|
||||
|
||||
try {
|
||||
const fileContent = await selectedFile.value.text()
|
||||
@@ -721,10 +691,10 @@ const restoreBackup = async () => {
|
||||
try {
|
||||
backupData = JSON.parse(fileContent)
|
||||
} catch {
|
||||
throw new Error(getErrorMessage('invalidBackupFormat'))
|
||||
throw new Error('无法解析备份文件,文件格式不正确')
|
||||
}
|
||||
|
||||
if (!backupData.data) throw new Error(getErrorMessage('missingBackupData'))
|
||||
if (!backupData.data) throw new Error('备份文件缺少数据部分')
|
||||
const fileHasSuperAdmin = hasSuperAdminInBackup.value
|
||||
if (!fileHasSuperAdmin) {
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
@@ -734,7 +704,7 @@ const restoreBackup = async () => {
|
||||
let temporaryPreservedUserId = null
|
||||
|
||||
if (restoreForm.value.mode === 'replace') {
|
||||
restoreProgress.value = getProgressMessage('clearingData')
|
||||
restoreProgress.value = '正在清空现有数据...'
|
||||
const clearResult = await $fetch('/api/admin/backup/clear', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
@@ -742,7 +712,7 @@ const restoreBackup = async () => {
|
||||
hasSuperAdminInBackup: fileHasSuperAdmin
|
||||
}
|
||||
})
|
||||
if (!clearResult.success) throw new Error(clearResult.message || getErrorMessage('clearDataFailed'))
|
||||
if (!clearResult.success) throw new Error(clearResult.message || '清空数据失败')
|
||||
preservedSuperAdminIds = clearResult.preservedSuperAdminIds || []
|
||||
temporaryPreservedUserId = clearResult.temporaryPreservedUserId || null
|
||||
}
|
||||
@@ -754,7 +724,6 @@ const restoreBackup = async () => {
|
||||
'semesters',
|
||||
'playTimes',
|
||||
'songs',
|
||||
'scheduleSongPool',
|
||||
'votes',
|
||||
'schedules',
|
||||
'notificationSettings',
|
||||
@@ -788,14 +757,7 @@ const restoreBackup = async () => {
|
||||
for (let i = 0; i < records.length; i += CHUNK_SIZE) {
|
||||
const chunk = records.slice(i, i + CHUNK_SIZE)
|
||||
const progressPercent = Math.round((totalProcessed / totalRecords) * 100)
|
||||
restoreProgress.value = getProgressMessage(
|
||||
'restoringTable',
|
||||
tableName,
|
||||
i + 1,
|
||||
Math.min(i + CHUNK_SIZE, records.length),
|
||||
records.length,
|
||||
progressPercent
|
||||
)
|
||||
restoreProgress.value = `正在恢复 ${tableName} (${i + 1}-${Math.min(i + CHUNK_SIZE, records.length)}/${records.length}) ${progressPercent}%`
|
||||
|
||||
const response = await $fetch('/api/admin/backup/restore-chunk', {
|
||||
method: 'POST',
|
||||
@@ -809,7 +771,7 @@ const restoreBackup = async () => {
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.success) throw new Error(response.message || getErrorMessage('restoreTableFailed', tableName))
|
||||
if (!response.success) throw new Error(response.message || `恢复表 ${tableName} 失败`)
|
||||
if (response.newMappings) {
|
||||
if (response.newMappings.users) Object.assign(mappings.users, response.newMappings.users)
|
||||
if (response.newMappings.songs) Object.assign(mappings.songs, response.newMappings.songs)
|
||||
@@ -818,21 +780,21 @@ const restoreBackup = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
restoreProgress.value = getProgressMessage('fixingSequence')
|
||||
restoreProgress.value = '正在修复数据表序列...'
|
||||
const sequenceResult = await $fetch('/api/admin/fix-sequence', {
|
||||
method: 'POST',
|
||||
body: { table: 'all' }
|
||||
})
|
||||
if (!sequenceResult.success) {
|
||||
throw new Error(sequenceResult.message || sequenceResult.error || getErrorMessage('fixSequenceFailed'))
|
||||
throw new Error(sequenceResult.message || sequenceResult.error || '序列修复失败')
|
||||
}
|
||||
|
||||
restoreProgress.value = getProgressMessage('reloadingSmtp')
|
||||
restoreProgress.value = '正在重载SMTP配置...'
|
||||
const smtpReloadResult = await $fetch('/api/admin/smtp/reload', {
|
||||
method: 'POST'
|
||||
})
|
||||
if (!smtpReloadResult.success) {
|
||||
throw new Error(smtpReloadResult.message || getErrorMessage('smtpReloadFailed'))
|
||||
throw new Error(smtpReloadResult.message || 'SMTP配置重载失败')
|
||||
}
|
||||
|
||||
const shouldFinalizeTempUser =
|
||||
@@ -844,7 +806,7 @@ const restoreBackup = async () => {
|
||||
if (shouldFinalizeTempUser) {
|
||||
const restoredUserIds = Object.values(mappings.users).map((id) => Number(id))
|
||||
if (!restoredUserIds.includes(Number(temporaryPreservedUserId))) {
|
||||
restoreProgress.value = getProgressMessage('finalizingAdmin')
|
||||
restoreProgress.value = '正在完成管理员替换...'
|
||||
await $fetch('/api/admin/backup/clear', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
@@ -852,7 +814,7 @@ const restoreBackup = async () => {
|
||||
}
|
||||
})
|
||||
}
|
||||
showNotification(getMessage('restoreSuccessRelogin'), 'success')
|
||||
showNotification('数据库恢复成功,正在重新登录', 'success')
|
||||
activeModal.value = 'none'
|
||||
setTimeout(() => {
|
||||
if (auth.logout) {
|
||||
@@ -865,11 +827,11 @@ const restoreBackup = async () => {
|
||||
return
|
||||
}
|
||||
|
||||
showNotification(getMessage('restoreSuccess'), 'success')
|
||||
showNotification('数据库恢复成功', 'success')
|
||||
activeModal.value = 'none'
|
||||
} catch (error) {
|
||||
console.error(getLogMessage('restoreBackupFailed'), error)
|
||||
showNotification(getErrorMessage('restoreBackupFailedWithMessage', error?.message), 'error')
|
||||
console.error('恢复备份失败:', error)
|
||||
showNotification('恢复备份失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
restoreProgress.value = ''
|
||||
@@ -880,19 +842,20 @@ const resetSequence = async () => {
|
||||
if (!sequenceForm.value.table) return
|
||||
sequenceLoading.value = true
|
||||
try {
|
||||
const tableValue = labelToValueMap[sequenceForm.value.table] || sequenceForm.value.table
|
||||
const response = await $fetch('/api/admin/fix-sequence', {
|
||||
method: 'POST',
|
||||
body: { table: sequenceForm.value.table }
|
||||
body: { table: tableValue }
|
||||
})
|
||||
if (response.success) {
|
||||
showNotification(response.message || getMessage('sequenceResetSuccess'), 'success')
|
||||
showNotification(response.message || '序列重置成功', 'success')
|
||||
activeModal.value = 'none'
|
||||
} else {
|
||||
throw new Error(response.error || response.message || getErrorMessage('resetFailed'))
|
||||
throw new Error(response.error || response.message || '重置失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(getLogMessage('resetSequenceFailed'), error)
|
||||
showNotification(getErrorMessage('resetSequenceFailedWithMessage', error?.message), 'error')
|
||||
console.error('重置序列失败:', error)
|
||||
showNotification('重置序列失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
sequenceLoading.value = false
|
||||
}
|
||||
@@ -904,15 +867,15 @@ const resetDatabase = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/admin/database/reset', { method: 'POST' })
|
||||
if (response.success) {
|
||||
showNotification(getMessage('databaseResetSuccess'), 'success')
|
||||
showNotification('数据库已成功重置', 'success')
|
||||
activeModal.value = 'none'
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} else {
|
||||
throw new Error(response.message || getErrorMessage('resetFailed'))
|
||||
throw new Error(response.message || '重置失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(getLogMessage('resetDatabaseFailed'), error)
|
||||
showNotification(getErrorMessage('resetDatabaseFailedWithMessage', error?.message), 'error')
|
||||
console.error('重置数据库失败:', error)
|
||||
showNotification('重置数据库失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
resetLoading.value = false
|
||||
}
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
<template>
|
||||
<div class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] overflow-hidden">
|
||||
<div class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] overflow-hidden">
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-border-secondary bg-bg-secondary-20">
|
||||
<div class="flex items-center justify-between p-6 border-b border-zinc-800 bg-zinc-900/20">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-primary-hover-10 flex items-center justify-center border border-primary-20"
|
||||
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center border border-blue-500/20"
|
||||
>
|
||||
<Mail :size="20" class="text-primary" />
|
||||
<Mail :size="20" class="text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">{{ locale.title }}</h3>
|
||||
<p class="text-[10px] text-text-tertiary mt-0.5">{{ locale.desc }}</p>
|
||||
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">邮件模板管理</h3>
|
||||
<p class="text-[10px] text-zinc-500 mt-0.5">自定义系统邮件的主题与内容,支持变量替换</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
v-if="selected"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary text-[11px] font-bold rounded-xl transition-all"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-[11px] font-bold rounded-xl transition-all"
|
||||
:disabled="saving"
|
||||
@click="doPreview"
|
||||
>
|
||||
<Eye :size="14" /> {{ previewHtml ? locale.refreshPreview : locale.livePreview }}
|
||||
<Eye :size="14" /> {{ previewHtml ? '刷新预览' : '实时预览' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="selected"
|
||||
class="flex items-center gap-2 px-6 py-2 bg-primary-hover hover:bg-primary text-text-primary text-[11px] font-bold rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
|
||||
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
:disabled="saving"
|
||||
@click="save"
|
||||
>
|
||||
<Save :size="14" /> {{ saving ? locale.saving : locale.updateTemplate }}
|
||||
<Save :size="14" /> {{ saving ? '保存中...' : '更新模板' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 min-h-[650px]">
|
||||
<!-- 左侧:模板列表 -->
|
||||
<div class="lg:col-span-3 border-r border-border-secondary bg-bg-secondary-10 flex flex-col">
|
||||
<div class="p-4 border-b border-border-secondary-50">
|
||||
<div class="lg:col-span-3 border-r border-zinc-800 bg-zinc-900/10 flex flex-col">
|
||||
<div class="p-4 border-b border-zinc-800/50">
|
||||
<div class="relative">
|
||||
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-text-disabled" />
|
||||
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600" />
|
||||
<input
|
||||
type="text"
|
||||
:placeholder="locale.searchPlaceholder"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-lg pl-9 pr-3 py-2 text-[10px] text-text-tertiary focus:outline-none focus:border-primary-30"
|
||||
placeholder="搜索模板..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-[10px] text-zinc-400 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,8 +53,8 @@
|
||||
class="w-full group flex flex-col gap-1 p-3 rounded-2xl transition-all text-left border"
|
||||
:class="[
|
||||
selectedKey === t.key
|
||||
? 'bg-primary-hover-10 border-primary-20'
|
||||
: 'bg-transparent border-transparent hover:bg-bg-tertiary-40'
|
||||
? 'bg-blue-600/10 border-blue-500/20'
|
||||
: 'bg-transparent border-transparent hover:bg-zinc-800/40'
|
||||
]"
|
||||
@click="select(t)"
|
||||
>
|
||||
@@ -63,8 +63,8 @@
|
||||
class="text-[11px] font-black tracking-tight transition-colors"
|
||||
:class="
|
||||
selectedKey === t.key
|
||||
? 'text-primary'
|
||||
: 'text-text-secondary group-hover:text-text-primary'
|
||||
? 'text-blue-400'
|
||||
: 'text-zinc-300 group-hover:text-zinc-100'
|
||||
"
|
||||
>
|
||||
{{ t.name }}
|
||||
@@ -72,69 +72,67 @@
|
||||
<div class="flex gap-1">
|
||||
<span
|
||||
v-if="t.isBuiltin && !t.isOverridden"
|
||||
class="px-1.5 py-0.5 rounded-md bg-bg-tertiary text-[8px] font-black text-text-tertiary uppercase tracking-tighter"
|
||||
class="px-1.5 py-0.5 rounded-md bg-zinc-800 text-[8px] font-black text-zinc-500 uppercase tracking-tighter"
|
||||
>
|
||||
{{ locale.builtin }}
|
||||
内置
|
||||
</span>
|
||||
<span
|
||||
v-if="t.isOverridden"
|
||||
class="px-1.5 py-0.5 rounded-md bg-success-10 text-[8px] font-black text-success uppercase tracking-tighter"
|
||||
class="px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-[8px] font-black text-emerald-500 uppercase tracking-tighter"
|
||||
>
|
||||
{{ locale.custom }}
|
||||
自定义
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[9px] font-bold text-text-disabled font-mono">{{ t.key }}</span>
|
||||
<span class="text-[9px] font-bold text-zinc-600 font-mono">{{ t.key }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:编辑器区域 -->
|
||||
<div class="lg:col-span-9 flex flex-col bg-bg-primary-20 overflow-hidden">
|
||||
<div class="lg:col-span-9 flex flex-col bg-zinc-950/20 overflow-hidden">
|
||||
<div v-if="selected" class="flex-1 overflow-y-auto custom-scrollbar p-8">
|
||||
<div class="max-w-4xl mx-auto space-y-8">
|
||||
<!-- 基础信息与主题 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
|
||||
>{{ locale.templateName }}</label
|
||||
>
|
||||
</div>
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>模板名称</label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="h-11 w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-xs text-text-primary focus:outline-none focus:border-primary-30"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
|
||||
>{{ locale.emailSubject }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>邮件主题</label
|
||||
>
|
||||
<span class="text-[9px] text-text-tertiary font-bold uppercase">{{ locale.supportsVariables }}</span>
|
||||
<span class="text-[9px] text-zinc-500 font-bold uppercase">支持变量</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.subject"
|
||||
type="text"
|
||||
:placeholder="locale.subjectPlaceholder"
|
||||
class="h-11 w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-3 text-xs text-text-primary focus:outline-none focus:border-primary-30"
|
||||
placeholder="请输入邮件主题..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变量提示 -->
|
||||
<div class="bg-primary-hover-5 border border-primary-10 rounded-2xl p-4 flex gap-4">
|
||||
<div class="bg-blue-600/5 border border-blue-500/10 rounded-2xl p-4 flex gap-4">
|
||||
<div
|
||||
class="w-8 h-8 rounded-xl bg-primary-10 flex items-center justify-center shrink-0"
|
||||
class="w-8 h-8 rounded-xl bg-blue-500/10 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<Info :size="16" class="text-primary" />
|
||||
<Info :size="16" class="text-blue-500" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-[10px] font-black text-primary uppercase tracking-widest">
|
||||
{{ locale.availableVariables }}
|
||||
<h4 class="text-[10px] font-black text-blue-400 uppercase tracking-widest">
|
||||
可用变量
|
||||
</h4>
|
||||
<p class="text-[11px] text-text-tertiary leading-relaxed font-mono">
|
||||
<p class="text-[11px] text-zinc-500 leading-relaxed font-mono">
|
||||
<template v-if="selected.key === 'verification.code'">
|
||||
<span v-pre>{{ name }}, {{ email }}, {{ code }}, {{ expiresInMinutes }}</span>
|
||||
</template>
|
||||
@@ -149,32 +147,32 @@
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest"
|
||||
>{{ locale.htmlContent }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>HTML 内容</label
|
||||
>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-md bg-bg-secondary text-[8px] font-black text-text-tertiary uppercase"
|
||||
class="px-2 py-0.5 rounded-md bg-zinc-900 text-[8px] font-black text-zinc-500 uppercase"
|
||||
>Handlebars</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
v-if="selected.isOverridden"
|
||||
class="flex items-center gap-1.5 text-[10px] font-bold text-error hover:text-error transition-colors"
|
||||
class="flex items-center gap-1.5 text-[10px] font-bold text-rose-500 hover:text-rose-400 transition-colors"
|
||||
@click="restore"
|
||||
>
|
||||
<RotateCcw :size="12" /> {{ locale.restoreDefault }}
|
||||
<RotateCcw :size="12" /> 恢复默认配置
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute right-4 top-4 p-2 rounded-lg bg-bg-secondary-50 border border-border-secondary opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center"
|
||||
class="absolute right-4 top-4 p-2 rounded-lg bg-zinc-900/50 border border-zinc-800 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Code :size="14" class="text-text-tertiary" />
|
||||
<Code :size="14" class="text-zinc-500" />
|
||||
</div>
|
||||
<textarea
|
||||
v-model="form.html"
|
||||
rows="16"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-2xl p-6 text-[11px] text-text-secondary font-mono leading-relaxed focus:outline-none focus:border-primary-30 custom-scrollbar"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl p-6 text-[11px] text-zinc-300 font-mono leading-relaxed focus:outline-none focus:border-blue-500/30 custom-scrollbar"
|
||||
placeholder="<html>..."
|
||||
/>
|
||||
</div>
|
||||
@@ -186,22 +184,22 @@
|
||||
enter-from-class="transform translate-y-4 opacity-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100"
|
||||
>
|
||||
<div v-if="previewHtml" class="space-y-4 pt-4 border-t border-border-secondary">
|
||||
<div v-if="previewHtml" class="space-y-4 pt-4 border-t border-zinc-800">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<label
|
||||
class="text-[10px] font-black text-text-disabled uppercase tracking-widest flex items-center gap-2"
|
||||
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<Eye :size="14" /> {{ locale.livePreviewLabel }}
|
||||
<span class="text-text-tertiary font-normal normal-case">{{ previewSubject }}</span>
|
||||
<Eye :size="14" /> 实时预览:
|
||||
<span class="text-zinc-400 font-normal normal-case">{{ previewSubject }}</span>
|
||||
</label>
|
||||
<button
|
||||
class="text-[10px] font-bold text-text-tertiary hover:text-text-secondary"
|
||||
class="text-[10px] font-bold text-zinc-500 hover:text-zinc-300"
|
||||
@click="previewHtml = ''"
|
||||
>
|
||||
{{ locale.hidePreview }}
|
||||
隐藏预览
|
||||
</button>
|
||||
</div>
|
||||
<div class="rounded-2xl overflow-hidden border border-border-secondary bg-bg-secondary shadow-2xl">
|
||||
<div class="rounded-2xl overflow-hidden border border-zinc-800 bg-white shadow-2xl">
|
||||
<iframe :srcdoc="previewHtml" class="w-full h-[500px] border-none" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -212,14 +210,14 @@
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="flex-1 flex flex-col items-center justify-center text-text-secondary space-y-4"
|
||||
class="flex-1 flex flex-col items-center justify-center text-zinc-700 space-y-4"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-secondary-50 flex items-center justify-center border border-border-secondary-50"
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-900/50 flex items-center justify-center border border-zinc-800/50"
|
||||
>
|
||||
<Mail :size="32" class="text-text-primary" />
|
||||
<Mail :size="32" class="text-zinc-800" />
|
||||
</div>
|
||||
<p class="text-xs font-bold tracking-widest uppercase">{{ locale.emptySelectTemplate }}</p>
|
||||
<p class="text-xs font-bold tracking-widest uppercase">请在左侧选择一个邮件模板</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,7 +227,6 @@
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { Mail, Info, Save, RotateCcw, Eye, Code, Search, ChevronRight } from '@lucide/vue'
|
||||
|
||||
type TemplateItem = {
|
||||
@@ -242,18 +239,6 @@ type TemplateItem = {
|
||||
}
|
||||
|
||||
const { success, error } = useToast()
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => {
|
||||
const base = admin.value?.emailTemplateManager || {}
|
||||
return {
|
||||
...base,
|
||||
messages: { ...(base.messages || {}) },
|
||||
errors: { ...(base.errors || {}) }
|
||||
}
|
||||
})
|
||||
const getLocaleText = (section: string, key: string, fallback = '') => {
|
||||
return locale.value?.[section]?.[key] || fallback
|
||||
}
|
||||
const templates = ref<TemplateItem[]>([])
|
||||
const selectedKey = ref<string>('')
|
||||
const form = ref<{ key: string; name: string; subject: string; html: string }>({
|
||||
@@ -293,10 +278,10 @@ async function save() {
|
||||
try {
|
||||
saving.value = true
|
||||
await $fetch('/api/admin/email-templates', { method: 'POST', body: form.value })
|
||||
success(getLocaleText('messages', 'saved', '模板已保存'))
|
||||
success('模板已保存')
|
||||
await loadList()
|
||||
} catch (e: any) {
|
||||
error(e?.data?.message || getLocaleText('errors', 'saveFailed', '保存失败'))
|
||||
error(e?.data?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -308,10 +293,10 @@ async function restore() {
|
||||
await $fetch(`/api/admin/email-templates?key=${encodeURIComponent(form.value.key)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
success(getLocaleText('messages', 'restored', '已恢复默认模板'))
|
||||
success('已恢复默认模板')
|
||||
await loadList()
|
||||
} catch (e: any) {
|
||||
error(e?.data?.message || getLocaleText('errors', 'restoreFailed', '恢复失败'))
|
||||
error(e?.data?.message || '恢复失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
@@ -321,15 +306,10 @@ async function doPreview() {
|
||||
try {
|
||||
const defaultData =
|
||||
form.value.key === 'verification.code'
|
||||
? {
|
||||
name: locale.value?.previewData?.name || 'Example User',
|
||||
email: 'example@school.edu',
|
||||
code: '123456',
|
||||
expiresInMinutes: 5
|
||||
}
|
||||
? { name: '张三', email: 'example@school.edu', code: '123456', expiresInMinutes: 5 }
|
||||
: {
|
||||
title: locale.value?.previewData?.title || 'System Notification',
|
||||
message: locale.value?.previewData?.message || 'This is preview content.\nLine breaks and links are supported.',
|
||||
title: '系统通知',
|
||||
message: '这是一条预览内容\n支持换行与链接',
|
||||
actionUrl: 'https://example.com'
|
||||
}
|
||||
const res = await $fetch('/api/admin/email-templates/preview', {
|
||||
@@ -339,7 +319,7 @@ async function doPreview() {
|
||||
previewHtml.value = res.html
|
||||
previewSubject.value = res.subject
|
||||
} catch (e: any) {
|
||||
error(e?.data?.message || getLocaleText('errors', 'previewFailed', '预览失败'))
|
||||
error(e?.data?.message || '预览失败')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -356,10 +336,10 @@ onMounted(() => {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--panel-bg-alt);
|
||||
background: var(--bg-tertiary, #27272a);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--panel-bg-hover);
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,540 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div class="flex justify-between items-center">
|
||||
<h3 class="text-lg font-bold text-zinc-100 flex items-center gap-2">
|
||||
<CalendarDays class="w-5 h-5 text-blue-400" />
|
||||
期望播出时段设置
|
||||
</h3>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg text-sm font-bold transition-colors"
|
||||
@click="openAddModal"
|
||||
>
|
||||
新增开放日期
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="text-center py-10">
|
||||
<Loader2 class="w-6 h-6 animate-spin mx-auto text-zinc-500" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="dates.length === 0" class="text-center py-10 text-zinc-500 bg-zinc-900/50 rounded-xl border border-zinc-800">
|
||||
暂未设置任何开放日期
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||
<div v-for="date in dates" :key="date.id" class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 flex flex-col gap-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div class="font-bold text-zinc-100 flex items-center gap-2">
|
||||
{{ date.date }}
|
||||
<span class="text-xs font-normal text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded">{{ date.weekday }}</span>
|
||||
</div>
|
||||
<button @click="openDeleteDialog(date)" class="text-red-400 hover:text-red-300 p-1 transition-colors" title="删除">
|
||||
<Trash2 class="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2.5 bg-zinc-950/50 p-3 rounded-lg border border-zinc-800/50">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs text-zinc-500">开放范围</div>
|
||||
<div class="text-xs font-mono text-blue-400 font-medium break-all text-right ml-4">{{ formatSequences(date.availableSequences) }}</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-xs text-zinc-500">序号状态</div>
|
||||
<div class="text-xs flex gap-2">
|
||||
<span class="text-emerald-500 bg-emerald-500/10 px-1.5 py-0.5 rounded border border-emerald-500/20">空闲 {{ getAvailableCount(date) }}</span>
|
||||
<span class="text-zinc-400 bg-zinc-800 px-1.5 py-0.5 rounded border border-zinc-700">共 {{ getTotalCount(date) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增详细占用情况列表 -->
|
||||
<div v-if="date.takenSequences && date.takenSequences.length > 0" class="mt-1 pt-3 border-t border-zinc-800">
|
||||
<div class="text-xs font-bold text-zinc-500 mb-2 flex items-center justify-between">
|
||||
<span>已占用明细</span>
|
||||
<span class="text-red-400">{{ date.takenSequences.length }} 个</span>
|
||||
</div>
|
||||
<div class="space-y-1.5 max-h-32 overflow-y-auto pr-1 custom-scrollbar">
|
||||
<div
|
||||
v-for="seq in date.takenSequences"
|
||||
:key="seq"
|
||||
class="flex items-center gap-2 text-[11px]"
|
||||
>
|
||||
<span class="w-5 h-5 rounded flex items-center justify-center bg-red-500/10 text-red-500 font-bold border border-red-500/20 shrink-0">{{ seq }}</span>
|
||||
<span class="text-zinc-300 truncate" :title="date.takenDetails?.[seq]?.title">{{ date.takenDetails?.[seq]?.title || '未知歌曲' }}</span>
|
||||
<span class="text-zinc-500 truncate" :title="date.takenDetails?.[seq]?.artist">- {{ date.takenDetails?.[seq]?.artist || '未知歌手' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-2 pt-3 border-t border-zinc-800 text-xs text-emerald-500/70 font-bold">
|
||||
所有序号目前空闲
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
|
||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-lg p-6">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h3 class="text-lg font-bold text-zinc-100">新增开放日期</h3>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="flex p-1 bg-zinc-950 rounded-lg mb-6">
|
||||
<button
|
||||
@click="activeTab = 'auto'"
|
||||
:class="['flex-1 py-2 text-xs font-bold rounded-md transition-colors', activeTab === 'auto' ? 'bg-blue-600 text-white' : 'text-zinc-500 hover:text-zinc-300']"
|
||||
>
|
||||
批量自动分配
|
||||
</button>
|
||||
<button
|
||||
@click="activeTab = 'manual'; nextTick(() => { setTimeout(() => { setStartSeq(manualFormData.startSeq); setEndSeq(manualFormData.endSeq) }, 100) })"
|
||||
:class="['flex-1 py-2 text-xs font-bold rounded-md transition-colors', activeTab === 'manual' ? 'bg-blue-600 text-white' : 'text-zinc-500 hover:text-zinc-300']"
|
||||
>
|
||||
手动配置单日
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 自动分配表单 -->
|
||||
<div v-if="activeTab === 'auto'" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-zinc-500 mb-2">选择播出日期 (可多选)</label>
|
||||
<AdminMultiDatePicker v-model="autoFormData.dates" />
|
||||
<p class="text-[10px] text-zinc-600 mt-1">已选择 {{ autoFormData.dates.length }} 天</p>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-zinc-500 mb-1">总序号数量</label>
|
||||
<input type="number" min="1" v-model="autoFormData.totalSequences" class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-zinc-200">
|
||||
<div class="flex items-center gap-2 mt-2">
|
||||
<button @click="autoFormData.totalSequences = 21" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">21</button>
|
||||
<button @click="autoFormData.totalSequences = 28" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">28</button>
|
||||
<button @click="autoFormData.totalSequences = 35" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">35</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
||||
<p class="text-xs text-blue-400 font-bold mb-1">自动分配预览:</p>
|
||||
<p class="text-[10px] text-blue-300/80">
|
||||
<span v-if="autoFormData.dates.length === 0">请先选择日期</span>
|
||||
<span v-else>将会把 1 - {{ autoFormData.totalSequences }} 号平均分配到选中的 {{ autoFormData.dates.length }} 天中。每天约 {{ Math.ceil(autoFormData.totalSequences / autoFormData.dates.length) }} 个序号。</span>
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button @click="showModal = false" class="flex-1 py-2 bg-zinc-800 text-zinc-300 rounded-lg text-sm font-bold">取消</button>
|
||||
<button @click="saveAuto" :disabled="saving || autoFormData.dates.length === 0" class="flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-50">批量保存</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 手动配置表单 -->
|
||||
<div v-else class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-zinc-500 mb-2">选择日期 (单选)</label>
|
||||
<AdminDatePicker v-model="manualFormData.date" />
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-zinc-500 mb-1">开放的空闲序号</label>
|
||||
|
||||
<div class="flex flex-col items-center bg-zinc-950 border border-zinc-800 rounded-xl p-4">
|
||||
<!-- 模拟 iOS 滚轮 (这里用简化的网格选择代替复杂的滚轮组件以保证稳定性和易用性) -->
|
||||
<div class="text-sm font-bold text-zinc-400 mb-3 w-full flex justify-between items-center">
|
||||
<span>选择开放范围</span>
|
||||
<span class="text-blue-400">{{ manualFormData.startSeq }} - {{ manualFormData.endSeq }}</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-4 items-center w-full justify-center py-4">
|
||||
<div class="flex flex-col items-center flex-1">
|
||||
<span class="text-[10px] text-zinc-500 mb-2">起始序号</span>
|
||||
<div ref="startScrollRef" class="relative w-full h-32 bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden snap-y snap-mandatory hide-scrollbar" style="scroll-behavior: smooth;" @scroll="handleStartScroll">
|
||||
<div class="absolute top-1/2 left-0 right-0 h-10 -mt-5 bg-blue-500/10 border-y border-blue-500/30 pointer-events-none z-10"></div>
|
||||
<div class="h-[44px]"></div> <!-- Padding top -->
|
||||
<div
|
||||
v-for="n in 100"
|
||||
:key="'start-'+n"
|
||||
class="h-10 flex items-center justify-center text-lg font-bold snap-center cursor-pointer transition-colors"
|
||||
:class="manualFormData.startSeq === n ? 'text-blue-400' : 'text-zinc-500 hover:text-zinc-300'"
|
||||
@click="setStartSeq(n)"
|
||||
:data-val="n"
|
||||
>
|
||||
{{ n }}
|
||||
</div>
|
||||
<div class="h-[44px]"></div> <!-- Padding bottom -->
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span class="text-zinc-500 font-bold">至</span>
|
||||
|
||||
<div class="flex flex-col items-center flex-1">
|
||||
<span class="text-[10px] text-zinc-500 mb-2">结束序号</span>
|
||||
<div ref="endScrollRef" class="relative w-full h-32 bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden snap-y snap-mandatory hide-scrollbar" style="scroll-behavior: smooth;" @scroll="handleEndScroll">
|
||||
<div class="absolute top-1/2 left-0 right-0 h-10 -mt-5 bg-blue-500/10 border-y border-blue-500/30 pointer-events-none z-10"></div>
|
||||
<div class="h-[44px]"></div> <!-- Padding top -->
|
||||
<div
|
||||
v-for="n in 100"
|
||||
:key="'end-'+n"
|
||||
class="h-10 flex items-center justify-center text-lg font-bold snap-center cursor-pointer transition-colors"
|
||||
:class="manualFormData.endSeq === n ? 'text-blue-400' : 'text-zinc-500 hover:text-zinc-300'"
|
||||
@click="setEndSeq(n)"
|
||||
:data-val="n"
|
||||
>
|
||||
{{ n }}
|
||||
</div>
|
||||
<div class="h-[44px]"></div> <!-- Padding bottom -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 如果有特定跳过的序号,保留文本框以供进阶编辑 -->
|
||||
<div class="w-full mt-4">
|
||||
<div
|
||||
class="flex justify-between items-center text-[10px] text-zinc-500 cursor-pointer py-2 hover:text-zinc-300 transition-colors"
|
||||
@click="showAdvancedManual = !showAdvancedManual"
|
||||
>
|
||||
<span>进阶:自定义跳过某些序号</span>
|
||||
<span :class="{'rotate-180': showAdvancedManual}" class="transition-transform">▼</span>
|
||||
</div>
|
||||
|
||||
<div v-if="showAdvancedManual" class="mt-2">
|
||||
<textarea v-model="manualFormData.sequences" rows="2" placeholder="例如: 1,2,3,5 (跳过了4)" class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-zinc-300 resize-none focus:outline-none focus:border-blue-500"></textarea>
|
||||
<p class="text-[10px] text-zinc-600 mt-1">手动编辑后将以此处文本框内容为准</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] text-zinc-600 mt-2">注意:手动配置会覆盖该日期原有的设置</p>
|
||||
</div>
|
||||
<div class="flex gap-3 pt-4">
|
||||
<button @click="showModal = false" class="flex-1 py-2 bg-zinc-800 text-zinc-300 rounded-lg text-sm font-bold">取消</button>
|
||||
<button @click="saveManual" :disabled="saving || !manualFormData.date" class="flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-50">保存</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
:show="showDeleteDialog"
|
||||
title="确认删除"
|
||||
:message="deleteDialogMessage"
|
||||
type="danger"
|
||||
confirm-text="确认删除"
|
||||
cancel-text="取消"
|
||||
:loading="deleting"
|
||||
@confirm="confirmDelete"
|
||||
@update:show="showDeleteDialog = $event"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, nextTick, computed } from 'vue'
|
||||
import { CalendarDays, Trash2, Loader2 } from '@lucide/vue'
|
||||
import AdminMultiDatePicker from './AdminMultiDatePicker.vue'
|
||||
import AdminDatePicker from './AdminDatePicker.vue'
|
||||
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
|
||||
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
|
||||
const auth = useAuth()
|
||||
const dates = ref([])
|
||||
const loading = ref(true)
|
||||
const showModal = ref(false)
|
||||
const saving = ref(false)
|
||||
const activeTab = ref('auto')
|
||||
const showAdvancedManual = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTarget = ref(null)
|
||||
const deleting = ref(false)
|
||||
|
||||
const deleteDialogMessage = computed(() => {
|
||||
if (!deleteTarget.value) return '确定要删除该开放日期吗?此操作不可撤销。'
|
||||
const dateStr = deleteTarget.value.date || ''
|
||||
return `确定要删除开放日期 ${dateStr} 吗?此操作不可撤销。`
|
||||
})
|
||||
|
||||
const formatSequences = (seqStr) => {
|
||||
if (!seqStr) return '无'
|
||||
const seqs = seqStr.split(',').map(Number).filter(n => !isNaN(n)).sort((a, b) => a - b)
|
||||
if (seqs.length === 0) return '无'
|
||||
|
||||
const ranges = []
|
||||
let start = seqs[0]
|
||||
let prev = seqs[0]
|
||||
|
||||
for (let i = 1; i < seqs.length; i++) {
|
||||
if (seqs[i] === prev + 1) {
|
||||
prev = seqs[i]
|
||||
} else {
|
||||
ranges.push(start === prev ? `${start}` : `${start}-${prev}`)
|
||||
start = seqs[i]
|
||||
prev = seqs[i]
|
||||
}
|
||||
}
|
||||
ranges.push(start === prev ? `${start}` : `${start}-${prev}`)
|
||||
|
||||
return ranges.join(', ')
|
||||
}
|
||||
|
||||
const getTotalCount = (date) => {
|
||||
return date.availableSequences ? date.availableSequences.split(',').filter(Boolean).length : 0
|
||||
}
|
||||
|
||||
const getTakenCount = (date) => {
|
||||
return date.takenSequences ? date.takenSequences.length : 0
|
||||
}
|
||||
|
||||
const getAvailableCount = (date) => {
|
||||
return Math.max(0, getTotalCount(date) - getTakenCount(date))
|
||||
}
|
||||
|
||||
const startScrollRef = ref(null)
|
||||
const endScrollRef = ref(null)
|
||||
let scrollTimeoutStart = null
|
||||
let scrollTimeoutEnd = null
|
||||
|
||||
const autoFormData = ref({
|
||||
dates: [],
|
||||
totalSequences: 28
|
||||
})
|
||||
|
||||
const manualFormData = ref({
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
startSeq: 1,
|
||||
endSeq: 28,
|
||||
sequences: ''
|
||||
})
|
||||
|
||||
const fetchDates = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const res = await $fetch('/api/admin/expected-play-dates', {
|
||||
...auth.getAuthConfig(),
|
||||
query: { t: Date.now() }
|
||||
})
|
||||
dates.value = res
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const setStartSeq = (val) => {
|
||||
manualFormData.value.startSeq = val
|
||||
if (startScrollRef.value) {
|
||||
const el = startScrollRef.value.querySelector(`[data-val="${val}"]`)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}
|
||||
|
||||
const setEndSeq = (val) => {
|
||||
manualFormData.value.endSeq = val
|
||||
if (endScrollRef.value) {
|
||||
const el = endScrollRef.value.querySelector(`[data-val="${val}"]`)
|
||||
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
}
|
||||
|
||||
const handleStartScroll = (e) => {
|
||||
clearTimeout(scrollTimeoutStart)
|
||||
scrollTimeoutStart = setTimeout(() => {
|
||||
if (!startScrollRef.value) return
|
||||
const container = startScrollRef.value
|
||||
const center = container.scrollTop + container.clientHeight / 2
|
||||
const items = Array.from(container.querySelectorAll('[data-val]'))
|
||||
let closest = items[0]
|
||||
let minDiff = Infinity
|
||||
|
||||
items.forEach(item => {
|
||||
const itemCenter = item.offsetTop + item.clientHeight / 2
|
||||
const diff = Math.abs(center - itemCenter)
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff
|
||||
closest = item
|
||||
}
|
||||
})
|
||||
|
||||
if (closest) {
|
||||
const val = parseInt(closest.getAttribute('data-val'))
|
||||
manualFormData.value.startSeq = val
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const handleEndScroll = (e) => {
|
||||
clearTimeout(scrollTimeoutEnd)
|
||||
scrollTimeoutEnd = setTimeout(() => {
|
||||
if (!endScrollRef.value) return
|
||||
const container = endScrollRef.value
|
||||
const center = container.scrollTop + container.clientHeight / 2
|
||||
const items = Array.from(container.querySelectorAll('[data-val]'))
|
||||
let closest = items[0]
|
||||
let minDiff = Infinity
|
||||
|
||||
items.forEach(item => {
|
||||
const itemCenter = item.offsetTop + item.clientHeight / 2
|
||||
const diff = Math.abs(center - itemCenter)
|
||||
if (diff < minDiff) {
|
||||
minDiff = diff
|
||||
closest = item
|
||||
}
|
||||
})
|
||||
|
||||
if (closest) {
|
||||
const val = parseInt(closest.getAttribute('data-val'))
|
||||
manualFormData.value.endSeq = val
|
||||
}
|
||||
}, 150)
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
activeTab.value = 'auto'
|
||||
autoFormData.value = { dates: [], totalSequences: 28 }
|
||||
manualFormData.value = { date: new Date().toISOString().split('T')[0], startSeq: 1, endSeq: 28, sequences: '' }
|
||||
showAdvancedManual.value = false
|
||||
showModal.value = true
|
||||
|
||||
// 延时滚动到对应位置
|
||||
nextTick(() => {
|
||||
setTimeout(() => {
|
||||
setStartSeq(manualFormData.value.startSeq)
|
||||
setEndSeq(manualFormData.value.endSeq)
|
||||
}, 100)
|
||||
})
|
||||
}
|
||||
|
||||
const saveAuto = async () => {
|
||||
if (autoFormData.value.dates.length === 0 || !autoFormData.value.totalSequences) return
|
||||
saving.value = true
|
||||
|
||||
const selectedDates = [...autoFormData.value.dates].sort()
|
||||
const totalDays = selectedDates.length
|
||||
const maxSeq = parseInt(autoFormData.value.totalSequences)
|
||||
const seqsPerDay = Math.ceil(maxSeq / totalDays)
|
||||
|
||||
try {
|
||||
for (let i = 0; i < selectedDates.length; i++) {
|
||||
const date = selectedDates[i]
|
||||
const startSeq = i * seqsPerDay + 1
|
||||
const endSeq = Math.min((i + 1) * seqsPerDay, maxSeq)
|
||||
|
||||
const seqs = []
|
||||
for (let j = startSeq; j <= endSeq; j++) {
|
||||
seqs.push(j)
|
||||
}
|
||||
|
||||
if (seqs.length > 0) {
|
||||
await $fetch('/api/admin/expected-play-dates', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
date: date,
|
||||
availableSequences: seqs.join(',')
|
||||
},
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
showModal.value = false
|
||||
fetchDates()
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification('批量自动分配成功', 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(e.data?.message || '保存失败', 'error')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveManual = async () => {
|
||||
let finalSequences = manualFormData.value.sequences
|
||||
if (!showAdvancedManual.value || !finalSequences) {
|
||||
const start = Math.min(manualFormData.value.startSeq, manualFormData.value.endSeq)
|
||||
const end = Math.max(manualFormData.value.startSeq, manualFormData.value.endSeq)
|
||||
finalSequences = Array.from({ length: end - start + 1 }, (_, i) => start + i).join(',')
|
||||
}
|
||||
|
||||
if (!manualFormData.value.date || !finalSequences) return
|
||||
saving.value = true
|
||||
try {
|
||||
await $fetch('/api/admin/expected-play-dates', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
date: manualFormData.value.date,
|
||||
availableSequences: finalSequences
|
||||
},
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
showModal.value = false
|
||||
fetchDates()
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification('手动配置成功', 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(e.data?.message || '保存失败', 'error')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
const deleteDate = async (id) => {
|
||||
try {
|
||||
await $fetch(`/api/admin/expected-play-dates/${id}`, {
|
||||
method: 'DELETE',
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
fetchDates()
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification('删除成功', 'success')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(e.data?.message || '删除失败', 'error')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const openDeleteDialog = (date) => {
|
||||
deleteTarget.value = date
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTarget.value?.id) return
|
||||
deleting.value = true
|
||||
try {
|
||||
await deleteDate(deleteTarget.value.id)
|
||||
showDeleteDialog.value = false
|
||||
deleteTarget.value = null
|
||||
} finally {
|
||||
deleting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(fetchDates)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #4a5568;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #718096;
|
||||
}
|
||||
</style>
|
||||
@@ -2,82 +2,45 @@
|
||||
<div class="max-w-[1400px] mx-auto space-y-8 pb-20">
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
|
||||
<p class="text-xs text-text-tertiary mt-1">{{ locale.desc }}</p>
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">向用户发送通知</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">即时推送系统公告、活动信息或重要提醒至指定群体</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
<!-- 左侧:编辑区 -->
|
||||
<div class="lg:col-span-7 space-y-6">
|
||||
<div class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-8 shadow-xl">
|
||||
<div class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-8 shadow-xl">
|
||||
<div class="space-y-6">
|
||||
<!-- 标题 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em] px-1"
|
||||
>{{ locale.notificationTitle }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
|
||||
>通知标题</label
|
||||
>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
:placeholder="locale.titlePlaceholder"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-3.5 text-sm focus:outline-none focus:border-primary-30 transition-all text-text-primary placeholder:text-text-primary"
|
||||
placeholder="请输入通知标题"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200 placeholder:text-zinc-800"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em] px-1"
|
||||
>{{ locale.notificationContent }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
|
||||
>通知内容</label
|
||||
>
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
:placeholder="locale.contentPlaceholder"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-primary-30 transition-all text-text-primary placeholder:text-text-primary min-h-[160px] resize-none"
|
||||
placeholder="请输入通知详情内容..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200 placeholder:text-zinc-800 min-h-[160px] resize-none"
|
||||
/>
|
||||
<p class="px-1 text-[10px] font-medium text-text-disabled">
|
||||
{{ locale.markdownHint }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 重要通知开关 -->
|
||||
<label
|
||||
class="flex cursor-pointer items-start gap-4 rounded-2xl border p-4 shadow-sm transition-colors"
|
||||
:class="
|
||||
form.important
|
||||
? 'border-primary-300 bg-primary-10'
|
||||
: 'border-border-tertiary bg-bg-primary hover:border-border-quaternary hover:bg-bg-secondary'
|
||||
"
|
||||
>
|
||||
<span
|
||||
class="relative mt-0.5 inline-block h-6 w-11 shrink-0"
|
||||
>
|
||||
<input v-model="form.important" type="checkbox" class="peer sr-only">
|
||||
<span
|
||||
class="absolute inset-0 rounded-full bg-border-tertiary transition-colors peer-checked:bg-primary peer-focus-visible:ring-2 peer-focus-visible:ring-primary-300"
|
||||
/>
|
||||
<span
|
||||
class="absolute left-1 top-1 h-4 w-4 rounded-full bg-white shadow-sm transition-transform peer-checked:translate-x-5"
|
||||
/>
|
||||
</span>
|
||||
<span class="min-w-0">
|
||||
<span class="flex items-center gap-2 text-sm font-black text-text-primary">
|
||||
<AlertTriangle
|
||||
:size="16"
|
||||
:class="form.important ? 'text-primary' : 'text-text-tertiary'"
|
||||
/>
|
||||
{{ locale.importantToggleTitle }}
|
||||
</span>
|
||||
<span class="mt-1 block text-xs leading-relaxed text-text-secondary">
|
||||
{{ locale.importantToggleDescription }}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
|
||||
<!-- 范围选择 -->
|
||||
<div class="space-y-3 pt-4 border-t border-border-secondary-50">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em] px-1"
|
||||
>{{ locale.scope }}</label
|
||||
<div class="space-y-3 pt-4 border-t border-zinc-800/50">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
|
||||
>通知范围</label
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
@@ -86,8 +49,8 @@
|
||||
:class="[
|
||||
'flex items-center gap-2 px-4 py-2.5 rounded-xl text-[11px] font-black transition-all border',
|
||||
form.scope === opt.id
|
||||
? 'bg-primary-hover border-primary text-text-primary shadow-lg shadow-[var(--primary-glow)]'
|
||||
: 'bg-bg-primary border-border-secondary text-text-tertiary hover:border-border-tertiary hover:text-text-secondary'
|
||||
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'bg-zinc-950 border-zinc-800 text-zinc-500 hover:border-zinc-700 hover:text-zinc-300'
|
||||
]"
|
||||
@click="form.scope = opt.id"
|
||||
>
|
||||
@@ -103,26 +66,24 @@
|
||||
<div
|
||||
v-if="form.scope === 'ALL'"
|
||||
key="all"
|
||||
class="p-4 bg-primary-5 border border-primary-10 rounded-2xl flex items-center gap-4"
|
||||
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-2xl flex items-center gap-4"
|
||||
>
|
||||
<Info class="text-primary shrink-0" :size="18" />
|
||||
<p class="text-[11px] font-bold text-text-tertiary">
|
||||
{{ locale.allUsersHint }}
|
||||
<Info class="text-blue-500 shrink-0" :size="18" />
|
||||
<p class="text-[11px] font-bold text-zinc-400">
|
||||
将向系统中所有已注册的活跃用户发送此通知。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="form.scope === 'GRADE'" key="grade" class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.selectGrade }}</span
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>选择年级</span
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="form.grade"
|
||||
:options="gradeOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
:placeholder="locale.selectGradePlaceholder"
|
||||
class-name="w-full md:w-64"
|
||||
:options="['高一', '高二', '高三', '教师']"
|
||||
placeholder="请选择年级"
|
||||
class="w-full md:w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -133,61 +94,55 @@
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.grade }}</span
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>年级</span
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="form.classGrade"
|
||||
:options="gradeOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
:placeholder="locale.selectGradePlaceholder"
|
||||
class-name="w-full"
|
||||
:options="['高一', '高二', '高三', '教师']"
|
||||
placeholder="请选择年级"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.className }}</span
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>班级</span
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="form.className"
|
||||
:options="classOptionsForClassScope"
|
||||
:options="classNumberOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
:placeholder="locale.selectClassPlaceholder"
|
||||
placeholder="请选择班级"
|
||||
class-name="w-full"
|
||||
:disabled="!form.classGrade"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="form.scope === 'MULTI_CLASS'" key="multi" class="space-y-4">
|
||||
<div class="p-6 bg-bg-primary-50 border border-border-secondary border-dashed rounded-2xl">
|
||||
<div class="p-6 bg-zinc-950/50 border border-zinc-800 border-dashed rounded-2xl">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||||
<CustomSelect
|
||||
v-model="multiClassForm.grade"
|
||||
:options="gradeOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
:placeholder="locale.selectGradePlaceholder"
|
||||
class-name="w-full"
|
||||
:options="['高一', '高二', '高三', '教师']"
|
||||
placeholder="请选择年级"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<CustomSelect
|
||||
v-model="multiClassForm.class"
|
||||
:options="classOptionsForMultiClassScope"
|
||||
:options="classNumberOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
:placeholder="locale.selectClassPlaceholder"
|
||||
placeholder="请选择班级"
|
||||
class-name="flex-1"
|
||||
:disabled="!multiClassForm.grade"
|
||||
/>
|
||||
<button
|
||||
:disabled="!canAddClass"
|
||||
class="px-4 bg-bg-tertiary hover:bg-bg-quaternary disabled:opacity-50 disabled:cursor-not-allowed text-text-tertiary font-bold rounded-xl text-xs transition-all"
|
||||
class="px-4 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-400 font-bold rounded-xl text-xs transition-all"
|
||||
@click="addClassToSelection"
|
||||
>
|
||||
{{ locale.add }}
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -196,13 +151,13 @@
|
||||
<div
|
||||
v-for="(cls, index) in form.selectedClasses"
|
||||
:key="index"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-bg-secondary border border-border-secondary rounded-lg group"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-zinc-900 border border-zinc-800 rounded-lg group"
|
||||
>
|
||||
<span class="text-[10px] font-bold text-text-tertiary"
|
||||
<span class="text-[10px] font-bold text-zinc-400"
|
||||
>{{ cls.grade }} {{ cls.class }}</span
|
||||
>
|
||||
<button
|
||||
class="text-text-disabled hover:text-error transition-colors"
|
||||
class="text-zinc-600 hover:text-red-400 transition-colors"
|
||||
@click="removeClassFromSelection(index)"
|
||||
>
|
||||
<X :size="12" />
|
||||
@@ -210,8 +165,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-4">
|
||||
<p class="text-[10px] font-black text-text-secondary uppercase tracking-widest">
|
||||
{{ locale.noClassSelected }}
|
||||
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
|
||||
未选择任何班级
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -221,19 +176,19 @@
|
||||
<div class="relative flex items-center">
|
||||
<Search
|
||||
v-if="!userSearchLoading"
|
||||
class="absolute left-4 text-text-secondary pointer-events-none"
|
||||
class="absolute left-4 text-zinc-700 pointer-events-none"
|
||||
:size="16"
|
||||
/>
|
||||
<Loader2
|
||||
v-else
|
||||
class="absolute left-4 text-primary animate-spin pointer-events-none"
|
||||
class="absolute left-4 text-blue-500 animate-spin pointer-events-none"
|
||||
:size="16"
|
||||
/>
|
||||
<input
|
||||
v-model="userSearchQuery"
|
||||
type="text"
|
||||
:placeholder="locale.userSearchPlaceholder"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-2xl pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-primary-30 transition-all text-text-primary"
|
||||
placeholder="搜索并选择用户 (姓名、用户名或ID)..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
|
||||
@input="onUserSearchInput"
|
||||
>
|
||||
</div>
|
||||
@@ -241,42 +196,42 @@
|
||||
<!-- 搜索结果 -->
|
||||
<div
|
||||
v-if="showUserSearchResults && userSearchResults.length > 0"
|
||||
class="bg-bg-primary border border-border-secondary rounded-2xl overflow-hidden max-h-[300px] overflow-y-auto custom-scrollbar"
|
||||
class="bg-zinc-950 border border-zinc-800 rounded-2xl overflow-hidden max-h-[300px] overflow-y-auto custom-scrollbar"
|
||||
>
|
||||
<div class="px-4 py-2 border-b border-border-secondary-60 bg-bg-secondary-40">
|
||||
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest"
|
||||
>{{ getLocaleMessage('searchResults', userSearchResults.length) }}</span
|
||||
<div class="px-4 py-2 border-b border-zinc-800/60 bg-zinc-900/40">
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>搜索结果 ({{ userSearchResults.length }})</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-for="user in userSearchResults"
|
||||
:key="user.id"
|
||||
class="flex items-center justify-between p-4 border-b border-border-secondary-30 last:border-0 hover:bg-bg-tertiary-30 transition-all"
|
||||
class="flex items-center justify-between p-4 border-b border-zinc-800/30 last:border-0 hover:bg-zinc-800/30 transition-all"
|
||||
>
|
||||
<div>
|
||||
<h5 class="text-sm font-bold text-text-primary">
|
||||
<h5 class="text-sm font-bold text-zinc-200">
|
||||
{{ user.name || user.username }}
|
||||
</h5>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span class="text-[10px] text-primary font-black"
|
||||
<span class="text-[10px] text-blue-500 font-black"
|
||||
>@{{ user.username }}</span
|
||||
>
|
||||
<span
|
||||
v-if="user.grade && user.class"
|
||||
class="text-[10px] text-text-disabled font-bold uppercase tracking-widest"
|
||||
class="text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
|
||||
>{{ user.grade }} {{ user.class }}</span
|
||||
>
|
||||
<span class="text-[10px] text-text-secondary font-black">{{
|
||||
<span class="text-[10px] text-zinc-700 font-black">{{
|
||||
getRoleText(user.role)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:disabled="isUserSelected(user.id)"
|
||||
class="px-3 py-1.5 bg-bg-tertiary hover:bg-primary-hover hover:text-text-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-tertiary text-[10px] font-black rounded-lg transition-all uppercase"
|
||||
class="px-3 py-1.5 bg-zinc-800 hover:bg-blue-600 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase"
|
||||
@click="addUserToSelection(user)"
|
||||
>
|
||||
{{ isUserSelected(user.id) ? locale.selected : locale.select }}
|
||||
{{ isUserSelected(user.id) ? '已选择' : '选择' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -284,30 +239,30 @@
|
||||
<!-- 已选择的用户 -->
|
||||
<div v-if="form.selectedUsers.length > 0" class="space-y-3">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<span class="text-[9px] font-black text-text-disabled uppercase tracking-widest"
|
||||
>{{ getLocaleMessage('selectedUsers', form.selectedUsers.length) }}</span
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>已选择用户 ({{ form.selectedUsers.length }})</span
|
||||
>
|
||||
<button
|
||||
class="text-[9px] font-black text-error-70 hover:text-error uppercase tracking-widest transition-colors"
|
||||
class="text-[9px] font-black text-red-500/70 hover:text-red-500 uppercase tracking-widest transition-colors"
|
||||
@click="clearAllSelectedUsers"
|
||||
>
|
||||
{{ locale.clearAll }}
|
||||
清空全部
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(user, index) in form.selectedUsers"
|
||||
:key="user.id"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-bg-secondary border border-border-secondary rounded-lg group"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-zinc-900 border border-zinc-800 rounded-lg group"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[10px] font-bold text-text-secondary leading-none">{{
|
||||
<span class="text-[10px] font-bold text-zinc-300 leading-none">{{
|
||||
user.name || user.username
|
||||
}}</span>
|
||||
<span class="text-[8px] text-text-disabled">@{{ user.username }}</span>
|
||||
<span class="text-[8px] text-zinc-600">@{{ user.username }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="text-text-disabled hover:text-error transition-colors"
|
||||
class="text-zinc-600 hover:text-red-400 transition-colors"
|
||||
@click="removeUserFromSelection(index)"
|
||||
>
|
||||
<X :size="12" />
|
||||
@@ -316,8 +271,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-4">
|
||||
<p class="text-[10px] font-black text-text-secondary uppercase tracking-widest">
|
||||
{{ locale.noUsersSelected }}
|
||||
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
|
||||
未选择任何用户
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -329,17 +284,17 @@
|
||||
<div v-if="error || success" class="mt-6">
|
||||
<div
|
||||
v-if="error"
|
||||
class="p-4 bg-error-5 border border-error-10 rounded-2xl flex items-center gap-3"
|
||||
class="p-4 bg-red-500/5 border border-red-500/10 rounded-2xl flex items-center gap-3"
|
||||
>
|
||||
<AlertCircle class="text-error shrink-0" :size="18" />
|
||||
<p class="text-xs font-bold text-error">{{ error }}</p>
|
||||
<AlertCircle class="text-red-500 shrink-0" :size="18" />
|
||||
<p class="text-xs font-bold text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="success"
|
||||
class="p-4 bg-success-5 border border-success-10 rounded-2xl flex items-center gap-3"
|
||||
class="p-4 bg-green-500/5 border border-green-500/10 rounded-2xl flex items-center gap-3"
|
||||
>
|
||||
<Check class="text-success shrink-0" :size="18" />
|
||||
<p class="text-xs font-bold text-success">{{ success }}</p>
|
||||
<Check class="text-green-500 shrink-0" :size="18" />
|
||||
<p class="text-xs font-bold text-green-400">{{ success }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -347,16 +302,16 @@
|
||||
<button
|
||||
:disabled="loading || !isFormValid"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-10 py-4 bg-bg-primary border border-border-secondary text-xs font-black rounded-2xl transition-all uppercase tracking-[0.2em] shadow-lg',
|
||||
'flex items-center gap-2 px-10 py-4 bg-zinc-950 border border-zinc-800 text-xs font-black rounded-2xl transition-all uppercase tracking-[0.2em] shadow-lg',
|
||||
loading || !isFormValid
|
||||
? 'text-text-primary cursor-not-allowed'
|
||||
: 'text-text-tertiary hover:text-text-primary hover:border-border-tertiary hover:bg-bg-secondary'
|
||||
? 'text-zinc-800 cursor-not-allowed'
|
||||
: 'text-zinc-400 hover:text-zinc-100 hover:border-zinc-600 hover:bg-zinc-900'
|
||||
]"
|
||||
@click="sendNotification"
|
||||
>
|
||||
<Loader2 v-if="loading" class="animate-spin" :size="16" />
|
||||
<Send v-else :size="16" />
|
||||
{{ loading ? locale.sending : locale.send }}
|
||||
{{ loading ? '发送中...' : '发送通知' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -365,130 +320,97 @@
|
||||
<!-- 右侧:预览区 -->
|
||||
<div class="lg:col-span-5 space-y-6 lg:sticky lg:top-8">
|
||||
<div
|
||||
class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-8 shadow-xl flex flex-col h-full"
|
||||
class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-8 shadow-xl flex flex-col h-full"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-black text-text-primary uppercase tracking-widest mb-6 flex items-center gap-2"
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest mb-6 flex items-center gap-2"
|
||||
>
|
||||
<Eye :size="16" class="text-primary" /> {{ locale.previewTitle }}
|
||||
<Eye :size="16" class="text-blue-500" /> 通知预览
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-1 flex-col items-center justify-center p-1 sm:p-4">
|
||||
<!-- 重要通知预览 -->
|
||||
<section
|
||||
v-if="form.important"
|
||||
class="w-full max-w-[320px] overflow-hidden rounded-2xl border border-primary-300 bg-bg-primary shadow-2xl"
|
||||
>
|
||||
<div class="h-1.5 w-full bg-warning" />
|
||||
<div class="p-5 space-y-5">
|
||||
<header class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-warning-300 bg-warning-10 text-warning"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="bell-ring" :size="17" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4
|
||||
class="break-words text-sm font-black"
|
||||
:class="form.title ? 'text-text-primary' : 'italic text-text-disabled'"
|
||||
>
|
||||
{{ form.title || locale.previewTitlePlaceholder }}
|
||||
</h4>
|
||||
<p class="mt-1 truncate text-[11px] font-black text-text-disabled">
|
||||
{{ locale.senderLabel }}:{{ previewSenderName }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="form.content"
|
||||
class="markdown-body max-h-64 overflow-y-auto text-sm leading-relaxed text-text-tertiary"
|
||||
v-html="previewContent"
|
||||
/>
|
||||
<p v-else class="text-sm italic leading-relaxed text-text-disabled">
|
||||
{{ locale.previewContentPlaceholder }}
|
||||
</p>
|
||||
|
||||
<footer class="flex items-center justify-between gap-4 border-t border-border-secondary-50 pt-4">
|
||||
<div class="flex min-w-0 items-center gap-1.5 text-text-secondary">
|
||||
<Users :size="13" class="shrink-0" aria-hidden="true" />
|
||||
<span class="truncate text-[10px] font-black text-text-disabled">
|
||||
{{ getLocaleMessage('previewScope', scopeDescription) }}
|
||||
</span>
|
||||
</div>
|
||||
<MessageSquare :size="17" class="shrink-0 text-warning" aria-hidden="true" />
|
||||
</footer>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 普通通知预览 -->
|
||||
<article
|
||||
v-else
|
||||
class="w-full max-w-[320px] overflow-hidden rounded-2xl border border-border-secondary bg-bg-primary shadow-2xl"
|
||||
>
|
||||
<div class="h-1.5 w-full bg-primary" />
|
||||
<div class="p-5 space-y-5">
|
||||
<header class="flex items-start gap-3">
|
||||
<div
|
||||
class="flex h-9 w-9 shrink-0 items-center justify-center rounded-lg border border-primary-20 bg-primary-hover-10 text-primary"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<Icon name="bell" :size="17" />
|
||||
</div>
|
||||
<div class="min-w-0 flex-1">
|
||||
<h4
|
||||
class="break-words text-sm font-black"
|
||||
:class="form.title ? 'text-text-primary' : 'italic text-text-disabled'"
|
||||
>
|
||||
{{ form.title || locale.previewTitlePlaceholder }}
|
||||
</h4>
|
||||
<p class="mt-1 truncate text-[11px] font-black text-text-disabled">
|
||||
{{ locale.senderLabel }}:{{ previewSenderName }}
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
v-if="form.content"
|
||||
class="markdown-body max-h-64 overflow-y-auto text-sm leading-relaxed text-text-tertiary"
|
||||
v-html="previewContent"
|
||||
/>
|
||||
<p v-else class="text-sm italic leading-relaxed text-text-disabled">
|
||||
{{ locale.previewContentPlaceholder }}
|
||||
</p>
|
||||
|
||||
<footer class="flex items-center justify-between gap-4 border-t border-border-secondary-50 pt-4">
|
||||
<div class="flex min-w-0 items-center gap-1.5 text-text-secondary">
|
||||
<Users :size="13" class="shrink-0" aria-hidden="true" />
|
||||
<span class="truncate text-[10px] font-black text-text-disabled">
|
||||
{{ getLocaleMessage('previewScope', scopeDescription) }}
|
||||
</span>
|
||||
</div>
|
||||
<MessageSquare :size="17" class="shrink-0 text-primary" aria-hidden="true" />
|
||||
</footer>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center justify-center p-4">
|
||||
<div
|
||||
class="mt-6 flex w-full max-w-[320px] items-start gap-3 rounded-lg border border-warning-10 bg-warning-5 p-3"
|
||||
class="w-full max-w-[320px] bg-[#0c0c0e] border border-zinc-800 rounded-3xl overflow-hidden shadow-2xl relative"
|
||||
>
|
||||
<AlertCircle :size="14" class="mt-0.5 shrink-0 text-warning" />
|
||||
<p class="text-[10px] font-black text-text-tertiary leading-normal">
|
||||
{{ locale.previewHint }}
|
||||
</p>
|
||||
<!-- 顶部装饰 -->
|
||||
<div class="h-1.5 bg-blue-600 w-full" />
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-8 h-8 rounded-xl bg-blue-600/10 text-blue-500 flex items-center justify-center border border-blue-500/20"
|
||||
>
|
||||
<Bell :size="14" />
|
||||
</div>
|
||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>VoiceHub 系统</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-[9px] text-zinc-700 font-bold uppercase tracking-wider"
|
||||
>刚刚</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h4
|
||||
:class="[
|
||||
'text-sm font-black transition-colors',
|
||||
form.title ? 'text-zinc-100' : 'text-zinc-800 italic'
|
||||
]"
|
||||
>
|
||||
{{ form.title || '通知标题' }}
|
||||
</h4>
|
||||
<p
|
||||
:class="[
|
||||
'text-[11px] leading-relaxed transition-colors',
|
||||
form.content ? 'text-zinc-400' : 'text-zinc-800 italic line-clamp-3'
|
||||
]"
|
||||
>
|
||||
{{ form.content || '这里将显示通知的详细内容预览...' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-zinc-800/50 flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Users :size="12" class="text-zinc-700" />
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-wider">
|
||||
发送范围: {{ scopeDescription }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="p-1.5 text-blue-500 hover:bg-blue-600/10 rounded-lg transition-all"
|
||||
>
|
||||
<MessageSquare :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背景光晕 -->
|
||||
<div
|
||||
class="absolute top-0 right-0 w-32 h-32 bg-blue-600/5 blur-[50px] -z-10 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 space-y-3 w-full max-w-[320px]">
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 bg-amber-500/5 border border-amber-500/10 rounded-2xl"
|
||||
>
|
||||
<AlertCircle class="text-amber-500 shrink-0 mt-0.5" :size="14" />
|
||||
<p class="text-[10px] font-bold text-zinc-500 leading-normal">
|
||||
预览图仅供参考布局,实际发送效果可能会根据用户设备显示语言和屏幕尺寸有所不同。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<NotificationHistory :refresh-key="historyRefreshKey" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onUnmounted, onMounted, watch } from 'vue'
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import {
|
||||
Send,
|
||||
Users,
|
||||
@@ -496,51 +418,27 @@ import {
|
||||
LayoutGrid,
|
||||
User,
|
||||
Search,
|
||||
Bell,
|
||||
Info,
|
||||
X,
|
||||
Check,
|
||||
Plus,
|
||||
AlertCircle,
|
||||
AlertTriangle,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Loader2
|
||||
} from '@lucide/vue'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import NotificationHistory from '~/components/Admin/NotificationHistory.vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useAdmin } from '~/composables/useAdmin'
|
||||
import { useUserFilters } from '~/composables/useUserFilters'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { renderMarkdown } from '~/utils/markdown'
|
||||
|
||||
const { user: authUser, isAdmin, getAuthConfig } = useAuth()
|
||||
const { isAdmin } = useAuth()
|
||||
const { sendAdminNotification } = useAdmin()
|
||||
const userFilters = useUserFilters()
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => admin.value?.notificationSender || {})
|
||||
const { msg: getLocaleMessage, nested: getNestedMessage } = useLocaleText(locale)
|
||||
const previewSenderName = computed(
|
||||
() =>
|
||||
authUser.value?.name?.trim() ||
|
||||
authUser.value?.username?.trim() ||
|
||||
locale.value.previewSender
|
||||
)
|
||||
|
||||
onMounted(() => {
|
||||
userFilters.fetchOptions()
|
||||
})
|
||||
|
||||
const gradeOptions = computed(() => {
|
||||
return userFilters.getAvailableGrades().map(g => ({ label: g, value: g }))
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
important: false,
|
||||
scope: 'ALL', // 'ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'
|
||||
grade: '',
|
||||
classGrade: '',
|
||||
@@ -555,29 +453,9 @@ const multiClassForm = ref({
|
||||
class: ''
|
||||
})
|
||||
|
||||
const classOptionsForClassScope = computed(() => {
|
||||
const classes = userFilters.getAvailableClasses(undefined, form.value.classGrade)
|
||||
return classes.map(c => ({ label: c, value: c }))
|
||||
})
|
||||
|
||||
const classOptionsForMultiClassScope = computed(() => {
|
||||
const classes = userFilters.getAvailableClasses(undefined, multiClassForm.value.grade)
|
||||
return classes.map(c => ({ label: c, value: c }))
|
||||
})
|
||||
|
||||
watch(() => form.value.classGrade, () => {
|
||||
form.value.className = ''
|
||||
})
|
||||
|
||||
watch(() => multiClassForm.value.grade, () => {
|
||||
multiClassForm.value.class = ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const historyRefreshKey = ref(0)
|
||||
const previewContent = computed(() => renderMarkdown(form.value.content))
|
||||
|
||||
// 用户搜索相关
|
||||
const userSearchQuery = ref('')
|
||||
@@ -586,13 +464,18 @@ const showUserSearchResults = ref(false)
|
||||
const userSearchLoading = ref(false)
|
||||
let userSearchTimeout = null
|
||||
|
||||
const targetOptions = computed(() => [
|
||||
{ id: 'ALL', label: getNestedMessage('targets', 'all'), icon: Users },
|
||||
{ id: 'GRADE', label: getNestedMessage('targets', 'grade'), icon: GraduationCap },
|
||||
{ id: 'CLASS', label: getNestedMessage('targets', 'class'), icon: LayoutGrid },
|
||||
{ id: 'MULTI_CLASS', label: getNestedMessage('targets', 'multiClass'), icon: Plus },
|
||||
{ id: 'SPECIFIC_USERS', label: getNestedMessage('targets', 'specificUsers'), icon: User }
|
||||
])
|
||||
const targetOptions = [
|
||||
{ id: 'ALL', label: '全体用户', icon: Users },
|
||||
{ id: 'GRADE', label: '按年级选择', icon: GraduationCap },
|
||||
{ id: 'CLASS', label: '按班级选择', icon: LayoutGrid },
|
||||
{ id: 'MULTI_CLASS', label: '多班级选择', icon: Plus },
|
||||
{ id: 'SPECIFIC_USERS', label: '指定用户', icon: User }
|
||||
]
|
||||
|
||||
const classNumberOptions = Array.from({ length: 16 }, (_, i) => ({
|
||||
label: `${i + 1}班`,
|
||||
value: (i + 1).toString()
|
||||
}))
|
||||
|
||||
// 判断是否可以添加班级
|
||||
const canAddClass = computed(() => {
|
||||
@@ -650,8 +533,7 @@ const searchUsers = async (query) => {
|
||||
query: {
|
||||
search: query,
|
||||
limit: 20
|
||||
},
|
||||
...getAuthConfig()
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
@@ -704,9 +586,9 @@ const clearAllSelectedUsers = () => {
|
||||
// 获取角色文本
|
||||
const getRoleText = (role) => {
|
||||
const roleMap = {
|
||||
admin: getNestedMessage('roles', 'admin'),
|
||||
teacher: getNestedMessage('roles', 'teacher'),
|
||||
student: getNestedMessage('roles', 'student')
|
||||
admin: '管理员',
|
||||
teacher: '教师',
|
||||
student: '学生'
|
||||
}
|
||||
return roleMap[role] || role
|
||||
}
|
||||
@@ -740,23 +622,21 @@ const isFormValid = computed(() => {
|
||||
const scopeDescription = computed(() => {
|
||||
switch (form.value.scope) {
|
||||
case 'ALL':
|
||||
return getNestedMessage('scopeDescriptions', 'all')
|
||||
return '全体用户'
|
||||
case 'GRADE':
|
||||
return form.value.grade
|
||||
? getNestedMessage('scopeDescriptions', 'grade', form.value.grade)
|
||||
: getNestedMessage('scopeDescriptions', 'selectGrade')
|
||||
return form.value.grade ? `${form.value.grade}年级` : '请选择年级'
|
||||
case 'CLASS':
|
||||
return form.value.classGrade && form.value.className
|
||||
? getNestedMessage('scopeDescriptions', 'class', form.value.classGrade, form.value.className)
|
||||
: getNestedMessage('scopeDescriptions', 'selectClass')
|
||||
? `${form.value.classGrade}年级${form.value.className}班`
|
||||
: '请选择班级'
|
||||
case 'MULTI_CLASS':
|
||||
return form.value.selectedClasses.length > 0
|
||||
? getNestedMessage('scopeDescriptions', 'multiClass', form.value.selectedClasses.length)
|
||||
: getNestedMessage('scopeDescriptions', 'selectClass')
|
||||
? `${form.value.selectedClasses.length}个班级`
|
||||
: '请选择班级'
|
||||
case 'SPECIFIC_USERS':
|
||||
return form.value.selectedUsers.length > 0
|
||||
? getNestedMessage('scopeDescriptions', 'specificUsers', form.value.selectedUsers.length)
|
||||
: getNestedMessage('scopeDescriptions', 'selectUsers')
|
||||
? `已选择${form.value.selectedUsers.length}个用户`
|
||||
: '请选择用户'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
@@ -765,12 +645,12 @@ const scopeDescription = computed(() => {
|
||||
// 发送通知
|
||||
const sendNotification = async () => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = getNestedMessage('errors', 'adminOnly')
|
||||
error.value = '只有管理员可以发送系统通知'
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFormValid.value) {
|
||||
error.value = getNestedMessage('errors', 'incomplete')
|
||||
error.value = '请填写完整信息'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -783,7 +663,6 @@ const sendNotification = async () => {
|
||||
const notificationData = {
|
||||
title: form.value.title,
|
||||
content: form.value.content,
|
||||
important: form.value.important,
|
||||
scope: form.value.scope,
|
||||
filter: {}
|
||||
}
|
||||
@@ -804,8 +683,7 @@ const sendNotification = async () => {
|
||||
const result = await sendAdminNotification(notificationData)
|
||||
|
||||
if (result && result.success) {
|
||||
success.value = getNestedMessage('messages', 'sendSuccess', result.sentCount)
|
||||
historyRefreshKey.value += 1
|
||||
success.value = `成功发送通知给 ${result.sentCount} 名用户`
|
||||
|
||||
// 3秒后自动隐藏成功提示
|
||||
setTimeout(() => {
|
||||
@@ -816,7 +694,6 @@ const sendNotification = async () => {
|
||||
form.value = {
|
||||
title: '',
|
||||
content: '',
|
||||
important: false,
|
||||
scope: 'ALL',
|
||||
grade: '',
|
||||
classGrade: '',
|
||||
@@ -835,10 +712,10 @@ const sendNotification = async () => {
|
||||
userSearchLoading.value = false
|
||||
clearTimeout(userSearchTimeout)
|
||||
} else {
|
||||
throw new Error(result?.message || getNestedMessage('errors', 'sendFailed'))
|
||||
throw new Error(result?.message || '发送通知失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || getNestedMessage('errors', 'sendError')
|
||||
error.value = err.message || '发送通知时发生错误'
|
||||
console.error('发送通知错误:', err)
|
||||
|
||||
// 3秒后自动隐藏错误提示
|
||||
@@ -865,11 +742,11 @@ onUnmounted(() => {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--panel-bg-alt);
|
||||
background: var(--bg-tertiary, #27272a);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--panel-bg-hover);
|
||||
background: #3f3f46;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
|
||||
@@ -5,21 +5,19 @@
|
||||
<div
|
||||
v-for="(stat, i) in statCards"
|
||||
:key="i"
|
||||
class="bg-bg-secondary-40 border border-border-secondary rounded-2xl p-6 relative overflow-hidden group hover:border-border-tertiary transition-all shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
|
||||
class="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 relative overflow-hidden group hover:border-zinc-700 transition-all shadow-lg shadow-black/20"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div
|
||||
:class="[
|
||||
'p-3 rounded-xl border flex items-center justify-center',
|
||||
'p-3 rounded-xl border',
|
||||
stat.color === 'blue'
|
||||
? 'bg-primary-10 text-primary border-primary-20'
|
||||
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
|
||||
: stat.color === 'emerald'
|
||||
? 'bg-success-10 text-success border-success-20'
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: stat.color === 'pink'
|
||||
? 'bg-color-pink-alpha-10 text-color-pink border-color-pink-alpha-10'
|
||||
: stat.color === 'warning'
|
||||
? 'bg-warning-10 text-warning border-warning-20'
|
||||
: 'bg-bg-quaternary-10 text-text-tertiary border-border-tertiary-20'
|
||||
? 'bg-pink-500/10 text-pink-500 border-pink-500/20'
|
||||
: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20'
|
||||
]"
|
||||
>
|
||||
<component :is="stat.icon" :size="24" />
|
||||
@@ -28,7 +26,7 @@
|
||||
v-if="stat.trend"
|
||||
:class="[
|
||||
'flex items-center gap-1 text-[11px] font-bold',
|
||||
stat.trendDown ? 'text-error' : 'text-success'
|
||||
stat.trendDown ? 'text-red-400' : 'text-emerald-400'
|
||||
]"
|
||||
>
|
||||
<TrendingDown v-if="stat.trendDown" :size="12" />
|
||||
@@ -37,8 +35,8 @@
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-text-tertiary text-sm font-medium">{{ stat.label }}</p>
|
||||
<h4 class="text-3xl font-bold tracking-tight text-text-primary">{{ stat.value }}</h4>
|
||||
<p class="text-zinc-500 text-sm font-medium">{{ stat.label }}</p>
|
||||
<h4 class="text-3xl font-bold tracking-tight text-zinc-100">{{ stat.value }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -46,19 +44,18 @@
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<!-- 最近活动 -->
|
||||
<div
|
||||
class="lg:col-span-5 bg-bg-secondary-40 border border-border-secondary rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
|
||||
class="lg:col-span-5 bg-zinc-900/40 border border-zinc-800 rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
|
||||
>
|
||||
<div class="px-6 py-5 border-b border-border-secondary flex items-center justify-between">
|
||||
<div class="px-6 py-5 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||
<Activity :size="18" class="text-primary" /> {{ locale.recentActivities }}
|
||||
<Activity :size="18" class="text-blue-500" /> 最近活动
|
||||
</h3>
|
||||
<button
|
||||
class="p-2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
:disabled="loadingActivities"
|
||||
class="p-2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
:class="{ 'animate-spin': loadingActivities }"
|
||||
@click="refreshActivities"
|
||||
>
|
||||
<RefreshCw v-if="!loadingActivities" :size="16" />
|
||||
<Loader2 v-else :size="16" class="animate-spin" />
|
||||
<RefreshCw :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
@@ -66,23 +63,23 @@
|
||||
>
|
||||
<div
|
||||
v-if="loadingActivities && recentActivities.length === 0"
|
||||
class="flex flex-col items-center justify-center h-full text-text-tertiary gap-3 py-20"
|
||||
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
|
||||
>
|
||||
<Loader2 :size="24" class="animate-spin" />
|
||||
<span class="text-sm">{{ locale.loading }}</span>
|
||||
<RefreshCw :size="24" class="animate-spin" />
|
||||
<span class="text-sm">加载中...</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="recentActivities.length === 0"
|
||||
class="flex flex-col items-center justify-center h-full text-text-tertiary gap-3 py-20"
|
||||
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
|
||||
>
|
||||
<Inbox :size="24" />
|
||||
<span class="text-sm">{{ locale.noActivities }}</span>
|
||||
<span class="text-sm">暂无活动记录</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(activity, idx) in recentActivities"
|
||||
:key="idx"
|
||||
class="flex items-start gap-4 p-4 rounded-2xl hover:bg-bg-tertiary-40 transition-all cursor-pointer group"
|
||||
class="flex items-start gap-4 p-4 rounded-2xl hover:bg-zinc-800/40 transition-all cursor-pointer group"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
@@ -94,14 +91,14 @@
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h5
|
||||
class="font-bold text-sm text-text-primary group-hover:text-primary transition-colors"
|
||||
class="font-bold text-sm text-zinc-200 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ activity.title }}
|
||||
</h5>
|
||||
<p class="text-xs text-text-tertiary truncate mt-1">{{ activity.description }}</p>
|
||||
<p class="text-xs text-zinc-500 truncate mt-1">{{ activity.description }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-2">
|
||||
<Clock :size="10" class="text-text-disabled" />
|
||||
<span class="text-[10px] text-text-disabled font-medium uppercase tracking-wider">{{
|
||||
<Clock :size="10" class="text-zinc-600" />
|
||||
<span class="text-[10px] text-zinc-600 font-medium uppercase tracking-wider">{{
|
||||
formatTime(activity.createdAt)
|
||||
}}</span>
|
||||
</div>
|
||||
@@ -113,21 +110,21 @@
|
||||
|
||||
<!-- 系统状态 -->
|
||||
<div
|
||||
class="lg:col-span-4 bg-bg-secondary-40 border border-border-secondary rounded-xl overflow-hidden flex flex-col shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
|
||||
class="lg:col-span-4 bg-zinc-900/40 border border-zinc-800 rounded-xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
|
||||
>
|
||||
<div class="px-6 py-5 border-b border-border-secondary flex items-center justify-between">
|
||||
<div class="px-6 py-5 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||
<ShieldCheck :size="18" class="text-success" /> {{ locale.systemStatus }}
|
||||
<ShieldCheck :size="18" class="text-emerald-500" /> 系统状态
|
||||
</h3>
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 text-[10px] font-black uppercase rounded-full border',
|
||||
systemStatus.online
|
||||
? 'bg-success-10 text-success border-success-20'
|
||||
: 'bg-error-10 text-error border-error-20'
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
]"
|
||||
>
|
||||
{{ systemStatus.online ? locale.online : locale.offline }}
|
||||
{{ systemStatus.online ? '在线' : '离线' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
@@ -141,38 +138,24 @@
|
||||
:class="[
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
status.active
|
||||
? 'bg-success shadow-[0_0_10px_var(--success-70)]'
|
||||
: 'bg-bg-quaternary'
|
||||
? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.7)]'
|
||||
: 'bg-zinc-600'
|
||||
]"
|
||||
/>
|
||||
<span class="text-xs font-semibold text-text-tertiary">{{ status.label }}</span>
|
||||
<span class="text-xs font-semibold text-zinc-400">{{ status.label }}</span>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-text-primary">{{ status.value }}</span>
|
||||
<span class="text-xs font-bold text-zinc-200">{{ status.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-auto border-t border-border-secondary px-6 py-4 flex flex-col items-center justify-center gap-1 text-center">
|
||||
<span class="text-[10px] font-medium uppercase tracking-[0.3em] text-text-disabled">
|
||||
{{ locale.instanceId }}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
class="max-w-full text-xs text-text-tertiary hover:text-text-secondary transition-colors break-all leading-relaxed"
|
||||
:title="instanceId || locale.noInstanceId"
|
||||
:disabled="!instanceId"
|
||||
@click="copyInstanceId"
|
||||
>
|
||||
{{ instanceId || locale.noInstanceId }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<div
|
||||
class="lg:col-span-3 bg-bg-secondary-40 border border-border-secondary rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-[0_10px_15px_var(--shadow-color)]"
|
||||
class="lg:col-span-3 bg-zinc-900/40 border border-zinc-800 rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
|
||||
>
|
||||
<div class="px-6 py-5 border-b border-border-secondary">
|
||||
<div class="px-6 py-5 border-b border-zinc-800">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||
<Zap :size="18" class="text-warning" /> {{ locale.quickActions }}
|
||||
<Zap :size="18" class="text-yellow-500" /> 快速操作
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-3">
|
||||
@@ -182,8 +165,8 @@
|
||||
:class="[
|
||||
'w-full flex items-center gap-3 px-5 py-4 rounded-lg border font-bold text-sm transition-all text-left group',
|
||||
action.primary
|
||||
? 'bg-primary-hover border-primary text-text-primary shadow-xl shadow-[var(--primary-glow)] hover:bg-primary'
|
||||
: 'bg-bg-primary-40 border-border-secondary text-text-tertiary hover:border-border-tertiary hover:text-text-primary'
|
||||
? 'bg-blue-600 border-blue-500 text-white shadow-xl shadow-blue-900/20 hover:bg-blue-500'
|
||||
: 'bg-zinc-950/40 border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
|
||||
]"
|
||||
@click="navigateTo(action.id)"
|
||||
>
|
||||
@@ -198,36 +181,31 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import {
|
||||
Activity,
|
||||
Ban,
|
||||
Bell,
|
||||
Calendar,
|
||||
Clock,
|
||||
ExternalLink,
|
||||
Heart,
|
||||
Inbox,
|
||||
Loader2,
|
||||
Music,
|
||||
RefreshCw,
|
||||
ShieldCheck,
|
||||
Users,
|
||||
Calendar,
|
||||
Heart,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Users,
|
||||
Zap
|
||||
Activity,
|
||||
ShieldCheck,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Zap,
|
||||
Bell,
|
||||
Ban,
|
||||
ExternalLink,
|
||||
Inbox
|
||||
} from '@lucide/vue'
|
||||
import packageJson from '~~/package.json'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const emit = defineEmits(['navigate'])
|
||||
const { success: showSuccess, error: showError } = useToast()
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => useSafeLocale(admin.value?.overview || {}))
|
||||
const { format: formatLocaleValue } = useLocaleText(locale)
|
||||
|
||||
const systemVersion = ref(packageJson.version)
|
||||
const { siteTitle } = useSiteConfig()
|
||||
const displayName = computed(() => siteTitle.value || '站点')
|
||||
const stats = ref({
|
||||
totalSongs: 0,
|
||||
totalUsers: 0,
|
||||
@@ -243,7 +221,6 @@ const stats = ref({
|
||||
|
||||
const recentActivities = ref([])
|
||||
const loadingActivities = ref(false)
|
||||
const instanceId = ref('')
|
||||
|
||||
const systemStatus = ref({
|
||||
online: true,
|
||||
@@ -254,7 +231,7 @@ const systemStatus = ref({
|
||||
// 统计卡片数据
|
||||
const statCards = computed(() => [
|
||||
{
|
||||
label: locale.value.statCards?.totalSongs || '总歌曲数',
|
||||
label: '总歌曲数',
|
||||
value: formatNumber(stats.value.totalSongs),
|
||||
icon: Music,
|
||||
color: 'blue',
|
||||
@@ -262,19 +239,19 @@ const statCards = computed(() => [
|
||||
trendDown: stats.value.songsChange < 0
|
||||
},
|
||||
{
|
||||
label: locale.value.statCards?.registeredUsers || '注册用户',
|
||||
label: '注册用户',
|
||||
value: formatNumber(stats.value.totalUsers),
|
||||
icon: Users,
|
||||
color: 'emerald'
|
||||
},
|
||||
{
|
||||
label: locale.value.statCards?.todaySchedules || '今日排班',
|
||||
label: '今日排期',
|
||||
value: formatNumber(stats.value.todaySchedules),
|
||||
icon: Calendar,
|
||||
color: 'warning'
|
||||
color: 'zinc'
|
||||
},
|
||||
{
|
||||
label: locale.value.statCards?.weeklyRequests || '本周点歌',
|
||||
label: '本周点歌',
|
||||
value: formatNumber(stats.value.weeklyRequests),
|
||||
icon: Heart,
|
||||
color: 'pink',
|
||||
@@ -286,49 +263,48 @@ const statCards = computed(() => [
|
||||
// 系统状态项
|
||||
const statusItems = computed(() => [
|
||||
{
|
||||
label: locale.value.statusItems?.database || '数据库',
|
||||
value: systemStatus.value.database ? locale.value.normal || '正常' : locale.value.abnormal || '异常',
|
||||
label: '数据库连接',
|
||||
value: systemStatus.value.database ? '正常' : '异常',
|
||||
active: systemStatus.value.database
|
||||
},
|
||||
{
|
||||
label: locale.value.statusItems?.api || 'API',
|
||||
value: systemStatus.value.api ? locale.value.normal || '正常' : locale.value.abnormal || '异常',
|
||||
label: 'API服务',
|
||||
value: systemStatus.value.api ? '正常' : '异常',
|
||||
active: systemStatus.value.api
|
||||
},
|
||||
{
|
||||
label: locale.value.statusItems?.semester || '当前学期',
|
||||
value: stats.value.currentSemester || locale.value.unset || '未设置',
|
||||
label: '当前学期',
|
||||
value: stats.value.currentSemester || '未设置',
|
||||
active: !!stats.value.currentSemester
|
||||
},
|
||||
{
|
||||
label: locale.value.statusItems?.blacklist || '黑名单',
|
||||
value: formatLocaleValue(locale.value.itemUnit, String(stats.value.blacklistCount), stats.value.blacklistCount),
|
||||
label: '黑名单项目',
|
||||
value: `${stats.value.blacklistCount} 项`,
|
||||
active: stats.value.blacklistCount >= 0
|
||||
},
|
||||
{ label: locale.value.statusItems?.version || '版本', value: `v${systemVersion.value}`, active: true }
|
||||
{ label: '站点版本', value: `${displayName.value} v${systemVersion.value}`, active: true }
|
||||
])
|
||||
|
||||
// 快速操作
|
||||
const quickActions = computed(() => [
|
||||
{ label: locale.value.actions?.schedule || '排班管理', icon: Calendar, id: 'schedule', primary: true },
|
||||
{ label: locale.value.actions?.users || '用户管理', icon: Users, id: 'users' },
|
||||
{ label: locale.value.actions?.notifications || '通知管理', icon: Bell, id: 'notifications' },
|
||||
{ label: locale.value.actions?.blacklist || '黑名单', icon: Ban, id: 'blacklist' }
|
||||
])
|
||||
const quickActions = [
|
||||
{ label: '管理排期', icon: Calendar, id: 'schedule', primary: true },
|
||||
{ label: '用户管理', icon: Users, id: 'users' },
|
||||
{ label: '发送通知', icon: Bell, id: 'notifications' },
|
||||
{ label: '黑名单管理', icon: Ban, id: 'blacklist' }
|
||||
]
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000) return `${(num / 1000).toFixed(1)}K`
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const getActivityStyle = (type) => {
|
||||
const styles = {
|
||||
song: { icon: Music, bg: 'bg-info-10 text-info border-info-20' },
|
||||
user: { icon: Users, bg: 'bg-color-pink-alpha-10 text-color-pink border-color-pink-alpha-20' },
|
||||
schedule: { icon: Calendar, bg: 'bg-primary-10 text-primary border-primary-20' }
|
||||
song: { icon: Music, bg: 'bg-purple-500/10 text-purple-500 border-purple-500/20' },
|
||||
user: { icon: Users, bg: 'bg-pink-500/10 text-pink-500 border-pink-500/20' },
|
||||
schedule: { icon: Calendar, bg: 'bg-blue-500/10 text-blue-500 border-blue-500/20' }
|
||||
}
|
||||
|
||||
return styles[type] || { icon: Activity, bg: 'bg-bg-quaternary-10 text-text-tertiary border-border-tertiary-20' }
|
||||
return styles[type] || { icon: Activity, bg: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20' }
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
@@ -342,60 +318,6 @@ const loadStats = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const loadSystemStatus = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/system/status')
|
||||
systemStatus.value.online = response?.status === 'ok'
|
||||
systemStatus.value.database = !!response?.database?.connected
|
||||
systemStatus.value.api = response?.status === 'ok'
|
||||
instanceId.value = response?.instance?.instanceId || ''
|
||||
} catch (error) {
|
||||
console.error('加载系统状态失败:', error)
|
||||
systemStatus.value.online = false
|
||||
systemStatus.value.database = false
|
||||
systemStatus.value.api = false
|
||||
instanceId.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const copyToClipboard = async (text) => {
|
||||
if (navigator.clipboard?.writeText) {
|
||||
await navigator.clipboard.writeText(text)
|
||||
return true
|
||||
}
|
||||
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = text
|
||||
textarea.setAttribute('readonly', 'true')
|
||||
textarea.style.position = 'fixed'
|
||||
textarea.style.opacity = '0'
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
|
||||
const copied = document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
return copied
|
||||
}
|
||||
|
||||
const copyInstanceId = async () => {
|
||||
if (!instanceId.value) {
|
||||
showError(locale.value.noInstanceId || '暂无实例 ID')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const copied = await copyToClipboard(instanceId.value)
|
||||
if (copied) {
|
||||
showSuccess(locale.value.copied || '已复制')
|
||||
} else {
|
||||
showError(locale.value.copyFailed || '复制失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('复制实例 ID 失败:', error)
|
||||
showError(locale.value.copyFailed || '复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecentActivities = async () => {
|
||||
loadingActivities.value = true
|
||||
try {
|
||||
@@ -417,20 +339,13 @@ const refreshActivities = () => {
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const now = getSyncedDate()
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return locale.value?.justNow || '刚刚'
|
||||
if (diff < 3600000) {
|
||||
const minutes = Math.floor(diff / 60000)
|
||||
return formatLocaleValue(locale.value?.minutesAgo, `${minutes}分钟前`, minutes)
|
||||
}
|
||||
if (diff < 86400000) {
|
||||
const hours = Math.floor(diff / 3600000)
|
||||
return formatLocaleValue(locale.value?.hoursAgo, `${hours}小时前`, hours)
|
||||
}
|
||||
const days = Math.floor(diff / 86400000)
|
||||
return formatLocaleValue(locale.value?.daysAgo, `${days}天前`, days)
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
return `${Math.floor(diff / 86400000)}天前`
|
||||
}
|
||||
|
||||
const navigateTo = (tab) => {
|
||||
@@ -439,7 +354,6 @@ const navigateTo = (tab) => {
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadSystemStatus()
|
||||
loadRecentActivities()
|
||||
})
|
||||
</script>
|
||||
@@ -452,10 +366,10 @@ onMounted(() => {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--panel-bg-alt);
|
||||
background: var(--bg-tertiary, #27272a);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--panel-bg-hover);
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -3,49 +3,49 @@
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
|
||||
<p class="text-xs text-text-tertiary mt-1">
|
||||
{{ locale.desc }}
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">播出时段管理</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">
|
||||
定义校园广播的常规播放窗口,用户仅可在这些时间段内进行点歌或查看排期
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="bg-bg-secondary-40 border border-border-secondary rounded-xl px-4 py-2 flex items-center gap-3"
|
||||
class="bg-zinc-900/40 border border-zinc-800 rounded-xl px-4 py-2 flex items-center gap-3"
|
||||
>
|
||||
<span class="text-[10px] font-black text-text-tertiary uppercase tracking-widest"
|
||||
>{{ locale.enableSelection }}</span
|
||||
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||
>启用播出时段选择</span
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'relative w-10 h-5 rounded-full transition-colors',
|
||||
enablePlayTimeSelection ? 'bg-primary-hover' : 'bg-bg-tertiary'
|
||||
enablePlayTimeSelection ? 'bg-blue-600' : 'bg-zinc-800'
|
||||
]"
|
||||
@click="toggleGlobalEnabled"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'absolute top-1 w-3 h-3 bg-bg-secondary rounded-full transition-all',
|
||||
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
|
||||
enablePlayTimeSelection ? 'left-6' : 'left-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-lg shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-lg shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
@click="openAddForm"
|
||||
>
|
||||
<Plus :size="14" /> {{ locale.add }}
|
||||
<Plus :size="14" /> 添加播出时段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息卡片 -->
|
||||
<div class="bg-primary-hover-5 border border-primary-10 rounded-xl p-5 flex items-start gap-4">
|
||||
<Info class="text-primary shrink-0 mt-0.5" :size="18" />
|
||||
<div class="bg-blue-600/5 border border-blue-500/10 rounded-xl p-5 flex items-start gap-4">
|
||||
<Info class="text-blue-500 shrink-0 mt-0.5" :size="18" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-bold text-text-secondary">{{ locale.aboutTitle }}</p>
|
||||
<p class="text-[11px] text-text-tertiary leading-relaxed">
|
||||
{{ locale.aboutDesc }}
|
||||
<p class="text-xs font-bold text-zinc-300">关于时段限制</p>
|
||||
<p class="text-[11px] text-zinc-500 leading-relaxed">
|
||||
启用全局时段选择后,系统将仅在定义的时段内开放点歌功能。如果未设置开始或结束时间,系统将视为该边界不设限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -53,35 +53,37 @@
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-col items-center justify-center py-20 space-y-4 bg-bg-secondary-20 border border-border-secondary-50 rounded-xl"
|
||||
class="flex flex-col items-center justify-center py-20 space-y-4 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
|
||||
>
|
||||
<AppSpinner :size="40" />
|
||||
<p class="text-xs font-black text-text-tertiary uppercase tracking-widest">{{ locale.loading }}</p>
|
||||
<div
|
||||
class="w-10 h-10 border-4 border-blue-600/20 border-t-blue-600 rounded-full animate-spin"
|
||||
/>
|
||||
<p class="text-xs font-black text-zinc-500 uppercase tracking-widest">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="bg-error-5 border border-error-10 rounded-xl p-5 flex items-start gap-4"
|
||||
class="bg-red-500/5 border border-red-500/10 rounded-xl p-5 flex items-start gap-4"
|
||||
>
|
||||
<AlertCircle class="text-error shrink-0 mt-0.5" :size="18" />
|
||||
<AlertCircle class="text-red-500 shrink-0 mt-0.5" :size="18" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-bold text-text-secondary">{{ locale.fetchFailed }}</p>
|
||||
<p class="text-[11px] text-text-tertiary leading-relaxed">{{ error }}</p>
|
||||
<p class="text-xs font-bold text-zinc-300">获取数据失败</p>
|
||||
<p class="text-[11px] text-zinc-500 leading-relaxed">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="playTimes.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 space-y-6 bg-bg-secondary-20 border border-border-secondary-50 rounded-xl"
|
||||
class="flex flex-col items-center justify-center py-20 space-y-6 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
|
||||
>
|
||||
<div class="p-6 rounded-xl bg-bg-primary border border-border-secondary text-text-secondary">
|
||||
<div class="p-6 rounded-xl bg-zinc-950 border border-zinc-800 text-zinc-700">
|
||||
<Clock :size="48" />
|
||||
</div>
|
||||
<div class="text-center space-y-2">
|
||||
<h3 class="text-lg font-bold text-text-primary">{{ locale.emptyTitle }}</h3>
|
||||
<p class="text-xs text-text-tertiary">{{ locale.emptyDesc }}</p>
|
||||
<h3 class="text-lg font-bold text-zinc-100">暂无播出时段</h3>
|
||||
<p class="text-xs text-zinc-500">点击“添加播出时段”按钮创建第一个播出时段</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -92,15 +94,15 @@
|
||||
v-for="playTime in playTimes"
|
||||
:key="playTime.id"
|
||||
:class="[
|
||||
'group relative bg-bg-secondary-30 border rounded-xl p-8 transition-all hover:shadow-2xl hover:shadow-[0_25px_50px_var(--shadow-color-deep)]',
|
||||
playTime.enabled ? 'border-border-secondary-80' : 'border-border-secondary-40 opacity-60'
|
||||
'group relative bg-zinc-900/30 border rounded-xl p-8 transition-all hover:shadow-2xl hover:shadow-black/40',
|
||||
playTime.enabled ? 'border-zinc-800/80' : 'border-zinc-800/40 opacity-60'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div
|
||||
:class="[
|
||||
'p-3 rounded-xl bg-bg-primary border border-border-secondary transition-all flex items-center justify-center',
|
||||
playTime.enabled ? 'text-primary border-primary-20' : 'text-text-secondary'
|
||||
'p-3 rounded-xl bg-zinc-950 border border-zinc-800 transition-all',
|
||||
playTime.enabled ? 'text-blue-500 border-blue-500/20' : 'text-zinc-700'
|
||||
]"
|
||||
>
|
||||
<Clock :size="20" />
|
||||
@@ -110,13 +112,13 @@
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
|
||||
playTime.enabled
|
||||
? 'bg-success-10 text-success border-success-20'
|
||||
: 'bg-bg-tertiary-50 text-text-disabled border-border-tertiary-50'
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-zinc-800/50 text-zinc-600 border-zinc-700/50'
|
||||
]"
|
||||
>
|
||||
{{ playTime.enabled ? locale.enabled : locale.disabled }}
|
||||
{{ playTime.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
<button class="p-1.5 text-text-secondary hover:text-text-tertiary">
|
||||
<button class="p-1.5 text-zinc-700 hover:text-zinc-400">
|
||||
<MoreVertical :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
@@ -125,34 +127,34 @@
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4
|
||||
class="text-lg font-black text-text-primary group-hover:text-primary transition-colors"
|
||||
class="text-lg font-black text-zinc-100 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ playTime.name }}
|
||||
</h4>
|
||||
<div
|
||||
class="flex items-center gap-2 mt-1.5 text-primary-80 font-black tracking-tighter"
|
||||
class="flex items-center gap-2 mt-1.5 text-blue-500/80 font-black tracking-tighter"
|
||||
>
|
||||
<span class="text-xl">{{ playTime.startTime || locale.unlimited }}</span>
|
||||
<div class="w-4 h-[2px] bg-bg-tertiary" />
|
||||
<span class="text-xl">{{ playTime.endTime || locale.unlimited }}</span>
|
||||
<span class="text-xl">{{ playTime.startTime || '不限' }}</span>
|
||||
<div class="w-4 h-[2px] bg-zinc-800" />
|
||||
<span class="text-xl">{{ playTime.endTime || '不限' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-text-tertiary font-medium leading-relaxed min-h-[32px] line-clamp-2">
|
||||
{{ playTime.description || locale.noDescription }}
|
||||
<p class="text-xs text-zinc-500 font-medium leading-relaxed min-h-[32px] line-clamp-2">
|
||||
{{ playTime.description || '暂无详细描述...' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-border-secondary-50 flex items-center justify-between">
|
||||
<div class="mt-8 pt-6 border-t border-zinc-800/50 flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="p-2.5 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-primary hover:border-primary-30 transition-all"
|
||||
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-500 hover:text-blue-400 hover:border-blue-500/30 transition-all"
|
||||
@click="editPlayTime(playTime)"
|
||||
>
|
||||
<Edit2 :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2.5 bg-bg-primary border border-border-secondary rounded-lg text-text-tertiary hover:text-error hover:border-error-30 transition-all"
|
||||
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-500 hover:text-red-400 hover:border-red-500/30 transition-all"
|
||||
@click="confirmDelete(playTime)"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
@@ -162,13 +164,13 @@
|
||||
:class="[
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
playTime.enabled
|
||||
? 'bg-bg-tertiary text-text-tertiary hover:text-text-primary'
|
||||
: 'bg-success text-text-primary shadow-lg shadow-[var(--success-glow-20)]'
|
||||
? 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
|
||||
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
|
||||
]"
|
||||
@click="togglePlayTimeStatus(playTime)"
|
||||
>
|
||||
<Power :size="12" />
|
||||
{{ playTime.enabled ? locale.disable : locale.enable }}
|
||||
{{ playTime.enabled ? '禁用' : '启用' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -181,17 +183,17 @@
|
||||
v-if="showAddForm || editingPlayTime"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm" @click="cancelForm" />
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="cancelForm" />
|
||||
<div
|
||||
class="relative w-full max-w-lg bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
|
||||
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div class="p-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight">
|
||||
{{ editingPlayTime ? locale.editTitle : locale.addTitle }}
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">
|
||||
{{ editingPlayTime ? '编辑播出时段' : '添加播出时段' }}
|
||||
</h3>
|
||||
<button
|
||||
class="p-2 text-text-tertiary hover:text-text-primary transition-colors"
|
||||
class="p-2 text-zinc-500 hover:text-zinc-200 transition-colors"
|
||||
@click="cancelForm"
|
||||
>
|
||||
<X :size="20" />
|
||||
@@ -200,64 +202,64 @@
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.name }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>时段名称</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
:placeholder="locale.namePlaceholder"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30"
|
||||
placeholder="例如: 午间广播"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.startTimeOptional }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>开始时间 (可选)</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="formData.startTime"
|
||||
type="time"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30 appearance-none"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 appearance-none"
|
||||
>
|
||||
<Clock
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
|
||||
:size="14"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-[9px] text-text-disabled px-1">{{ locale.startTimeHint }}</p>
|
||||
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制开始时间</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.endTimeOptional }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>结束时间 (可选)</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="formData.endTime"
|
||||
type="time"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-3.5 text-sm text-text-primary focus:outline-none focus:border-primary-30 appearance-none"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 appearance-none"
|
||||
>
|
||||
<Clock
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-secondary pointer-events-none"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
|
||||
:size="14"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-[9px] text-text-disabled px-1">{{ locale.endTimeHint }}</p>
|
||||
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制结束时间</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1 flex items-center gap-2"
|
||||
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 flex items-center gap-2"
|
||||
>
|
||||
<AlignLeft :size="10" /> {{ locale.descriptionOptional }}
|
||||
<AlignLeft :size="10" /> 描述 (可选)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="formData.description"
|
||||
:placeholder="locale.descriptionPlaceholder"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-lg px-5 py-4 text-sm text-text-primary focus:outline-none focus:border-primary-30 min-h-[100px] resize-none"
|
||||
placeholder="请输入时段描述信息..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 min-h-[100px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -265,36 +267,36 @@
|
||||
<input
|
||||
v-model="formData.enabled"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-border-secondary bg-bg-primary"
|
||||
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-blue-600"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold text-text-secondary group-hover:text-primary transition-colors"
|
||||
>{{ locale.enableThisPlayTime }}</span
|
||||
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
|
||||
>启用此播出时段</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="bg-error-10 border border-error-20 rounded-lg p-3 text-[11px] text-error"
|
||||
class="bg-red-500/10 border border-red-500/20 rounded-lg p-3 text-[11px] text-red-400"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-6 bg-bg-primary-50 border-t border-border-secondary flex justify-end gap-3">
|
||||
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex justify-end gap-3">
|
||||
<button
|
||||
class="px-6 py-2.5 text-xs font-bold text-text-tertiary hover:text-text-secondary"
|
||||
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
|
||||
@click="cancelForm"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="formSubmitting"
|
||||
class="px-8 py-2.5 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs font-black rounded-lg shadow-lg transition-all active:scale-95"
|
||||
class="px-8 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-lg transition-all active:scale-95"
|
||||
@click="savePlayTime"
|
||||
>
|
||||
{{ formSubmitting ? locale.saving : locale.saveSettings }}
|
||||
{{ formSubmitting ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -305,40 +307,40 @@
|
||||
<Transition name="modal">
|
||||
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-bg-primary-80 backdrop-blur-sm"
|
||||
class="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
@click="showDeleteConfirm = false"
|
||||
/>
|
||||
<div
|
||||
class="relative w-full max-w-sm bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
|
||||
class="relative w-full max-w-sm bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div class="p-8">
|
||||
<div class="flex flex-col items-center py-4 space-y-6">
|
||||
<div
|
||||
class="w-16 h-16 rounded-xl bg-error-10 text-error flex items-center justify-center border border-error-10 shadow-xl shadow-[var(--error-glow-5)]"
|
||||
class="w-16 h-16 rounded-xl bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/10 shadow-xl shadow-red-900/5"
|
||||
>
|
||||
<AlertCircle :size="32" />
|
||||
</div>
|
||||
<div class="text-center space-y-2 px-4">
|
||||
<h4 class="text-lg font-bold text-text-primary">
|
||||
{{ deleteConfirmTitleText }}
|
||||
<h4 class="text-lg font-bold text-zinc-100">
|
||||
确定要删除播出时段 "{{ playTimeToDelete?.name }}" 吗?
|
||||
</h4>
|
||||
<p class="text-xs text-text-tertiary leading-relaxed">
|
||||
{{ locale.deleteConfirmDesc }}
|
||||
<p class="text-xs text-zinc-500 leading-relaxed">
|
||||
此操作不可恢复,相关的歌曲点播和排期的时段设置将受影响或被清除。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full pt-4">
|
||||
<button
|
||||
class="flex-1 px-4 py-3 bg-bg-primary border border-border-secondary text-text-tertiary text-xs font-black rounded-lg transition-all hover:bg-bg-tertiary"
|
||||
class="flex-1 px-4 py-3 bg-zinc-950 border border-zinc-800 text-zinc-500 text-xs font-black rounded-lg transition-all hover:bg-zinc-800"
|
||||
@click="showDeleteConfirm = false"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="deleteInProgress"
|
||||
class="flex-1 px-4 py-3 bg-error hover:bg-error disabled:opacity-50 disabled:cursor-not-allowed text-text-primary text-xs font-black rounded-lg shadow-xl shadow-[var(--error-glow-20)] transition-all active:scale-95"
|
||||
class="flex-1 px-4 py-3 bg-red-600 hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-xl shadow-red-900/20 transition-all active:scale-95"
|
||||
@click="deletePlayTime"
|
||||
>
|
||||
{{ deleteInProgress ? locale.deleting : locale.confirmDelete }}
|
||||
{{ deleteInProgress ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -350,12 +352,10 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import type { PlayTime } from '~/types'
|
||||
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
|
||||
import type { PlayTime } from '~~/types'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
@@ -371,17 +371,6 @@ import {
|
||||
|
||||
const { getAuthConfig, isAdmin } = useAuth()
|
||||
const { showToast: showNotification } = useToast()
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => {
|
||||
const base = admin.value?.playTimeManager || {}
|
||||
const emptyText = () => ''
|
||||
return useSafeLocale({
|
||||
...base,
|
||||
errors: { ...(base.errors || {}) },
|
||||
messages: { ...(base.messages || {}) },
|
||||
deleteConfirmTitle: base.deleteConfirmTitle || emptyText
|
||||
})
|
||||
})
|
||||
|
||||
const playTimes = ref<PlayTime[]>([])
|
||||
const loading = ref(false)
|
||||
@@ -394,12 +383,6 @@ const formSubmitting = ref(false)
|
||||
const deleteInProgress = ref(false)
|
||||
const formError = ref('')
|
||||
const enablePlayTimeSelection = ref(false)
|
||||
const deleteConfirmTitleText = computed(() => {
|
||||
const title = locale.value.deleteConfirmTitle
|
||||
const name = playTimeToDelete.value?.name || ''
|
||||
if (typeof title === 'function') return title(name)
|
||||
return (title || '').replace('{0}', name)
|
||||
})
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
@@ -420,7 +403,7 @@ onMounted(async () => {
|
||||
// 获取播出时段列表
|
||||
const fetchPlayTimes = async () => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = locale.value.errors.adminOnly
|
||||
error.value = '只有管理员才能管理播出时段'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -428,10 +411,11 @@ const fetchPlayTimes = async () => {
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const data = await $fetch('/api/admin/play-times', {
|
||||
...authConfig
|
||||
})
|
||||
const authConfig = getAuthConfig()
|
||||
const data = await $fetch('/api/admin/play-times', {
|
||||
...authConfig,
|
||||
query: { t: Date.now() }
|
||||
})
|
||||
|
||||
// 自定义排序:先按启用状态排序,然后有时间的排在前面,没有时间的排在后面
|
||||
playTimes.value = data.sort((a: PlayTime, b: PlayTime) => {
|
||||
@@ -463,7 +447,7 @@ const fetchPlayTimes = async () => {
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err) || locale.value.errors.fetchPlayTimesFailed
|
||||
error.value = err.message || '获取播出时段失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -480,7 +464,7 @@ const fetchSystemSettings = async () => {
|
||||
})
|
||||
enablePlayTimeSelection.value = data.enablePlayTimeSelection
|
||||
} catch (err: any) {
|
||||
console.error('获取系统设置失败:', getErrorMessage(err))
|
||||
console.error('获取系统设置失败:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -504,9 +488,9 @@ const updateSystemSettings = async () => {
|
||||
...authConfig
|
||||
})
|
||||
|
||||
showNotification(locale.value.messages.systemSettingsUpdated, 'success')
|
||||
showNotification('系统设置已更新', 'success')
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err) || locale.value.errors.updateSystemSettingsFailed
|
||||
error.value = err.message || '更新系统设置失败'
|
||||
showNotification(error.value, 'error')
|
||||
// 如果失败,恢复状态
|
||||
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
|
||||
@@ -555,14 +539,9 @@ const togglePlayTimeStatus = async (playTime: PlayTime) => {
|
||||
|
||||
// 更新本地数据
|
||||
await fetchPlayTimes()
|
||||
showNotification(
|
||||
playTime.enabled
|
||||
? (locale.value?.messages?.playTimeDisabled || 'Play time disabled')
|
||||
: (locale.value?.messages?.playTimeEnabled || 'Play time enabled'),
|
||||
'success'
|
||||
)
|
||||
showNotification(playTime.enabled ? '播出时段已禁用' : '播出时段已启用', 'success')
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err) || locale.value.errors.updatePlayTimeStatusFailed
|
||||
error.value = err.message || '更新播出时段状态失败'
|
||||
showNotification(error.value, 'error')
|
||||
}
|
||||
}
|
||||
@@ -590,9 +569,9 @@ const deletePlayTime = async () => {
|
||||
await fetchPlayTimes()
|
||||
showDeleteConfirm.value = false
|
||||
playTimeToDelete.value = null
|
||||
showNotification(locale.value?.messages?.playTimeDeleted || 'Play time deleted', 'success')
|
||||
showNotification('播出时段已删除', 'success')
|
||||
} catch (err: any) {
|
||||
error.value = getErrorMessage(err) || locale.value.errors.deletePlayTimeFailed
|
||||
error.value = err.message || '删除播出时段失败'
|
||||
showNotification(error.value, 'error')
|
||||
} finally {
|
||||
deleteInProgress.value = false
|
||||
@@ -605,13 +584,13 @@ const savePlayTime = async () => {
|
||||
|
||||
// 时间验证(仅当两个时间都有填写时才进行比较)
|
||||
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
|
||||
formError.value = locale.value.errors.startBeforeEnd
|
||||
formError.value = '开始时间必须早于结束时间'
|
||||
return
|
||||
}
|
||||
|
||||
// 至少要有名称
|
||||
if (!formData.name.trim()) {
|
||||
formError.value = locale.value.errors.nameRequired
|
||||
formError.value = '时段名称不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -624,7 +603,7 @@ const savePlayTime = async () => {
|
||||
)
|
||||
|
||||
if (nameExists) {
|
||||
formError.value = locale.value.errors.nameExists
|
||||
formError.value = '播出时段名称已存在,请使用其他名称'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -648,14 +627,9 @@ const savePlayTime = async () => {
|
||||
// 更新本地数据
|
||||
await fetchPlayTimes()
|
||||
cancelForm()
|
||||
showNotification(
|
||||
isUpdate
|
||||
? (locale.value?.messages?.playTimeUpdated || 'Play time updated')
|
||||
: (locale.value?.messages?.playTimeCreated || 'Play time created'),
|
||||
'success'
|
||||
)
|
||||
showNotification(isUpdate ? '播出时段已更新' : '播出时段已创建', 'success')
|
||||
} catch (err: any) {
|
||||
formError.value = getErrorMessage(err) || locale.value.errors.savePlayTimeFailed
|
||||
formError.value = err.message || '保存播出时段失败'
|
||||
showNotification(formError.value, 'error')
|
||||
} finally {
|
||||
formSubmitting.value = false
|
||||
|
||||
@@ -0,0 +1,246 @@
|
||||
<template>
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<Transition name="scale">
|
||||
<div
|
||||
v-if="show"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden flex flex-col"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
|
||||
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">生成歌单卡片</h3>
|
||||
<button
|
||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 flex flex-col items-center gap-4">
|
||||
<!-- 标题输入 -->
|
||||
<div class="w-full space-y-2">
|
||||
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">歌单标题</label>
|
||||
<input
|
||||
v-model="cardTitle"
|
||||
type="text"
|
||||
placeholder="例如: 2026-03-21 校园午间广播"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50 transition-colors"
|
||||
@input="drawCanvas"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- Canvas 预览 -->
|
||||
<div class="relative w-full aspect-[2/3] max-w-[280px] bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800 shadow-inner flex items-center justify-center">
|
||||
<canvas ref="canvasRef" width="400" :height="canvasHeight" class="w-full h-full object-contain bg-white"></canvas>
|
||||
</div>
|
||||
|
||||
<p class="text-[10px] text-zinc-500 text-center">宽度 400 像素,高度自适应,适合在手机端查看和分享</p>
|
||||
</div>
|
||||
|
||||
<div class="p-4 border-t border-zinc-800 bg-zinc-900/50 flex gap-3">
|
||||
<button
|
||||
class="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider"
|
||||
@click="downloadCard"
|
||||
>
|
||||
下载图片
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, watch, nextTick, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
songs: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
defaultTitle: {
|
||||
type: String,
|
||||
default: '校园广播歌单'
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const canvasRef = ref(null)
|
||||
const cardTitle = ref(props.defaultTitle)
|
||||
|
||||
const aiPrompt = ref('anime style school campus sunset beautiful sky highly detailed')
|
||||
const isGeneratingAi = ref(false)
|
||||
const bgImageObj = ref(null)
|
||||
|
||||
const maxDisplaySongs = 35
|
||||
|
||||
const canvasHeight = computed(() => {
|
||||
const count = Math.min(props.songs.length, maxDisplaySongs)
|
||||
if (count <= 10) return 600
|
||||
// 每多一首增加 40px 高度
|
||||
return 600 + (count - 10) * 40
|
||||
})
|
||||
|
||||
|
||||
|
||||
const drawCanvas = () => {
|
||||
if (!canvasRef.value) return
|
||||
const canvas = canvasRef.value
|
||||
const ctx = canvas.getContext('2d')
|
||||
if (!ctx) return
|
||||
|
||||
const height = canvasHeight.value
|
||||
|
||||
// 清空画布
|
||||
if (bgImageObj.value) {
|
||||
ctx.drawImage(bgImageObj.value, 0, 0, 400, height)
|
||||
// 增加一层半透明黑色遮罩,确保文字和列表框可见
|
||||
ctx.fillStyle = 'rgba(0, 0, 0, 0.15)'
|
||||
ctx.fillRect(0, 0, 400, height)
|
||||
} else {
|
||||
ctx.fillStyle = '#F8FAFC' // Background color
|
||||
ctx.fillRect(0, 0, 400, height)
|
||||
}
|
||||
|
||||
// 绘制渐变头部
|
||||
const gradient = ctx.createLinearGradient(0, 0, 400, 0)
|
||||
if (bgImageObj.value) {
|
||||
gradient.addColorStop(0, 'rgba(0, 0, 0, 0.7)')
|
||||
gradient.addColorStop(1, 'rgba(0, 0, 0, 0.4)')
|
||||
} else {
|
||||
gradient.addColorStop(0, '#2563eb') // Primary Blue
|
||||
gradient.addColorStop(1, '#3b82f6')
|
||||
}
|
||||
ctx.fillStyle = gradient
|
||||
ctx.fillRect(0, 0, 400, 100)
|
||||
|
||||
// 绘制标题
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
ctx.font = 'bold 24px "Inter", "system-ui", sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(cardTitle.value || '校园广播歌单', 200, 50)
|
||||
|
||||
// 绘制副标题(日期)
|
||||
ctx.font = '14px "Inter", "system-ui", sans-serif'
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
|
||||
ctx.fillText(new Date().toLocaleDateString('zh-CN'), 200, 80)
|
||||
|
||||
// 绘制列表背景
|
||||
if (bgImageObj.value) {
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.85)' // 半透明白色背景
|
||||
} else {
|
||||
ctx.fillStyle = '#FFFFFF'
|
||||
}
|
||||
ctx.shadowColor = 'rgba(0,0,0,0.05)'
|
||||
ctx.shadowBlur = 10
|
||||
ctx.shadowOffsetY = 4
|
||||
ctx.beginPath()
|
||||
ctx.roundRect(20, 120, 360, height - 180, 12)
|
||||
ctx.fill()
|
||||
ctx.shadowColor = 'transparent'
|
||||
|
||||
// 绘制歌曲列表
|
||||
ctx.textAlign = 'left'
|
||||
let y = 160
|
||||
|
||||
const displaySongs = props.songs.slice(0, maxDisplaySongs) // 最多显示 35 首
|
||||
displaySongs.forEach((schedule, index) => {
|
||||
const song = schedule.song || schedule
|
||||
|
||||
// 序号
|
||||
ctx.fillStyle = '#2563eb'
|
||||
ctx.font = 'bold 16px "Inter", "system-ui", sans-serif'
|
||||
ctx.fillText(`${index + 1}.`, 40, y)
|
||||
|
||||
// 歌名
|
||||
ctx.fillStyle = '#0F172A' // Neutral Dark
|
||||
ctx.font = 'bold 16px "Inter", "system-ui", sans-serif'
|
||||
// 限制歌名长度
|
||||
let title = song.title || '未知歌曲'
|
||||
if (title.length > 12) title = title.substring(0, 12) + '...'
|
||||
ctx.fillText(title, 70, y)
|
||||
|
||||
// 歌手
|
||||
ctx.fillStyle = '#64748B'
|
||||
ctx.font = '14px "Inter", "system-ui", sans-serif'
|
||||
let artist = song.artist || '未知歌手'
|
||||
if (artist.length > 10) artist = artist.substring(0, 10) + '...'
|
||||
ctx.fillText(`- ${artist}`, 240, y)
|
||||
|
||||
y += 40
|
||||
})
|
||||
|
||||
if (props.songs.length > maxDisplaySongs) {
|
||||
ctx.fillStyle = '#94A3B8'
|
||||
ctx.font = '14px "Inter", "system-ui", sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(`...及其他 ${props.songs.length - maxDisplaySongs} 首歌曲`, 200, y + 10)
|
||||
}
|
||||
|
||||
// 绘制底部水印
|
||||
if (bgImageObj.value) {
|
||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'
|
||||
} else {
|
||||
ctx.fillStyle = '#CBD5E1'
|
||||
}
|
||||
ctx.font = '12px "Inter", "system-ui", sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText('Generated by VoiceHub', 200, height - 20)
|
||||
}
|
||||
|
||||
const downloadCard = () => {
|
||||
if (!canvasRef.value) return
|
||||
const dataUrl = canvasRef.value.toDataURL('image/png')
|
||||
const link = document.createElement('a')
|
||||
link.download = `歌单卡片_${new Date().toISOString().split('T')[0]}.png`
|
||||
link.href = dataUrl
|
||||
link.click()
|
||||
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification('图片导出成功', 'success')
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
cardTitle.value = props.defaultTitle
|
||||
nextTick(() => {
|
||||
drawCanvas()
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: transform 0.2s ease, opacity 0.2s ease;
|
||||
}
|
||||
.scale-enter-from,
|
||||
.scale-leave-to {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,114 @@
|
||||
<template>
|
||||
<div class="flex flex-col space-y-8 pb-12 lg:pb-20 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div class="flex flex-col space-y-2">
|
||||
<h2 class="text-2xl font-bold tracking-tight text-zinc-100">图片歌单</h2>
|
||||
<p class="text-sm text-zinc-500">选择日期生成广播排期卡片并分享</p>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-8">
|
||||
<!-- 设置面板 -->
|
||||
<div class="w-full flex flex-col gap-6">
|
||||
<div class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 space-y-6 shadow-lg">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-100 shrink-0">
|
||||
<Layout class="w-4 h-4 text-blue-500" /> 卡片设置
|
||||
</h3>
|
||||
|
||||
<div class="flex flex-col gap-5 items-end">
|
||||
<!-- 日期选择 -->
|
||||
<div class="space-y-2 flex-1 w-full">
|
||||
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">排期日期</label>
|
||||
|
||||
<!-- 类似排期管理的日期选择器 -->
|
||||
<AdminDatePicker
|
||||
:modelValue="selectedDate"
|
||||
@update:modelValue="selectedDate = $event"
|
||||
@change="fetchSchedules"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="loading || schedules.length === 0"
|
||||
class="w-full py-3 h-[54px] bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shrink-0"
|
||||
@click="showModal = true"
|
||||
>
|
||||
<ImageIcon class="w-4 h-4" />
|
||||
{{ loading ? '加载中...' : (schedules.length === 0 ? '该日期无排期' : '生成卡片') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览区 -->
|
||||
<div class="w-full flex flex-col gap-4">
|
||||
<div v-if="schedules.length > 0" class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 shadow-lg">
|
||||
<h3 class="text-lg font-bold text-zinc-100 mb-4">排期预览 ({{ schedules.length }} 首)</h3>
|
||||
<div class="space-y-3">
|
||||
<div v-for="(schedule, index) in schedules" :key="schedule.id" class="flex items-center gap-3 p-3 bg-zinc-950/50 border border-zinc-800/50 rounded-lg">
|
||||
<span class="text-blue-500 font-bold w-6">{{ index + 1 }}.</span>
|
||||
<div class="flex-1 overflow-hidden">
|
||||
<p class="text-sm text-zinc-200 font-medium truncate">{{ schedule.song?.title || schedule.title }}</p>
|
||||
<p class="text-xs text-zinc-500 truncate">{{ schedule.song?.artist || schedule.artist }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-20 bg-zinc-900/20 border border-zinc-800/50 rounded-xl border-dashed">
|
||||
<CalendarDays class="w-12 h-12 text-zinc-700 mb-4" />
|
||||
<p class="text-zinc-500">该日期没有排期的歌曲</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminPlaylistCardGenerator
|
||||
:show="showModal"
|
||||
:songs="schedules"
|
||||
:default-title="`${selectedDate} 校园广播歌单`"
|
||||
@close="showModal = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { Layout, Image as ImageIcon, CalendarDays } from '@lucide/vue'
|
||||
import AdminDatePicker from './AdminDatePicker.vue'
|
||||
import AdminPlaylistCardGenerator from './PlaylistCardGenerator.vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
|
||||
const auth = useAuth()
|
||||
|
||||
const selectedDate = ref(new Date().toISOString().split('T')[0])
|
||||
const schedules = ref([])
|
||||
const loading = ref(false)
|
||||
const showModal = ref(false)
|
||||
|
||||
const fetchSchedules = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/admin/schedule/full', {
|
||||
...auth.getAuthConfig(),
|
||||
query: {
|
||||
date: selectedDate.value,
|
||||
includeDrafts: 'true'
|
||||
}
|
||||
})
|
||||
if (response.success && response.data && response.data.schedules) {
|
||||
schedules.value = response.data.schedules
|
||||
} else {
|
||||
schedules.value = []
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch schedules:', error)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification('获取排期失败', 'error')
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchSchedules()
|
||||
})
|
||||
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<div class="roles-manager">
|
||||
<div class="admin-section-header">
|
||||
<div>
|
||||
<h2 class="text-xl font-black text-zinc-100 flex items-center gap-2">
|
||||
<Shield class="w-6 h-6 text-indigo-400" />
|
||||
权限组管理
|
||||
</h2>
|
||||
<p class="text-sm text-zinc-500 mt-1">管理各角色可以访问的后台页面功能</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="px-4 py-2 bg-indigo-500/10 hover:bg-indigo-500/20 text-indigo-400 rounded-xl font-bold transition-all disabled:opacity-50 flex items-center gap-2"
|
||||
:disabled="loading || saving"
|
||||
@click="saveRoles"
|
||||
>
|
||||
<Save v-if="!saving" class="w-4 h-4" />
|
||||
<Loader2 v-else class="w-4 h-4 animate-spin" />
|
||||
{{ saving ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="py-12 flex flex-col items-center justify-center text-zinc-500">
|
||||
<Loader2 class="w-8 h-8 animate-spin mb-4" />
|
||||
<p>正在加载权限配置...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="py-12 text-center text-red-400">
|
||||
{{ error }}
|
||||
<button class="mt-4 px-4 py-2 bg-zinc-800 rounded-lg hover:bg-zinc-700" @click="fetchRoles">
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6 mt-6">
|
||||
<div
|
||||
v-for="(roleConfig, roleKey) in roles"
|
||||
:key="roleKey"
|
||||
class="role-card p-6 bg-zinc-900/50 border border-zinc-800 rounded-2xl"
|
||||
>
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div class="w-10 h-10 rounded-xl bg-zinc-800 flex items-center justify-center text-zinc-400">
|
||||
<Shield class="w-5 h-5" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-zinc-200">{{ roleConfig.displayName }}</h3>
|
||||
<p class="text-xs text-zinc-500 font-mono mt-0.5">{{ roleKey }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 cursor-pointer">
|
||||
<input
|
||||
v-model="roleConfig.canAccessAdmin"
|
||||
type="checkbox"
|
||||
class="hidden"
|
||||
:disabled="roleKey === 'SUPER_ADMIN'"
|
||||
/>
|
||||
<div class="w-10 h-6 bg-zinc-800 rounded-full relative transition-colors" :class="{ 'bg-indigo-500': roleConfig.canAccessAdmin }">
|
||||
<div class="absolute w-4 h-4 bg-white rounded-full top-1 transition-transform" :class="roleConfig.canAccessAdmin ? 'left-5' : 'left-1'" />
|
||||
</div>
|
||||
<span class="text-xs font-bold text-zinc-400">允许进后台</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="roleConfig.canAccessAdmin" class="mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<h4 class="text-xs font-bold text-zinc-500 uppercase tracking-wider">可访问页面</h4>
|
||||
<span class="text-[10px] font-black px-2 py-0.5 rounded-md bg-zinc-800/60 text-zinc-400 border border-zinc-800">
|
||||
已启用 {{ roleConfig.pages.length }} 项
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 text-[11px] font-black rounded-lg bg-zinc-800/60 border border-zinc-800 text-zinc-300 hover:bg-zinc-800 transition-colors"
|
||||
:disabled="roleKey === 'SUPER_ADMIN'"
|
||||
@click="selectAll(roleKey)"
|
||||
>
|
||||
全选
|
||||
</button>
|
||||
<button
|
||||
class="px-3 py-1.5 text-[11px] font-black rounded-lg bg-zinc-800/60 border border-zinc-800 text-zinc-300 hover:bg-zinc-800 transition-colors"
|
||||
:disabled="roleKey === 'SUPER_ADMIN'"
|
||||
@click="clearAll(roleKey)"
|
||||
>
|
||||
清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-5">
|
||||
<div v-for="section in pageSections" :key="section.title" class="space-y-2">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
|
||||
{{ section.title }}
|
||||
</div>
|
||||
<button
|
||||
class="text-[10px] font-black text-indigo-400 hover:text-indigo-300 transition-colors"
|
||||
:disabled="roleKey === 'SUPER_ADMIN'"
|
||||
@click="toggleSection(roleKey, section)"
|
||||
>
|
||||
{{ isSectionAllSelected(roleKey, section) ? '取消全选' : '本组全选' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<label
|
||||
v-for="page in section.pages"
|
||||
:key="page.id"
|
||||
class="flex items-center gap-2 p-2 rounded-xl border cursor-pointer transition-all"
|
||||
:class="roleConfig.pages.includes(page.id) ? 'bg-indigo-500/10 border-indigo-500/30 text-indigo-200 shadow-[0_0_0_1px_rgba(99,102,241,0.15)]' : 'bg-zinc-800/30 border-zinc-800 text-zinc-500 hover:bg-zinc-800/70'"
|
||||
>
|
||||
<input
|
||||
v-model="roleConfig.pages"
|
||||
type="checkbox"
|
||||
:value="page.id"
|
||||
class="hidden"
|
||||
:disabled="roleKey === 'SUPER_ADMIN' && isRequiredForSuperAdmin(page.id)"
|
||||
/>
|
||||
<component :is="page.icon" class="w-4 h-4" />
|
||||
<span class="text-xs font-bold">{{ page.label }}</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="mt-6 py-8 text-center border border-dashed border-zinc-800 rounded-xl">
|
||||
<p class="text-sm text-zinc-600">该角色不允许进入后台</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import {
|
||||
Shield,
|
||||
Save,
|
||||
Loader2,
|
||||
Calendar,
|
||||
Music,
|
||||
Users,
|
||||
Bell,
|
||||
Clock,
|
||||
BookOpen,
|
||||
Database,
|
||||
Settings,
|
||||
Ban,
|
||||
Key,
|
||||
BarChart,
|
||||
FileEdit,
|
||||
Mail
|
||||
} from '@lucide/vue'
|
||||
|
||||
const roles = ref({})
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const pageSections = [
|
||||
{
|
||||
title: '数据中心',
|
||||
pages: [
|
||||
{ id: 'overview', label: '数据概览', icon: BarChart },
|
||||
{ id: 'workflow-tutorial', label: '流程与教程', icon: BookOpen },
|
||||
{ id: 'data-analysis', label: '数据分析', icon: BarChart }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '播音管理',
|
||||
pages: [
|
||||
{ id: 'songs', label: '歌曲管理', icon: Music },
|
||||
{ id: 'schedule', label: '排期管理', icon: Calendar },
|
||||
{ id: 'print', label: '打印排期', icon: BookOpen }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '业务设置',
|
||||
pages: [
|
||||
{ id: 'settings', label: '选曲日期', icon: Calendar },
|
||||
{ id: 'request-times', label: '投稿管理', icon: FileEdit },
|
||||
{ id: 'semesters', label: '学期管理', icon: BookOpen },
|
||||
{ id: 'playtimes', label: '播出时段', icon: Clock }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '用户与权限',
|
||||
pages: [
|
||||
{ id: 'users', label: '用户管理', icon: Users },
|
||||
{ id: 'roles', label: '权限组管理', icon: Shield },
|
||||
{ id: 'blacklist', label: '黑名单管理', icon: Ban },
|
||||
{ id: 'api-keys', label: 'API密钥管理', icon: Key }
|
||||
]
|
||||
},
|
||||
{
|
||||
title: '系统运维',
|
||||
pages: [
|
||||
{ id: 'site-config', label: '站点配置', icon: Settings },
|
||||
{ id: 'notifications', label: '通知管理', icon: Bell },
|
||||
{ id: 'smtp-config', label: '邮件配置', icon: Mail },
|
||||
{ id: 'database', label: '数据库操作', icon: Database }
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
const allPages = pageSections.flatMap((s) => s.pages)
|
||||
|
||||
const isRequiredForSuperAdmin = (pageId) => {
|
||||
return ['roles', 'users', 'site-config'].includes(pageId)
|
||||
}
|
||||
|
||||
const selectAll = (roleKey) => {
|
||||
const role = roles.value?.[roleKey]
|
||||
if (!role || roleKey === 'SUPER_ADMIN') return
|
||||
role.pages = Array.from(new Set(allPages.map((p) => p.id)))
|
||||
}
|
||||
|
||||
const clearAll = (roleKey) => {
|
||||
const role = roles.value?.[roleKey]
|
||||
if (!role || roleKey === 'SUPER_ADMIN') return
|
||||
role.pages = []
|
||||
}
|
||||
|
||||
const isSectionAllSelected = (roleKey, section) => {
|
||||
const role = roles.value?.[roleKey]
|
||||
if (!role) return false
|
||||
return section.pages.every((p) => role.pages.includes(p.id))
|
||||
}
|
||||
|
||||
const toggleSection = (roleKey, section) => {
|
||||
const role = roles.value?.[roleKey]
|
||||
if (!role || roleKey === 'SUPER_ADMIN') return
|
||||
|
||||
const allSelected = section.pages.every((p) => role.pages.includes(p.id))
|
||||
if (allSelected) {
|
||||
role.pages = role.pages.filter((id) => !section.pages.some((p) => p.id === id))
|
||||
return
|
||||
}
|
||||
|
||||
role.pages = Array.from(new Set([...role.pages, ...section.pages.map((p) => p.id)]))
|
||||
}
|
||||
|
||||
const fetchRoles = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
try {
|
||||
const data = await $fetch('/api/admin/roles')
|
||||
roles.value = JSON.parse(JSON.stringify(data))
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch roles:', err)
|
||||
error.value = '加载权限配置失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveRoles = async () => {
|
||||
saving.value = true
|
||||
try {
|
||||
await $fetch('/api/admin/roles', {
|
||||
method: 'PUT',
|
||||
body: roles.value
|
||||
})
|
||||
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification('权限配置已保存', 'success')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to save roles:', err)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification('保存失败: ' + (err.message || '未知错误'), 'error')
|
||||
}
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchRoles()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.role-card {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.role-card:hover {
|
||||
border-color: rgba(99, 102, 241, 0.3);
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<div
|
||||
class="backdrop-blur-md p-6 rounded-xl border border-primary-10 bg-bg-tertiary-70 shadow-2xl max-w-[400px] mx-auto text-text-primary transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-[0_15px_30px_var(--modal-hover-shadow)]"
|
||||
class="backdrop-blur-md p-6 rounded-xl border border-white/10 bg-slate-800/70 shadow-2xl max-w-[400px] mx-auto text-zinc-100 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-[0_15px_30px_rgba(0,0,0,0.2)]"
|
||||
>
|
||||
<h3 class="mb-6 pb-2 border-b border-primary-10 text-text-primary font-bold text-lg">
|
||||
{{ scheduleTitle }}
|
||||
<h3 class="mb-6 pb-2 border-b border-white/10 text-zinc-100 font-bold text-lg">
|
||||
为歌曲 "{{ song?.title }}" 创建排期
|
||||
</h3>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 font-medium text-text-primary" for="playDate">{{ locale.playDate }}</label>
|
||||
<label class="block mb-2 font-medium text-zinc-100" for="playDate">播放日期</label>
|
||||
<input
|
||||
id="playDate"
|
||||
v-model="playDate"
|
||||
class="w-full p-3 border border-primary-10 rounded-lg text-base bg-bg-tertiary-50 text-text-primary outline-none transition-colors duration-150 focus:border-primary focus:shadow-[0_0_0_2px_var(--primary-light)]"
|
||||
class="w-full p-3 border border-white/10 rounded-lg text-base bg-[#0f172a99] text-zinc-100 outline-none transition-colors duration-150 focus:border-indigo-500 focus:shadow-[0_0_0_2px_rgba(99,102,241,0.25)]"
|
||||
required
|
||||
type="date"
|
||||
>
|
||||
@@ -23,19 +23,19 @@
|
||||
<CustomSelect
|
||||
v-model="playTimeId"
|
||||
:options="playTimeOptions"
|
||||
:label="locale.playTime"
|
||||
:placeholder="locale.unspecified"
|
||||
label="播出时段"
|
||||
placeholder="未指定"
|
||||
class-name="w-full"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="song?.preferredPlayTime"
|
||||
class="mt-3 p-3 bg-bg-secondary-5 rounded-lg text-sm flex items-start gap-2"
|
||||
class="mt-3 p-3 bg-white/5 rounded-lg text-sm flex items-start gap-2"
|
||||
>
|
||||
<div class="text-base">💡</div>
|
||||
<Icon class="text-base text-yellow-400" name="lightbulb" size="16" />
|
||||
<div>
|
||||
{{ locale.preferredPlayTime }}
|
||||
<span class="font-medium text-info">
|
||||
用户期望的播出时段:
|
||||
<span class="font-medium text-indigo-300">
|
||||
{{ song.preferredPlayTime.name }}
|
||||
<template v-if="song.preferredPlayTime.startTime || song.preferredPlayTime.endTime">
|
||||
({{ formatPlayTimeRange(song.preferredPlayTime) }})
|
||||
@@ -45,24 +45,24 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-3 mt-4 bg-error-10 text-error rounded-lg">
|
||||
<div v-if="error" class="p-3 mt-4 bg-red-500/10 text-red-300 rounded-lg">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-4 mt-6">
|
||||
<button
|
||||
class="flex-1 p-3 border border-primary-10 rounded-lg text-base cursor-pointer transition-all duration-200 bg-bg-secondary-10 text-text-primary hover:bg-bg-secondary-15"
|
||||
class="flex-1 p-3 border border-white/10 rounded-lg text-base cursor-pointer transition-all duration-200 bg-white/10 text-zinc-100 hover:bg-white/15"
|
||||
type="button"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
{{ commonLocale.cancel }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="flex-1 p-3 border-none rounded-lg text-base cursor-pointer transition-all duration-200 bg-info text-text-primary hover:bg-info hover:-translate-y-px disabled:bg-info-50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
class="flex-1 p-3 border-none rounded-lg text-base cursor-pointer transition-all duration-200 bg-indigo-600 text-white hover:bg-indigo-700 hover:-translate-y-px disabled:bg-indigo-500/50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
type="submit"
|
||||
>
|
||||
{{ loading ? locale.creating : locale.create }}
|
||||
{{ loading ? '创建中...' : '创建排期' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@@ -73,7 +73,6 @@
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const props = defineProps({
|
||||
song: {
|
||||
@@ -93,19 +92,10 @@ const playTimeId = ref('')
|
||||
const error = ref('')
|
||||
const playTimes = ref([])
|
||||
const { playTimeEnabled } = useSongs()
|
||||
const { common } = useLocale()
|
||||
const commonLocale = computed(() => common.value || {})
|
||||
const locale = computed(() => useSafeLocale(common.value?.scheduleForm || {}))
|
||||
const scheduleTitle = computed(() => {
|
||||
const title = locale.value?.title
|
||||
if (typeof title === 'string') return title.replace(/{0}/g, props.song?.title || '')
|
||||
if (typeof title === 'function') return title(props.song?.title || '')
|
||||
return ''
|
||||
})
|
||||
|
||||
// 转换播出时段为 CustomSelect 选项格式
|
||||
const playTimeOptions = computed(() => {
|
||||
const options = [{ label: locale.value.unspecified, value: '' }]
|
||||
const options = [{ label: '未指定', value: '' }]
|
||||
|
||||
if (playTimes.value && playTimes.value.length > 0) {
|
||||
playTimes.value.forEach((pt) => {
|
||||
@@ -152,19 +142,19 @@ const formatPlayTimeRange = (playTime) => {
|
||||
if (playTime.startTime && playTime.endTime) {
|
||||
return `${playTime.startTime} - ${playTime.endTime}`
|
||||
} else if (playTime.startTime) {
|
||||
return formatLocale(locale.value.startAt, playTime.startTime) || playTime.startTime
|
||||
return `${playTime.startTime} 开始`
|
||||
} else if (playTime.endTime) {
|
||||
return formatLocale(locale.value.endAt, playTime.endTime) || playTime.endTime
|
||||
return `${playTime.endTime} 结束`
|
||||
}
|
||||
|
||||
return locale.value.unlimited
|
||||
return '不限时间'
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
error.value = ''
|
||||
|
||||
if (!playDate.value) {
|
||||
error.value = locale.value.dateRequired
|
||||
error.value = '请选择播放日期'
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
@@ -1,79 +1,64 @@
|
||||
<template>
|
||||
<div class="schedule-item-print" :class="{ 'is-narrow': narrow }">
|
||||
<div class="schedule-item-print">
|
||||
<div class="item-content">
|
||||
<div v-if="settings.showSequence || settings.showCover" class="item-leading">
|
||||
<!-- 序号 -->
|
||||
<div v-if="settings.showSequence" class="sequence-number">
|
||||
{{ schedule.sequence || 1 }}
|
||||
</div>
|
||||
<!-- 序号 -->
|
||||
<div v-if="settings.showSequence" class="sequence-number">
|
||||
{{ schedule.sequence || 1 }}
|
||||
</div>
|
||||
|
||||
<!-- 歌曲封面 -->
|
||||
<div v-if="settings.showCover" class="cover-section">
|
||||
<img
|
||||
v-if="schedule.song.cover"
|
||||
:alt="schedule.song.title"
|
||||
:data-original-src="convertToHttps(schedule.song.cover)"
|
||||
:src="convertToHttps(schedule.song.cover)"
|
||||
class="song-cover"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div class="cover-placeholder" :class="{ show: !schedule.song.cover }">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v6m0 6v6" />
|
||||
<path d="m21 12-6-3-6 3-6-3" />
|
||||
</svg>
|
||||
</div>
|
||||
<!-- 歌曲封面 -->
|
||||
<div v-if="settings.showCover" class="cover-section aspect-square">
|
||||
<img
|
||||
v-if="schedule.song.cover"
|
||||
:alt="schedule.song.title"
|
||||
:src="convertToHttps(schedule.song.cover)"
|
||||
class="song-cover object-cover"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div class="cover-placeholder object-contain" :class="{ 'show': !schedule.song.cover }">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v6m0 6v6" />
|
||||
<path d="m21 12-6-3-6 3-6-3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="song-info">
|
||||
<div v-if="settings.showTitle" class="song-title">
|
||||
<span class="title-text">{{ schedule.song.title }}</span>
|
||||
{{ schedule.song.title }}
|
||||
<!-- 重播标识 -->
|
||||
<span v-if="schedule.replayRequestId != null" class="replay-badge-print"> {{ locale.replay }} </span>
|
||||
<!-- 跨学期标识 -->
|
||||
<span
|
||||
v-if="
|
||||
settings.currentSemester &&
|
||||
schedule.song.semester &&
|
||||
schedule.song.semester !== settings.currentSemester
|
||||
"
|
||||
class="cross-semester-badge-print"
|
||||
>
|
||||
{{ locale.crossSemester }}
|
||||
</span>
|
||||
<span v-if="schedule.song.replayRequestCount > 0" class="replay-badge-print"> 重播 </span>
|
||||
</div>
|
||||
<div v-if="settings.showArtist" class="song-artist">
|
||||
{{ schedule.song.artist }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="settings.showRequester || settings.showVotes" class="item-trailing">
|
||||
<!-- 投稿人信息(重播歌曲不显示申请人,只显示原投稿人) -->
|
||||
<div v-if="settings.showRequester" class="requester-info">
|
||||
<span class="label">{{ locale.requesterPrefix }}</span>
|
||||
<span class="value">
|
||||
{{ schedule.song.requester }}
|
||||
<span v-if="schedule.song.collaborators && schedule.song.collaborators.length > 0">
|
||||
& {{ schedule.song.collaborators.map((c) => c.displayName || c.name).join(' & ') }}
|
||||
</span>
|
||||
<!-- 投稿人信息(重播歌曲不显示申请人,只显示原投稿人) -->
|
||||
<div v-if="settings.showRequester" class="requester-info">
|
||||
<span class="label">投稿人:</span>
|
||||
<span class="value">
|
||||
{{ schedule.song.requester }}
|
||||
<span v-if="schedule.song.collaborators && schedule.song.collaborators.length > 0">
|
||||
& {{ schedule.song.collaborators.map((c) => c.displayName || c.name).join(' & ') }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 人数信息 -->
|
||||
<div v-if="settings.showVotes" class="votes-info">
|
||||
<span v-if="schedule.replayRequestId != null" class="label">{{ locale.replayRequestsPrefix }}</span>
|
||||
<span v-else class="label">{{ locale.popularityPrefix }}</span>
|
||||
<span class="value">{{
|
||||
schedule.replayRequestId != null
|
||||
? locale.replayCount(schedule.song.replayRequestCount)
|
||||
: schedule.song.voteCount || 0
|
||||
}}</span>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 人数信息 -->
|
||||
<div v-if="settings.showVotes" class="votes-info">
|
||||
<span v-if="schedule.song.replayRequestCount > 0" class="label">申请重播:</span>
|
||||
<span v-else class="label">热度:</span>
|
||||
<span class="value">{{
|
||||
schedule.song.replayRequestCount > 0
|
||||
? schedule.song.replayRequestCount + '人'
|
||||
: schedule.song.voteCount || 0
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -81,9 +66,6 @@
|
||||
<script setup>
|
||||
import { defineProps } from 'vue'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => admin.value.schedulePrinter)
|
||||
|
||||
// 定义props
|
||||
defineProps({
|
||||
@@ -94,10 +76,6 @@ defineProps({
|
||||
settings: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
narrow: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -113,9 +91,8 @@ const handleImageError = (event) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #d1d5db;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
page-break-inside: avoid;
|
||||
break-inside: avoid;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
@@ -123,21 +100,6 @@ const handleImageError = (event) => {
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.item-leading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.item-trailing {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sequence-number {
|
||||
@@ -150,13 +112,14 @@ const handleImageError = (event) => {
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: #1a1a1a;
|
||||
color: var(--text-quaternary, #333333);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover-section {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
aspect-ratio: 1 / 1;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -165,17 +128,20 @@ const handleImageError = (event) => {
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
aspect-ratio: 1 / 1;
|
||||
object-fit: contain;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #6c757d;
|
||||
color: var(--text-tertiary, #999999);
|
||||
}
|
||||
|
||||
.cover-placeholder.show {
|
||||
@@ -195,53 +161,32 @@ const handleImageError = (event) => {
|
||||
.song-title {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
color: #1a1a1a;
|
||||
color: var(--text-quaternary, #333333);
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* 歌名文本块:text-overflow 只能作用在块容器上,故单独承载裁剪 */
|
||||
.title-text {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 打印用重播标识 */
|
||||
.replay-badge-print {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
background: #f8f9fa;
|
||||
border: 1px solid #0b5afe;
|
||||
background: #e3f2fd;
|
||||
border: 1px solid var(--border-secondary, #2196f3);
|
||||
border-radius: 3px;
|
||||
color: #2563eb;
|
||||
color: #1976d2;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 跨学期标识 */
|
||||
.cross-semester-badge-print {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #d1d5db;
|
||||
color: #636366;
|
||||
font-size: 10px;
|
||||
border-radius: 2px;
|
||||
font-weight: normal;
|
||||
margin-left: 4px;
|
||||
vertical-align: middle;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 14px;
|
||||
color: #636366;
|
||||
color: var(--text-quaternary, #666666);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -252,91 +197,24 @@ const handleImageError = (event) => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #636366;
|
||||
color: var(--text-quaternary, #666666);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #1a1a1a;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
/* 两列窄版排布 */
|
||||
.schedule-item-print.is-narrow {
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
.is-narrow .item-content,
|
||||
.is-narrow .item-leading {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.is-narrow .sequence-number {
|
||||
width: 22px;
|
||||
height: 22px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.is-narrow .cover-section {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.is-narrow .cover-placeholder svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.is-narrow .song-title {
|
||||
font-size: 13px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.is-narrow .song-artist {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.is-narrow .replay-badge-print,
|
||||
.is-narrow .cross-semester-badge-print {
|
||||
font-size: 9px;
|
||||
padding: 0 3px;
|
||||
}
|
||||
|
||||
/* 人数与歌名同行、投稿人与歌手同行 */
|
||||
.is-narrow .item-trailing {
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: 0;
|
||||
min-width: 0;
|
||||
max-width: 45%;
|
||||
}
|
||||
|
||||
.is-narrow .votes-info {
|
||||
order: -1;
|
||||
}
|
||||
|
||||
.is-narrow .requester-info,
|
||||
.is-narrow .votes-info {
|
||||
font-size: 11px;
|
||||
justify-content: flex-end;
|
||||
max-width: 100%;
|
||||
color: var(--text-quaternary, #333333);
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.schedule-item-print {
|
||||
color: #1a1a1a !important;
|
||||
background: #ffffff !important;
|
||||
color: var(--text-primary, #000) !important;
|
||||
background: var(--card-bg, #fff) !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
box-sizing: border-box !important;
|
||||
@@ -352,30 +230,43 @@ const handleImageError = (event) => {
|
||||
|
||||
.sequence-number {
|
||||
background: #f0f0f0 !important;
|
||||
color: #1a1a1a !important;
|
||||
color: var(--text-primary, #000) !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.cover-section {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
width: 48px !important;
|
||||
height: 48px !important;
|
||||
aspect-ratio: 1 / 1 !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: contain !important;
|
||||
border-radius: 0 !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
object-fit: contain !important;
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
color: #1a1a1a !important;
|
||||
color: var(--text-primary, #000) !important;
|
||||
font-size: 16px !important;
|
||||
white-space: nowrap !important;
|
||||
}
|
||||
|
||||
.title-text {
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
@@ -383,11 +274,11 @@ const handleImageError = (event) => {
|
||||
.song-artist,
|
||||
.label,
|
||||
.value {
|
||||
color: #3a3a3c !important;
|
||||
color: var(--text-quaternary, #333333) !important;
|
||||
}
|
||||
|
||||
.time-range {
|
||||
color: #636366 !important;
|
||||
color: var(--text-quaternary, #666666) !important;
|
||||
}
|
||||
|
||||
.requester-info,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -3,14 +3,14 @@
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
|
||||
<p class="text-xs text-text-tertiary mt-1">
|
||||
{{ locale.desc }}
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">学期管理</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">
|
||||
定义系统的教学周期,点歌、排期及统计数据将基于当前活跃学期进行归档
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
|
||||
@click="openModal"
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
@click="openAddModal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -26,7 +26,7 @@
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
{{ locale.add }}
|
||||
添加新学期
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -44,24 +44,24 @@
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-warning fill-warning"
|
||||
class="text-amber-500 fill-amber-500"
|
||||
>
|
||||
<polygon
|
||||
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em]">
|
||||
{{ locale.currentActive }}
|
||||
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
|
||||
当前活跃学期
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentSemester"
|
||||
class="relative bg-gradient-to-br from-primary to-primary-hover rounded-3xl p-10 shadow-2xl shadow-[var(--primary-glow)] overflow-hidden group"
|
||||
class="relative bg-gradient-to-br from-blue-600 to-blue-700 rounded-3xl p-10 shadow-2xl shadow-blue-900/20 overflow-hidden group"
|
||||
>
|
||||
<div class="relative z-10 space-y-6">
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-secondary-10 backdrop-blur-md flex items-center justify-center text-text-primary border border-primary-20"
|
||||
class="w-16 h-16 rounded-3xl bg-white/10 backdrop-blur-md flex items-center justify-center text-white border border-white/20"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -79,10 +79,10 @@
|
||||
</svg>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-3xl font-black text-text-primary tracking-tight leading-tight">
|
||||
<h4 class="text-3xl font-black text-white tracking-tight leading-tight">
|
||||
{{ currentSemester.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2 text-text-primary-80 font-bold text-xs">
|
||||
<div class="flex items-center gap-2 text-blue-100/60 font-bold text-xs">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
@@ -99,20 +99,20 @@
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
{{ getLocaleMessage('createdAt', formatDate(currentSemester.createdAt)) }}
|
||||
创建于 {{ formatDate(currentSemester.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<span
|
||||
class="px-4 py-2 bg-bg-secondary-20 text-text-primary text-[10px] font-black uppercase tracking-widest rounded-xl shadow-lg"
|
||||
class="px-4 py-2 bg-white text-blue-600 text-[10px] font-black uppercase tracking-widest rounded-xl shadow-lg"
|
||||
>
|
||||
{{ locale.inProgress }}
|
||||
正在进行中
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装饰性背景元素 -->
|
||||
<div class="absolute -right-10 -bottom-10 w-64 h-64 bg-bg-secondary-5 blur-3xl rounded-full" />
|
||||
<div class="absolute -right-10 -bottom-10 w-64 h-64 bg-white/5 blur-3xl rounded-full" />
|
||||
<div class="absolute top-10 right-10 opacity-10">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -124,7 +124,7 @@
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-text-primary"
|
||||
class="text-white"
|
||||
>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
@@ -134,7 +134,7 @@
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="bg-bg-secondary-30 border border-border-secondary border-dashed rounded-3xl p-20 flex flex-col items-center justify-center text-center gap-4"
|
||||
class="bg-zinc-900/30 border border-zinc-800 border-dashed rounded-3xl p-20 flex flex-col items-center justify-center text-center gap-4"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -146,19 +146,19 @@
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-text-secondary"
|
||||
class="text-zinc-700"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-sm font-bold text-text-disabled">{{ locale.noCurrent }}</p>
|
||||
<button class="text-xs text-primary font-black hover:underline" @click="openModal">
|
||||
{{ locale.createNow }}
|
||||
<p class="text-sm font-bold text-zinc-600">未设置当前活跃学期</p>
|
||||
<button class="text-xs text-blue-500 font-black hover:underline" @click="openAddModal">
|
||||
立即创建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-bg-secondary-20 border border-border-secondary rounded-3xl flex items-start gap-4">
|
||||
<div class="p-6 bg-zinc-900/20 border border-zinc-800 rounded-3xl flex items-start gap-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
@@ -169,16 +169,16 @@
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-text-tertiary shrink-0 mt-0.5"
|
||||
class="text-zinc-500 shrink-0 mt-0.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-bold text-text-tertiary">{{ locale.switchTitle }}</p>
|
||||
<p class="text-[11px] text-text-disabled leading-relaxed">
|
||||
{{ locale.switchDesc }}
|
||||
<p class="text-xs font-bold text-zinc-400">学期切换说明</p>
|
||||
<p class="text-[11px] text-zinc-600 leading-relaxed">
|
||||
切换学期后,前端点歌界面将清空旧排期。所有历史数据会自动归档至对应学期下,您仍可在数据中心进行查看。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -198,25 +198,25 @@
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-text-disabled"
|
||||
class="text-zinc-600"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M12 7v5l4 2" />
|
||||
</svg>
|
||||
<h3 class="text-[10px] font-black text-text-disabled uppercase tracking-[0.2em]">
|
||||
{{ locale.historyTitle }}
|
||||
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
|
||||
学期历史记录
|
||||
</h3>
|
||||
</div>
|
||||
<span class="text-[10px] font-black text-text-secondary">{{ getLocaleMessage('historyCount', semesters.length) }}</span>
|
||||
<span class="text-[10px] font-black text-zinc-700">共 {{ semesters.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="loading && semesters.length === 0"
|
||||
class="text-center py-20 bg-bg-secondary-10 border border-border-secondary border-dashed rounded-[2rem]"
|
||||
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
|
||||
>
|
||||
<p class="text-xs font-bold text-text-disabled">{{ locale.loading }}</p>
|
||||
<p class="text-xs font-bold text-zinc-600">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -225,8 +225,8 @@
|
||||
class="group flex items-center justify-between p-6 rounded-[2rem] border transition-all"
|
||||
:class="
|
||||
sem.isActive
|
||||
? 'bg-primary-hover-5 border-primary-30'
|
||||
: 'bg-bg-secondary-40 border-border-secondary hover:border-border-tertiary'
|
||||
? 'bg-blue-600/5 border-blue-500/30'
|
||||
: 'bg-zinc-900/40 border-zinc-800 hover:border-zinc-700'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-5">
|
||||
@@ -234,8 +234,8 @@
|
||||
class="w-12 h-12 rounded-2xl flex items-center justify-center transition-all"
|
||||
:class="
|
||||
sem.isActive
|
||||
? 'bg-primary-hover text-text-primary shadow-lg shadow-[var(--primary-glow)]'
|
||||
: 'bg-bg-tertiary text-text-disabled border border-border-tertiary'
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'bg-zinc-800 text-zinc-600 border border-zinc-700'
|
||||
"
|
||||
>
|
||||
<svg
|
||||
@@ -258,13 +258,13 @@
|
||||
<h5
|
||||
class="font-bold transition-colors"
|
||||
:class="
|
||||
sem.isActive ? 'text-primary' : 'text-text-primary group-hover:text-primary'
|
||||
sem.isActive ? 'text-blue-400' : 'text-zinc-200 group-hover:text-blue-400'
|
||||
"
|
||||
>
|
||||
{{ sem.name }}
|
||||
</h5>
|
||||
<p class="text-[10px] text-text-disabled font-medium uppercase tracking-widest mt-0.5">
|
||||
{{ getLocaleMessage('createdAt', formatDate(sem.createdAt)) }}
|
||||
<p class="text-[10px] text-zinc-600 font-medium uppercase tracking-widest mt-0.5">
|
||||
创建于 {{ formatDate(sem.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -272,7 +272,7 @@
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="sem.isActive"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-success-10 text-success rounded-xl border border-success-20"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-500 rounded-xl border border-emerald-500/20"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -288,108 +288,83 @@
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<span class="text-[10px] font-black uppercase tracking-widest">{{ locale.currentSemester }}</span>
|
||||
<span class="text-[10px] font-black uppercase tracking-widest">当前学期</span>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
:disabled="loading"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary hover:text-text-primary text-[10px] font-black rounded-xl border border-border-tertiary transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="setActive(sem.id)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<template v-else>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-white text-[10px] font-black rounded-xl border border-zinc-700 transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="setActive(sem.id)"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
{{ locale.setCurrent }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="p-2.5 text-text-secondary hover:text-primary hover:bg-primary-10 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="locale.editSemester"
|
||||
@click="openEditModal(sem)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
设为当前
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="p-2.5 text-zinc-700 hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="deleteSemester(sem.id)"
|
||||
>
|
||||
<path d="M17 3a2.828 2.828 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5L17 3z" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="!sem.isActive"
|
||||
:disabled="loading"
|
||||
class="p-2.5 text-text-secondary hover:text-error hover:bg-error-10 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:title="locale.deleteSemester"
|
||||
@click="deleteSemester(sem.id)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path
|
||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path
|
||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="semesters.length === 0 && !loading"
|
||||
class="text-center py-20 bg-bg-secondary-10 border border-border-secondary border-dashed rounded-[2rem]"
|
||||
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
|
||||
>
|
||||
<p class="text-xs font-bold text-text-disabled">{{ locale.empty }}</p>
|
||||
<p class="text-xs font-bold text-zinc-600">暂无学期数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/修改学期弹窗 -->
|
||||
<!-- 添加学期弹窗 -->
|
||||
<div
|
||||
v-if="showModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-primary-60 backdrop-blur-sm"
|
||||
v-if="showAddModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-bg-secondary rounded-3xl border border-border-secondary shadow-2xl overflow-hidden"
|
||||
class="w-full max-w-md bg-zinc-900 rounded-3xl border border-zinc-800 shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight">
|
||||
{{ isEditing ? locale.editSemester : locale.add }}
|
||||
</h3>
|
||||
<h3 class="text-xl font-black text-white tracking-tight">添加新学期</h3>
|
||||
<button
|
||||
class="p-2 text-text-tertiary hover:text-text-primary transition-colors"
|
||||
@click="closeModal"
|
||||
class="p-2 text-zinc-500 hover:text-white transition-colors"
|
||||
@click="closeAddModal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
@@ -410,35 +385,35 @@
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.semesterName }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>学期名称</label
|
||||
>
|
||||
<input
|
||||
v-model="semesterForm.name"
|
||||
v-model="newSemester.name"
|
||||
type="text"
|
||||
:placeholder="locale.namePlaceholder"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-2xl px-5 py-4 text-sm text-text-primary focus:outline-none focus:border-primary-30 font-bold transition-all"
|
||||
placeholder="例如: 2025学年第一学期"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 font-bold transition-all"
|
||||
>
|
||||
</div>
|
||||
|
||||
<label v-if="!isEditing" class="flex items-center gap-3 cursor-pointer group px-1">
|
||||
<label class="flex items-center gap-3 cursor-pointer group px-1">
|
||||
<input
|
||||
v-model="semesterForm.isActive"
|
||||
v-model="newSemester.isActive"
|
||||
type="checkbox"
|
||||
class="w-4.5 h-4.5 rounded-lg border-border-secondary bg-bg-primary transition-all"
|
||||
class="w-4.5 h-4.5 rounded-lg border-zinc-800 bg-zinc-950 accent-blue-600 transition-all"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text-xs font-bold text-text-secondary group-hover:text-primary transition-colors"
|
||||
>{{ locale.setAsActive }}</span
|
||||
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
|
||||
>设为当前活跃学期</span
|
||||
>
|
||||
<p class="text-[10px] text-text-disabled font-medium">
|
||||
{{ locale.setAsActiveHint }}
|
||||
<p class="text-[10px] text-zinc-600 font-medium">
|
||||
创建后将立即激活此学期,原学期将变为历史状态
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="p-4 bg-bg-primary border border-border-secondary rounded-2xl flex items-start gap-3">
|
||||
<div class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl flex items-start gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
@@ -449,32 +424,32 @@
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-warning shrink-0 mt-0.5"
|
||||
class="text-amber-500 shrink-0 mt-0.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-[10px] text-text-tertiary leading-normal">
|
||||
{{ locale.namingTip }}
|
||||
<p class="text-[10px] text-zinc-500 leading-normal">
|
||||
提示:请按照统一的命名规范设置学期名称,这有助于数据归档后的检索与分析。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-bg-tertiary-50 border-t border-border-secondary flex gap-3 justify-end">
|
||||
<div class="p-6 bg-zinc-800/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-6 py-2.5 text-xs font-bold text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
@click="closeModal"
|
||||
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@click="closeAddModal"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="submitting || !semesterForm.name.trim()"
|
||||
class="px-8 py-2.5 bg-primary-hover hover:bg-primary disabled:bg-primary-hover disabled:opacity-50 text-text-primary text-xs font-black rounded-xl shadow-lg transition-all active:scale-95"
|
||||
@click="handleSubmit"
|
||||
:disabled="submitting || !newSemester.name.trim()"
|
||||
class="px-8 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:opacity-50 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95"
|
||||
@click="handleAddSemester"
|
||||
>
|
||||
{{ submitting ? (isEditing ? locale.saving : locale.creating) : (isEditing ? locale.saveChanges : locale.createSemester) }}
|
||||
{{ submitting ? '创建中...' : '创建学期' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -484,10 +459,10 @@
|
||||
<ConfirmDialog
|
||||
:loading="loading"
|
||||
:show="showDeleteDialog"
|
||||
:cancel-text="locale.cancel"
|
||||
:confirm-text="locale.confirmDelete"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认删除"
|
||||
:message="deleteMessage"
|
||||
:title="locale.deleteDialogTitle"
|
||||
title="确认删除学期"
|
||||
type="danger"
|
||||
@close="showDeleteDialog = false"
|
||||
@confirm="confirmDelete"
|
||||
@@ -499,12 +474,8 @@
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => admin.value?.semesterManager || {})
|
||||
const { msg: getLocaleMessage, nested: getNestedMessage } = useLocaleText(locale)
|
||||
|
||||
const {
|
||||
semesters,
|
||||
@@ -515,20 +486,15 @@ const {
|
||||
fetchCurrentSemester,
|
||||
createSemester,
|
||||
setActiveSemester,
|
||||
updateSemester,
|
||||
deleteSemester: deleteSemesterAPI
|
||||
} = useSemesters()
|
||||
|
||||
const showModal = ref(false)
|
||||
const isEditing = ref(false)
|
||||
const editSemesterId = ref(null)
|
||||
const originalSemesterName = ref('')
|
||||
|
||||
const showAddModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTargetId = ref(null)
|
||||
const deleteTargetName = ref('')
|
||||
const submitting = ref(false)
|
||||
const semesterForm = ref({
|
||||
const newSemester = ref({
|
||||
name: '',
|
||||
isActive: false
|
||||
})
|
||||
@@ -542,7 +508,7 @@ const sortedSemesters = computed(() => {
|
||||
|
||||
// 删除确认信息
|
||||
const deleteMessage = computed(() => {
|
||||
return getLocaleMessage('deleteMessage', deleteTargetName.value)
|
||||
return `确定要彻底删除 "${deleteTargetName.value}" 吗?此操作将同步清除该学期下的所有点歌记录、排期数据和统计信息。数据一旦删除将无法找回。`
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
@@ -563,13 +529,13 @@ const setActive = async (semesterId) => {
|
||||
// 注意:这里需要重新获取error,因为useSemesters内部可能没有正确更新error的响应式引用
|
||||
// 或者直接依据success返回值判断
|
||||
if (success) {
|
||||
showNotification(getNestedMessage('messages', 'activeSet'), 'success')
|
||||
showNotification('活跃学期设置成功!', 'success')
|
||||
} else {
|
||||
// 如果setActiveSemester返回false,尝试直接显示一个通用错误,因为error可能为空
|
||||
showNotification(error.value || getNestedMessage('errors', 'setActiveFailedRetry'), 'error')
|
||||
showNotification(error.value || '设置活跃学期失败,请稍后重试', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err.message || getNestedMessage('errors', 'setFailed'), 'error')
|
||||
showNotification(err.message || '设置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -590,12 +556,12 @@ const confirmDelete = async () => {
|
||||
try {
|
||||
const success = await deleteSemesterAPI(deleteTargetId.value)
|
||||
if (success) {
|
||||
showNotification(getNestedMessage('messages', 'deleted'), 'success')
|
||||
showNotification('学期删除成功!', 'success')
|
||||
} else {
|
||||
showNotification(error.value || getNestedMessage('errors', 'deleteFailed'), 'error')
|
||||
showNotification(error.value || '删除学期失败', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err.message || getNestedMessage('errors', 'deleteFailedShort'), 'error')
|
||||
showNotification(err.message || '删除失败', 'error')
|
||||
}
|
||||
|
||||
showDeleteDialog.value = false
|
||||
@@ -603,103 +569,64 @@ const confirmDelete = async () => {
|
||||
deleteTargetName.value = ''
|
||||
}
|
||||
|
||||
// 处理表单提交
|
||||
const handleSubmit = async () => {
|
||||
const normalizedName = semesterForm.value.name.trim()
|
||||
if (!normalizedName) return
|
||||
|
||||
if (isEditing.value && normalizedName === originalSemesterName.value) {
|
||||
showNotification(getNestedMessage('messages', 'nameUnchanged'), 'info')
|
||||
closeModal()
|
||||
return
|
||||
}
|
||||
// 处理添加学期
|
||||
const handleAddSemester = async () => {
|
||||
if (!newSemester.value.name.trim()) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
if (isEditing.value && editSemesterId.value) {
|
||||
// 修改学期
|
||||
const success = await updateSemester(editSemesterId.value, {
|
||||
name: normalizedName
|
||||
})
|
||||
if (success) {
|
||||
showNotification(getNestedMessage('messages', 'updated'), 'success')
|
||||
closeModal()
|
||||
} else {
|
||||
showNotification(error.value || getNestedMessage('errors', 'updateFailed'), 'error')
|
||||
}
|
||||
const result = await createSemester({
|
||||
name: newSemester.value.name,
|
||||
isActive: newSemester.value.isActive
|
||||
})
|
||||
if (result) {
|
||||
showNotification('学期创建成功!', 'success')
|
||||
closeAddModal()
|
||||
} else {
|
||||
// 创建学期
|
||||
const result = await createSemester({
|
||||
name: normalizedName,
|
||||
isActive: semesterForm.value.isActive
|
||||
})
|
||||
if (result) {
|
||||
showNotification(getNestedMessage('messages', 'created'), 'success')
|
||||
closeModal()
|
||||
} else {
|
||||
showNotification(error.value || getNestedMessage('errors', 'createFailed'), 'error')
|
||||
}
|
||||
showNotification(error.value || '创建学期失败', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(isEditing.value ? '修改学期失败:' : '创建学期失败:', err)
|
||||
showNotification(
|
||||
err.message || (isEditing.value ? getNestedMessage('errors', 'updateFailed') : getNestedMessage('errors', 'createFailed')),
|
||||
'error'
|
||||
)
|
||||
console.error('创建学期失败:', err)
|
||||
showNotification(err.message || '创建学期失败', 'error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭弹窗
|
||||
const closeModal = () => {
|
||||
showModal.value = false
|
||||
isEditing.value = false
|
||||
editSemesterId.value = null
|
||||
originalSemesterName.value = ''
|
||||
semesterForm.value = {
|
||||
// 关闭添加弹窗
|
||||
const closeAddModal = () => {
|
||||
showAddModal.value = false
|
||||
newSemester.value = {
|
||||
name: '',
|
||||
isActive: false
|
||||
}
|
||||
}
|
||||
|
||||
const getRecommendedName = () => {
|
||||
const now = getSyncedDate()
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = now.getMonth() + 1
|
||||
|
||||
let academicYear = year
|
||||
let term = locale.value?.terms?.first || '第一学期'
|
||||
let term = '第一学期'
|
||||
|
||||
if (month >= 3 && month <= 8) {
|
||||
academicYear = year - 1
|
||||
term = locale.value?.terms?.second || '第二学期'
|
||||
term = '第二学期'
|
||||
} else if (month <= 2) {
|
||||
academicYear = year - 1
|
||||
term = locale.value?.terms?.first || '第一学期'
|
||||
term = '第一学期'
|
||||
} else {
|
||||
academicYear = year
|
||||
term = locale.value?.terms?.first || '第一学期'
|
||||
term = '第一学期'
|
||||
}
|
||||
|
||||
return getLocaleMessage('recommendedName', academicYear, term)
|
||||
return `${academicYear}学年${term}`
|
||||
}
|
||||
|
||||
const openModal = () => {
|
||||
isEditing.value = false
|
||||
editSemesterId.value = null
|
||||
semesterForm.value.name = getRecommendedName()
|
||||
semesterForm.value.isActive = false
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
const openEditModal = (sem) => {
|
||||
isEditing.value = true
|
||||
editSemesterId.value = sem.id
|
||||
originalSemesterName.value = sem.name
|
||||
semesterForm.value.name = sem.name
|
||||
semesterForm.value.isActive = sem.isActive
|
||||
showModal.value = true
|
||||
const openAddModal = () => {
|
||||
newSemester.value.name = getRecommendedName()
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<template>
|
||||
<aside
|
||||
:class="[
|
||||
'fixed inset-y-0 left-0 z-50 w-64 bg-bg-primary border-r border-border-secondary transform transition-transform duration-300 ease-in-out lg:translate-x-0',
|
||||
'fixed inset-y-0 left-0 z-50 w-64 bg-[#09090b] border-r border-zinc-800 transform transition-transform duration-300 ease-in-out lg:translate-x-0',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
]"
|
||||
>
|
||||
@@ -12,26 +12,34 @@
|
||||
<NuxtLink to="/" class="flex items-center gap-2.5 group">
|
||||
<!-- Logo 图标 -->
|
||||
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
|
||||
<img src="/assets/logo.png" alt="VoiceHub Logo" class="w-8 h-8 object-contain" >
|
||||
<img
|
||||
:src="logo"
|
||||
alt="VoiceHub Logo"
|
||||
class="w-8 h-8 object-contain"
|
||||
width="32"
|
||||
height="32"
|
||||
decoding="async"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
<!-- 品牌文字 -->
|
||||
<div class="flex flex-col justify-center">
|
||||
<h1 class="font-bold text-lg text-text-primary leading-none tracking-tight">VoiceHub</h1>
|
||||
<h1 class="font-bold text-lg text-zinc-100 leading-none tracking-tight">VoiceHub</h1>
|
||||
<p
|
||||
class="text-[10px] text-text-tertiary mt-1.5 uppercase tracking-widest font-bold leading-none"
|
||||
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
|
||||
>
|
||||
{{ locale.console }}
|
||||
管理控制台
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单区域 -->
|
||||
<nav class="flex-1 space-y-6 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<nav class="admin-sidebar-nav flex-1 space-y-6 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div v-for="(group, idx) in menuGroups" :key="idx" class="space-y-1">
|
||||
<template v-if="shouldShowGroup(group)">
|
||||
<!-- 分组标题 -->
|
||||
<h3 class="px-3 text-[10px] font-bold text-text-disabled uppercase tracking-[0.2em] mb-2">
|
||||
<h3 class="px-3 text-[10px] font-bold text-zinc-600 uppercase tracking-[0.2em] mb-2">
|
||||
{{ group.section }}
|
||||
</h3>
|
||||
<!-- 菜单项列表 -->
|
||||
@@ -39,10 +47,11 @@
|
||||
<button
|
||||
v-if="permissions.canAccessPage(item.permissionId || item.id)"
|
||||
:class="[
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-[13px] font-bold transition-all group border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary-10 focus-visible:ring-inset',
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-[13px] font-bold transition-all group border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/80 focus-visible:ring-inset',
|
||||
activeTab === item.id
|
||||
? 'bg-primary-hover-10 text-primary border-primary-20'
|
||||
: 'text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary-40 border-transparent'
|
||||
? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
|
||||
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40 border-transparent',
|
||||
getTourClass(item.id)
|
||||
]"
|
||||
@click="onNavigate(item.id)"
|
||||
>
|
||||
@@ -52,15 +61,15 @@
|
||||
:size="18"
|
||||
:class="
|
||||
activeTab === item.id
|
||||
? 'text-primary'
|
||||
: 'text-text-tertiary group-hover:text-text-secondary'
|
||||
? 'text-blue-400'
|
||||
: 'text-zinc-500 group-hover:text-zinc-300'
|
||||
"
|
||||
/>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
<!-- 选中状态指示器 -->
|
||||
<div
|
||||
v-if="activeTab === item.id"
|
||||
class="ml-auto w-1 h-1 bg-primary rounded-full shadow-[0_0_8px_var(--primary-60)]"
|
||||
class="ml-auto w-1 h-1 bg-blue-400 rounded-full shadow-[0_0_8px_rgba(96,165,250,0.6)]"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
@@ -69,38 +78,38 @@
|
||||
</nav>
|
||||
|
||||
<!-- 用户信息及退出登录 -->
|
||||
<div class="mt-4 pt-4 border-t border-border-secondary">
|
||||
<div class="mt-4 pt-4 border-t border-zinc-800">
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 rounded-lg bg-bg-secondary-50 border border-border-secondary-50 hover:bg-bg-tertiary-30 transition-colors"
|
||||
class="flex items-center gap-3 p-3 rounded-lg bg-zinc-900/50 border border-zinc-800/50 hover:bg-zinc-800/30 transition-colors"
|
||||
>
|
||||
<!-- 用户头像/首字母 -->
|
||||
<img
|
||||
v-if="currentUser?.avatar && !avatarError"
|
||||
:src="currentUser.avatar"
|
||||
class="w-10 h-10 rounded-lg object-cover border border-border-tertiary shrink-0"
|
||||
class="w-10 h-10 rounded-lg object-cover border border-zinc-700 shrink-0"
|
||||
@error="avatarError = true"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-10 h-10 rounded-lg bg-bg-tertiary flex items-center justify-center text-text-tertiary font-bold border border-border-tertiary shrink-0"
|
||||
class="w-10 h-10 rounded-lg bg-zinc-800 flex items-center justify-center text-zinc-400 font-bold border border-zinc-700 shrink-0"
|
||||
>
|
||||
{{ (currentUser?.name || locale.avatarFallback || '管').charAt(0) }}
|
||||
{{ (currentUser?.name || '管').charAt(0) }}
|
||||
</div>
|
||||
<!-- 用户详细信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-black truncate text-text-primary">
|
||||
{{ currentUser?.name || locale.adminFallback }}
|
||||
<p class="text-xs font-black truncate text-zinc-100">
|
||||
{{ currentUser?.name || '管理员' }}
|
||||
</p>
|
||||
<p
|
||||
class="text-[10px] text-text-tertiary truncate uppercase tracking-wider font-medium mt-0.5"
|
||||
class="text-[10px] text-zinc-500 truncate uppercase tracking-wider font-medium mt-0.5"
|
||||
>
|
||||
{{ getRoleDisplayName(currentUser?.role || 'ADMIN') }}
|
||||
{{ currentUser?.role?.replace('_', ' ') || 'ADMIN' }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 退出按钮 -->
|
||||
<button
|
||||
class="p-2 text-text-disabled hover:text-error hover:bg-error-10 rounded-lg transition-all"
|
||||
:title="locale.logout"
|
||||
class="p-2 text-zinc-600 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
|
||||
title="退出登录"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
<LogOut :size="16" />
|
||||
@@ -128,20 +137,20 @@ import {
|
||||
LogOut,
|
||||
Clock,
|
||||
FileEdit,
|
||||
ListChecks,
|
||||
BookOpen,
|
||||
Ban,
|
||||
ListMusic,
|
||||
Globe,
|
||||
Image as ImageIcon,
|
||||
ScrollText,
|
||||
Database,
|
||||
Lock,
|
||||
Ticket,
|
||||
GraduationCap
|
||||
Shield
|
||||
} from '@lucide/vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import logo from '~~/public/images/logo.png'
|
||||
import { rolesConfig } from '~/composables/usePermissions'
|
||||
|
||||
const avatarError = ref(false)
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => admin.value?.sidebar || {})
|
||||
|
||||
const props = defineProps({
|
||||
// 侧边栏是否打开(移动端)
|
||||
@@ -166,56 +175,57 @@ watch(
|
||||
const emit = defineEmits(['navigate', 'close', 'logout'])
|
||||
|
||||
// 菜单分组配置
|
||||
const menuGroups = computed(() => [
|
||||
const menuGroups = [
|
||||
{
|
||||
section: locale.value.sections?.overview || '概览',
|
||||
items: [{ icon: LayoutDashboard, label: locale.value.menu?.overview || '数据概览', id: 'overview' }]
|
||||
},
|
||||
{
|
||||
section: locale.value.sections?.content || '内容管理',
|
||||
section: '数据中心',
|
||||
items: [
|
||||
{ icon: CalendarDays, label: locale.value.menu?.schedule || '排班管理', id: 'schedule' },
|
||||
{ icon: Printer, label: locale.value.menu?.print || '打印管理', id: 'print' },
|
||||
{ icon: Music2, label: locale.value.menu?.songs || '点歌管理', id: 'songs' },
|
||||
{
|
||||
icon: BarChart3,
|
||||
label: locale.value.menu?.dataAnalysis || '数据分析',
|
||||
id: 'data-analysis',
|
||||
permissionId: 'data-analysis'
|
||||
}
|
||||
{ icon: LayoutDashboard, label: '数据概览', id: 'overview' },
|
||||
{ icon: BookOpen, label: '流程与教程', id: 'workflow-tutorial' },
|
||||
{ icon: BarChart3, label: '数据分析', id: 'data-analysis', permissionId: 'data-analysis' }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: locale.value.sections?.users || '用户管理',
|
||||
section: '播音管理',
|
||||
items: [
|
||||
{ icon: Users, label: locale.value.menu?.users || '用户管理', id: 'users' },
|
||||
{ icon: GraduationCap, label: locale.value.menu?.gradeClass || '年级班级', id: 'grade-class' }
|
||||
{ icon: Music2, label: '歌曲管理', id: 'songs' },
|
||||
{ icon: CalendarDays, label: '排期管理', id: 'schedule' },
|
||||
{ icon: Printer, label: '打印排期', id: 'print' },
|
||||
{ icon: ImageIcon, label: '图片歌单', id: 'playlist-card' }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: locale.value.sections?.api || 'API',
|
||||
items: [{ icon: Key, label: locale.value.menu?.apiKeys || 'API 密钥', id: 'api-keys' }]
|
||||
},
|
||||
{
|
||||
section: locale.value.sections?.system || '系统设置',
|
||||
section: '业务设置',
|
||||
items: [
|
||||
{ icon: Bell, label: locale.value.menu?.notifications || '通知管理', id: 'notifications' },
|
||||
{ icon: Mail, label: locale.value.menu?.smtpConfig || 'SMTP 配置', id: 'smtp-config' },
|
||||
{ icon: Clock, label: locale.value.menu?.playtimes || '播放时段', id: 'playtimes' },
|
||||
{ icon: FileEdit, label: locale.value.menu?.requestTimes || '点歌时段', id: 'request-times' },
|
||||
{ icon: BookOpen, label: locale.value.menu?.semesters || '学期管理', id: 'semesters' },
|
||||
{ icon: Ban, label: locale.value.menu?.blacklist || '黑名单', id: 'blacklist' },
|
||||
{ icon: Ticket, label: locale.value.menu?.cardCodes || '卡密管理', id: 'card-codes' },
|
||||
{ icon: ListMusic, label: locale.value.menu?.musicSource || '音源控制', id: 'music-source' },
|
||||
{ icon: Globe, label: locale.value.menu?.siteConfig || '站点配置', id: 'site-config' },
|
||||
{ icon: Database, label: locale.value.menu?.database || '数据库', id: 'database' }
|
||||
{ icon: CalendarDays, label: '选曲日期', id: 'expected-play-dates', permissionId: 'settings' },
|
||||
{ icon: FileEdit, label: '投稿管理', id: 'request-times' },
|
||||
{ icon: BookOpen, label: '学期管理', id: 'semesters' },
|
||||
{ icon: Clock, label: '播出时段', id: 'playtimes' }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: locale.value.sections?.account || '账户',
|
||||
items: [{ icon: Lock, label: locale.value.menu?.password || '修改密码', id: 'password' }]
|
||||
section: '用户与权限',
|
||||
items: [
|
||||
{ icon: Users, label: '用户管理', id: 'users' },
|
||||
{ icon: Shield, label: '权限组管理', id: 'roles', permissionId: 'roles' },
|
||||
{ icon: Ban, label: '黑名单管理', id: 'blacklist' },
|
||||
{ icon: Key, label: 'API密钥管理', id: 'api-keys' }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '系统运维',
|
||||
items: [
|
||||
{ icon: Globe, label: '站点配置', id: 'site-config' },
|
||||
{ icon: ScrollText, label: '更新日志', id: 'changelog' },
|
||||
{ icon: Bell, label: '通知管理', id: 'notifications' },
|
||||
{ icon: Mail, label: '邮件配置', id: 'smtp-config' },
|
||||
{ icon: Database, label: '数据库操作', id: 'database' }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '账户管理',
|
||||
items: [{ icon: Lock, label: '修改密码', id: 'password' }]
|
||||
}
|
||||
])
|
||||
]
|
||||
|
||||
/**
|
||||
* 判断是否应该显示该菜单组
|
||||
@@ -238,12 +248,25 @@ const onNavigate = (id) => {
|
||||
emit('navigate', id)
|
||||
}
|
||||
|
||||
const getTourClass = (id) => `tour-nav-${id}`
|
||||
|
||||
/**
|
||||
* 获取角色显示名称
|
||||
* @param {string} role 角色标识
|
||||
*/
|
||||
const getRoleDisplayName = (role) => {
|
||||
return locale.value.roles?.[role] || role
|
||||
if (rolesConfig.value && rolesConfig.value[role]?.displayName) {
|
||||
return rolesConfig.value[role].displayName
|
||||
}
|
||||
const roleNames = {
|
||||
USER: '学生(普通用户)',
|
||||
SONG_ADMIN: '工作人员',
|
||||
VICE_MINISTER: '副部',
|
||||
ADMIN: '部长',
|
||||
RESIDENT: '常驻',
|
||||
SUPER_ADMIN: '技术运维'
|
||||
}
|
||||
return roleNames[role] || role
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -256,10 +279,10 @@ const getRoleDisplayName = (role) => {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--panel-bg-alt);
|
||||
background: var(--bg-tertiary, #27272a);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--panel-bg-hover);
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,26 +3,26 @@
|
||||
<!-- 头部区域 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-text-primary tracking-tight">{{ locale.title }}</h2>
|
||||
<p class="text-xs text-text-tertiary mt-1">
|
||||
{{ locale.desc }}
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">邮件服务配置</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">
|
||||
配置 SMTP 服务以发送系统验证码、投稿通知及其他重要提醒
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-secondary text-xs font-bold rounded-xl transition-all active:scale-95 disabled:opacity-50"
|
||||
class="flex items-center gap-2 px-6 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="reloading"
|
||||
@click="reloadSmtpConfig"
|
||||
>
|
||||
<RotateCw :size="14" :class="reloading ? 'animate-spin' : ''" />
|
||||
{{ reloading ? locale.reloading : locale.reload }}
|
||||
{{ reloading ? '重载中...' : '重载SMTP' }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2 bg-primary-hover hover:bg-primary text-text-primary text-xs font-bold rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95"
|
||||
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
:disabled="saving"
|
||||
@click="saveConfig"
|
||||
>
|
||||
<Save :size="14" /> {{ saving ? locale.saving : locale.save }}
|
||||
<Save :size="14" /> {{ saving ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -31,20 +31,20 @@
|
||||
<!-- 左侧栏:SMTP 设置与测试 -->
|
||||
<div class="xl:col-span-4 space-y-8">
|
||||
<!-- SMTP 核心设置 -->
|
||||
<section class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-6 space-y-6">
|
||||
<section class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3
|
||||
class="text-sm font-black text-text-primary uppercase tracking-widest flex items-center gap-2"
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<Server :size="16" class="text-primary" /> {{ locale.serviceConfig }}
|
||||
<Server :size="16" class="text-blue-500" /> 服务配置
|
||||
</h3>
|
||||
<button
|
||||
class="relative w-10 h-5 rounded-full transition-colors"
|
||||
:class="config.smtpEnabled ? 'bg-primary-hover' : 'bg-bg-tertiary'"
|
||||
:class="config.smtpEnabled ? 'bg-blue-600' : 'bg-zinc-800'"
|
||||
@click="config.smtpEnabled = !config.smtpEnabled"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 w-3 h-3 bg-bg-secondary rounded-full transition-all"
|
||||
class="absolute top-1 w-3 h-3 bg-white rounded-full transition-all"
|
||||
:class="config.smtpEnabled ? 'left-6' : 'left-1'"
|
||||
/>
|
||||
</button>
|
||||
@@ -52,118 +52,118 @@
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.smtpHost }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>SMTP 服务器</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpHost"
|
||||
type="text"
|
||||
placeholder="smtp.example.com"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.port }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>端口</label
|
||||
>
|
||||
<input
|
||||
v-model.number="config.smtpPort"
|
||||
type="number"
|
||||
placeholder="587"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.secure }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>加密方式</label
|
||||
>
|
||||
<CustomSelect
|
||||
:model-value="config.smtpSecure ? secureOptionLabels.ssl : secureOptionLabels.none"
|
||||
:options="[secureOptionLabels.ssl, secureOptionLabels.none]"
|
||||
:model-value="config.smtpSecure ? 'SSL/TLS' : '无'"
|
||||
:options="['SSL/TLS', '无']"
|
||||
class="w-full"
|
||||
@update:model-value="(val) => (config.smtpSecure = val === secureOptionLabels.ssl)"
|
||||
@update:model-value="(val) => (config.smtpSecure = val === 'SSL/TLS')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.username }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>发件人账号</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpUsername"
|
||||
type="text"
|
||||
placeholder="your-email@example.com"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.fromEmail }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>发件人邮箱 (From Email)</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpFromEmail"
|
||||
type="text"
|
||||
:placeholder="config.smtpUsername || 'your-email@example.com'"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
<p class="text-[9px] text-text-tertiary px-1 italic">
|
||||
{{ locale.fromEmailHint }}
|
||||
<p class="text-[9px] text-zinc-500 px-1 italic">
|
||||
通常与发件人账号一致,部分服务商强制要求一致。
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.password }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>授权码 / 密码</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpPassword"
|
||||
type="password"
|
||||
placeholder="••••••••••••"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.fromName }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>发件人姓名</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpFromName"
|
||||
type="text"
|
||||
:placeholder="locale.defaultFromName"
|
||||
class="w-full bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
|
||||
placeholder="校园广播站"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SMTP 测试模块 -->
|
||||
<section class="bg-bg-secondary-30 border border-border-secondary rounded-[2rem] p-6 space-y-6">
|
||||
<section class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
|
||||
<h3
|
||||
class="text-sm font-black text-text-primary uppercase tracking-widest flex items-center gap-2"
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<Send :size="16" class="text-success" /> {{ locale.serviceTest }}
|
||||
<Send :size="16" class="text-emerald-500" /> 服务测试
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-text-disabled uppercase tracking-widest px-1"
|
||||
>{{ locale.testEmail }}</label
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>测试接收邮箱</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="testEmail"
|
||||
type="email"
|
||||
:placeholder="locale.testEmailPlaceholder"
|
||||
class="flex-1 bg-bg-primary border border-border-secondary rounded-xl px-4 py-2.5 text-xs text-text-primary focus:outline-none focus:border-primary-30"
|
||||
placeholder="输入测试邮箱地址"
|
||||
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-2 px-4 py-2 bg-bg-secondary border border-border-secondary hover:border-border-tertiary text-text-tertiary text-xs font-bold rounded-xl transition-all disabled:opacity-50"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="testing || !testEmail || !config.smtpEnabled"
|
||||
@click="sendTestEmail"
|
||||
>
|
||||
<Check v-if="testResult?.success" :size="14" class="text-success" />
|
||||
<Check v-if="testResult?.success" :size="14" class="text-emerald-500" />
|
||||
<Send v-else :size="14" />
|
||||
{{ testing ? locale.sending : testResult?.success ? locale.sent : locale.send }}
|
||||
{{ testing ? '发送中' : testResult?.success ? '已发送' : '发送' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -173,8 +173,8 @@
|
||||
class="flex items-center gap-2 p-3 rounded-xl text-xs"
|
||||
:class="
|
||||
testResult.success
|
||||
? 'bg-success-10 text-success border border-success-20'
|
||||
: 'bg-error-10 text-error border border-error-20'
|
||||
? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/20'
|
||||
: 'bg-red-500/10 text-red-500 border border-red-500/20'
|
||||
"
|
||||
>
|
||||
<CheckCircle v-if="testResult.success" :size="14" />
|
||||
@@ -200,26 +200,13 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import EmailTemplateManager from '~/components/Admin/EmailTemplateManager.vue'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import { Server, Save, Check, Send, CheckCircle, XCircle, RotateCw } from '@lucide/vue'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => admin.value?.smtpManager || {})
|
||||
const getLocaleMessage = (key) => locale.value?.[key] || `SMTP 配置:${key}`
|
||||
const getOptionText = (value, fallback) => {
|
||||
if (typeof value === 'function') return value() || fallback
|
||||
if (typeof value === 'string') return value || fallback
|
||||
return fallback
|
||||
}
|
||||
const secureOptionLabels = computed(() => ({
|
||||
ssl: getOptionText(locale.value?.secureOptions?.ssl, 'SSL/TLS'),
|
||||
none: getOptionText(locale.value?.secureOptions?.none, 'None')
|
||||
}))
|
||||
|
||||
// 响应式数据
|
||||
const config = ref({
|
||||
@@ -230,7 +217,7 @@ const config = ref({
|
||||
smtpUsername: '',
|
||||
smtpPassword: '',
|
||||
smtpFromEmail: '',
|
||||
smtpFromName: getLocaleMessage('defaultFromName')
|
||||
smtpFromName: '校园广播站'
|
||||
})
|
||||
|
||||
const testEmail = ref('')
|
||||
@@ -240,30 +227,6 @@ const reloading = ref(false)
|
||||
const testResult = ref(null)
|
||||
const originalConfig = ref({})
|
||||
|
||||
// 将服务端返回的固定文案映射为当前语言,避免后端中文穿透到前端界面。
|
||||
const getLocalizedServerMessage = (message) => {
|
||||
if (!message) {
|
||||
return ''
|
||||
}
|
||||
|
||||
const serverMessages = locale.value?.serverMessages
|
||||
if (!serverMessages) return message
|
||||
|
||||
const rawMessages = serverMessages.raw
|
||||
if (rawMessages) {
|
||||
const key = Object.keys(rawMessages).find((item) => rawMessages[item] === message)
|
||||
return key ? serverMessages[key] : message
|
||||
}
|
||||
|
||||
const matchedEntry = Object.values(serverMessages).find((entry) => entry?.raw === message)
|
||||
return matchedEntry?.text || message
|
||||
}
|
||||
|
||||
const localizeSmtpResult = (response) => ({
|
||||
...response,
|
||||
message: getLocalizedServerMessage(response?.message) || response?.message
|
||||
})
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
@@ -277,14 +240,14 @@ const loadConfig = async () => {
|
||||
smtpUsername: response.smtpUsername || '',
|
||||
smtpPassword: response.smtpPassword || '',
|
||||
smtpFromEmail: response.smtpFromEmail || '',
|
||||
smtpFromName: response.smtpFromName || getLocaleMessage('defaultFromName')
|
||||
smtpFromName: response.smtpFromName || '校园广播站'
|
||||
}
|
||||
|
||||
// 保存原始配置用于重置
|
||||
originalConfig.value = { ...config.value }
|
||||
} catch (error) {
|
||||
console.error('加载SMTP配置失败:', error)
|
||||
showNotification(getLocaleMessage('loadFailed'), 'error')
|
||||
showNotification('加载配置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +256,7 @@ const saveConfig = async () => {
|
||||
if (config.value.smtpEnabled) {
|
||||
// 验证必填字段
|
||||
if (!config.value.smtpHost || !config.value.smtpUsername || !config.value.smtpPassword) {
|
||||
showNotification(getLocaleMessage('incompleteConfig'), 'error')
|
||||
showNotification('请填写完整的SMTP配置信息', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -306,21 +269,17 @@ const saveConfig = async () => {
|
||||
})
|
||||
|
||||
originalConfig.value = { ...config.value }
|
||||
showNotification(getLocaleMessage('saveSuccess'), 'success')
|
||||
showNotification('SMTP配置保存成功', 'success')
|
||||
} catch (error) {
|
||||
console.error('保存SMTP配置失败:', error)
|
||||
showNotification(getLocalizedServerMessage(error.data?.message) || getLocaleMessage('saveFailed'), 'error')
|
||||
showNotification(error.data?.message || '保存配置失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
const resetConfig = () => {
|
||||
config.value = { ...originalConfig.value }
|
||||
testResult.value = null
|
||||
showNotification(getLocaleMessage('resetSuccess'), 'info')
|
||||
}
|
||||
|
||||
|
||||
const reloadSmtpConfig = async () => {
|
||||
reloading.value = true
|
||||
@@ -329,53 +288,24 @@ const reloadSmtpConfig = async () => {
|
||||
method: 'POST'
|
||||
})
|
||||
if (!response.success) {
|
||||
throw new Error(getLocalizedServerMessage(response.message) || getLocaleMessage('reloadFailed'))
|
||||
throw new Error(response.message || 'SMTP重载失败')
|
||||
}
|
||||
showNotification(getLocalizedServerMessage(response.message) || getLocaleMessage('reloadSuccess'), 'success')
|
||||
showNotification(response.message || 'SMTP配置已重载', 'success')
|
||||
} catch (error) {
|
||||
console.error('重载SMTP配置失败:', error)
|
||||
showNotification(
|
||||
getLocalizedServerMessage(error.data?.message || error.message) || getLocaleMessage('reloadFailed'),
|
||||
'error'
|
||||
)
|
||||
showNotification(error.data?.message || error.message || '重载SMTP配置失败', 'error')
|
||||
} finally {
|
||||
reloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const testConnection = async () => {
|
||||
if (!config.value.smtpEnabled || !config.value.smtpHost) {
|
||||
showNotification(getLocaleMessage('serverRequired'), 'error')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/admin/smtp/test-connection', {
|
||||
method: 'POST',
|
||||
body: config.value
|
||||
})
|
||||
|
||||
testResult.value = localizeSmtpResult(response)
|
||||
} catch (error) {
|
||||
console.error('测试连接失败:', error)
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message:
|
||||
getLocalizedServerMessage(error.data?.message) || getLocaleMessage('testConnectionFailed')
|
||||
}
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 发送测试邮件
|
||||
const sendTestEmail = async () => {
|
||||
if (!testEmail.value) {
|
||||
showNotification(getLocaleMessage('testEmailRequired'), 'error')
|
||||
showNotification('请输入测试邮箱地址', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -391,9 +321,9 @@ const sendTestEmail = async () => {
|
||||
}
|
||||
})
|
||||
|
||||
testResult.value = localizeSmtpResult(response)
|
||||
testResult.value = response
|
||||
if (response.success) {
|
||||
showNotification(getLocaleMessage('testEmailSuccess'), 'success')
|
||||
showNotification('测试邮件发送成功', 'success')
|
||||
setTimeout(() => {
|
||||
testResult.value = null
|
||||
}, 5000)
|
||||
@@ -402,7 +332,7 @@ const sendTestEmail = async () => {
|
||||
console.error('发送测试邮件失败:', error)
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: getLocalizedServerMessage(error.data?.message) || getLocaleMessage('testEmailFailed')
|
||||
message: error.data?.message || '发送测试邮件失败'
|
||||
}
|
||||
} finally {
|
||||
testing.value = false
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
<template>
|
||||
<div class="space-y-10 max-w-[1600px] mx-auto pb-20">
|
||||
<div class="space-y-6">
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 px-1">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-3xl font-black text-zinc-100 tracking-tight">歌曲审核</h2>
|
||||
<div class="flex items-center gap-6 mt-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">待审核</span>
|
||||
<span class="text-sm font-bold text-amber-500">{{ pendingAuditSongs.length }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="flex items-center gap-2 px-5 py-2.5 bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 text-xs font-bold rounded-lg border border-blue-500/20 transition-all disabled:opacity-50"
|
||||
@click="refreshSongs(true)"
|
||||
>
|
||||
<RotateCcw :size="16" :class="{ 'animate-spin': loading }" /> 刷新
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲表格 -->
|
||||
<div class="bg-zinc-900/10 border border-zinc-800/40 rounded-xl overflow-hidden shadow-2xl relative">
|
||||
<div v-if="loading" class="absolute inset-0 bg-zinc-950/40 backdrop-blur-sm z-10 flex items-center justify-center">
|
||||
<div class="flex flex-col items-center gap-3">
|
||||
<div class="w-8 h-8 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
|
||||
<span class="text-xs font-bold text-zinc-400 tracking-widest uppercase">正在加载...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="hidden lg:grid grid-cols-12 gap-4 px-8 py-5 border-b border-zinc-800/60 bg-zinc-900/40">
|
||||
<div class="col-span-4 text-[10px] font-black text-zinc-600 uppercase tracking-widest">歌曲信息</div>
|
||||
<div class="col-span-2 text-[10px] font-black text-zinc-600 uppercase tracking-widest">投稿人</div>
|
||||
<div class="col-span-3 text-[10px] font-black text-zinc-600 uppercase tracking-widest">期望排期</div>
|
||||
<div class="col-span-3 text-[10px] font-black text-zinc-600 uppercase tracking-widest text-right pr-4">操作</div>
|
||||
</div>
|
||||
|
||||
<div v-if="pendingAuditSongs.length === 0 && !loading" class="py-20 flex flex-col items-center justify-center text-zinc-500">
|
||||
<Music :size="48" class="text-zinc-800 mb-4" />
|
||||
<p class="text-sm font-bold">暂无待审核歌曲</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="divide-y divide-zinc-800/40">
|
||||
<div
|
||||
v-for="song in pendingAuditSongs"
|
||||
:key="song.id"
|
||||
class="grid grid-cols-1 lg:grid-cols-12 gap-4 px-6 lg:px-8 py-5 transition-all items-center hover:bg-zinc-800/20 group relative"
|
||||
>
|
||||
<div class="col-span-12 lg:col-span-4 flex items-center gap-4">
|
||||
<div class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 border transition-all overflow-hidden bg-zinc-800/40 border-zinc-700/30 cursor-pointer hover:opacity-80" @click.stop="playSong(song)">
|
||||
<img v-if="song.cover" :src="convertToHttps(song.cover)" class="w-full h-full object-cover" referrerpolicy="no-referrer">
|
||||
<Music v-else :size="20" class="text-zinc-600" />
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h4 class="font-bold transition-colors flex items-center text-zinc-100 group-hover:text-blue-400">
|
||||
<span class="truncate">{{ song.title }}</span>
|
||||
<button v-if="song.hasSubmissionNote && song.submissionNote" class="ml-2 inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-all" title="查看备注留言" @click.stop="openSubmissionRemark(song)">
|
||||
<MessageSquare :size="12" />
|
||||
</button>
|
||||
</h4>
|
||||
<p class="text-xs text-zinc-500 font-medium truncate mt-0.5">{{ song.artist }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 lg:col-span-2 flex flex-col lg:items-start">
|
||||
<span class="text-xs font-bold text-zinc-400">{{ song.requester || '未知' }}</span>
|
||||
<span v-if="song.user" class="text-[10px] font-bold text-zinc-600">@{{ song.user.username }}</span>
|
||||
</div>
|
||||
|
||||
<div class="col-span-6 lg:col-span-3 flex flex-col">
|
||||
<span v-if="song.expectedPlayDate" class="text-xs font-bold text-amber-500">
|
||||
{{ song.expectedPlayDate }} 序号: {{ song.expectedPlaySequence }}
|
||||
</span>
|
||||
<span v-else class="text-xs font-bold text-zinc-500">未指定日期</span>
|
||||
</div>
|
||||
|
||||
<div class="col-span-12 lg:col-span-3 flex items-center justify-end gap-2">
|
||||
<button class="px-4 py-2 bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-500 hover:text-emerald-400 text-xs font-bold rounded-lg border border-emerald-500/20 transition-all" @click="auditSong(song.id, 'approved')">
|
||||
通过
|
||||
</button>
|
||||
<button class="px-4 py-2 bg-red-600/10 hover:bg-red-600/20 text-red-500 hover:text-red-400 text-xs font-bold rounded-lg border border-red-500/20 transition-all" @click="rejectSong(song.id)">
|
||||
未过审 (释放排期)
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 驳回歌曲对话框 -->
|
||||
<Transition enter-active-class="transition duration-300 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-200 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
|
||||
<div v-if="showRejectDialog" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
|
||||
<div class="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden" @click.stop>
|
||||
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100">未过审处理</h3>
|
||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showRejectDialog = false"><X :size="20" /></button>
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg">
|
||||
<p class="text-xs text-zinc-400 mb-2">此操作会将歌曲标记为未过审,并<span class="text-red-400 font-bold">立即释放其占用的排期序号</span>,供其他同学选择。歌曲不会被删除。</p>
|
||||
<textarea v-model="rejectReason" placeholder="请输入未过审原因 (可选),将通知投稿人..." class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-red-500/30 min-h-[100px] resize-none transition-all" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-6 bg-zinc-900/50 border-t border-zinc-800/50 flex gap-3 justify-end">
|
||||
<button class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300" @click="showRejectDialog = false">取消</button>
|
||||
<button :disabled="auditLoading" class="px-8 py-2.5 bg-red-600 hover:bg-red-500 text-white text-xs font-black rounded-lg transition-all disabled:opacity-50" @click="confirmReject">
|
||||
{{ auditLoading ? '处理中...' : '确认释放排期' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<SubmissionRemarkDialog :show="submissionRemarkDialog.show" :song-title="submissionRemarkDialog.songTitle" :content="submissionRemarkDialog.content" :is-public="submissionRemarkDialog.isPublic" @close="submissionRemarkDialog.show = false" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import SubmissionRemarkDialog from '~/components/Admin/SubmissionRemarkDialog.vue'
|
||||
import { RotateCcw, X, Music, MessageSquare } from '@lucide/vue'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import { useSongPlayer } from '~/composables/useSongPlayer'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
|
||||
const { showToast } = useToast()
|
||||
const { playSong } = useSongPlayer()
|
||||
const loading = ref(false)
|
||||
const auditLoading = ref(false)
|
||||
const songs = ref([])
|
||||
|
||||
const showRejectDialog = ref(false)
|
||||
const rejectReason = ref('')
|
||||
const currentAuditId = ref(null)
|
||||
const submissionRemarkDialog = ref({ show: false, songTitle: '', content: '', isPublic: false })
|
||||
|
||||
let songsService = null
|
||||
|
||||
const pendingAuditSongs = computed(() => {
|
||||
return songs.value.filter(s =>
|
||||
!s.played &&
|
||||
!s.scheduled &&
|
||||
(!s.submissionNote || (!s.submissionNote.includes('[审核未通过]') && !s.submissionNote.includes('[审核已通过]')))
|
||||
)
|
||||
})
|
||||
|
||||
const openSubmissionRemark = (song) => {
|
||||
if (!song?.submissionNote) return
|
||||
submissionRemarkDialog.value = {
|
||||
show: true,
|
||||
songTitle: `${song.title} - ${song.artist}`,
|
||||
content: song.submissionNote,
|
||||
isPublic: song.submissionNotePublic === true
|
||||
}
|
||||
}
|
||||
|
||||
const refreshSongs = async (bypassCache = false) => {
|
||||
loading.value = true
|
||||
try {
|
||||
await songsService.fetchSongs(false, undefined, true, bypassCache)
|
||||
songs.value = songsService.songs.value || []
|
||||
} catch (error) {
|
||||
console.error('刷新歌曲失败:', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const auditSong = async (id, status, reason = '') => {
|
||||
try {
|
||||
await $fetch('/api/admin/songs/audit', {
|
||||
method: 'POST',
|
||||
body: { songId: id, status, reason }
|
||||
})
|
||||
showToast(status === 'approved' ? '审核已通过' : '已驳回并释放排期', 'success')
|
||||
await refreshSongs(true)
|
||||
} catch (err) {
|
||||
showToast('操作失败: ' + (err.data?.message || err.message), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const rejectSong = (id) => {
|
||||
currentAuditId.value = id
|
||||
rejectReason.value = ''
|
||||
showRejectDialog.value = true
|
||||
}
|
||||
|
||||
const confirmReject = async () => {
|
||||
auditLoading.value = true
|
||||
await auditSong(currentAuditId.value, 'rejected', rejectReason.value)
|
||||
showRejectDialog.value = false
|
||||
auditLoading.value = false
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
songsService = useSongs()
|
||||
await refreshSongs()
|
||||
})
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -9,87 +9,38 @@
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
|
||||
@click="close"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-lg bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
|
||||
class="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-border-secondary-50 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-text-primary">{{ locale.title }}</h3>
|
||||
<button class="text-text-tertiary hover:text-text-secondary transition-colors" @click="close">
|
||||
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100">投稿备注留言</h3>
|
||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="close">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-xs text-text-tertiary font-medium">{{ songTitle }}</p>
|
||||
<p class="text-xs text-zinc-500 font-medium">{{ songTitle }}</p>
|
||||
<span
|
||||
v-if="noteStatus === 'pending'"
|
||||
class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border bg-warning-10 text-warning border-warning-20"
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border',
|
||||
isPublic
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
]"
|
||||
>
|
||||
{{ locale.statusPending }}
|
||||
{{ isPublic ? '公开备注' : '仅管理员可见' }}
|
||||
</span>
|
||||
<span
|
||||
v-else-if="noteStatus === 'rejected'"
|
||||
class="px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border bg-error-10 text-error border-error-20"
|
||||
>
|
||||
{{ locale.statusRejected }}
|
||||
</span>
|
||||
<label
|
||||
v-else
|
||||
for="is-public-checkbox"
|
||||
class="flex items-center gap-2 cursor-pointer group"
|
||||
:class="{ 'opacity-50': isUpdatingPublic }"
|
||||
>
|
||||
<input
|
||||
id="is-public-checkbox"
|
||||
type="checkbox"
|
||||
:checked="isPublic"
|
||||
:disabled="isUpdatingPublic"
|
||||
@change="$emit('update:isPublic', $event.target.checked)"
|
||||
class="w-4 h-4 rounded border-border-secondary bg-bg-primary cursor-pointer disabled:cursor-not-allowed"
|
||||
/>
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border transition-colors',
|
||||
isPublic
|
||||
? 'bg-primary-10 text-primary border-primary-20 group-hover:bg-primary-20'
|
||||
: 'bg-warning-10 text-warning border-warning-20 group-hover:bg-warning-20'
|
||||
]"
|
||||
>
|
||||
{{ isPublic ? locale.publicRemark : locale.adminOnly }}
|
||||
</span>
|
||||
<span v-if="isUpdatingPublic" class="text-[10px] text-text-tertiary animate-pulse">{{ locale.updating }}</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="bg-bg-primary-50 border border-border-secondary-50 rounded-xl p-4">
|
||||
<p class="text-sm text-text-primary leading-relaxed whitespace-pre-wrap">
|
||||
<div class="bg-zinc-950/50 border border-zinc-800/50 rounded-xl p-4">
|
||||
<p class="text-sm text-zinc-200 leading-relaxed whitespace-pre-wrap">
|
||||
{{ content }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 审核操作:待审可通过/拒绝;已拒绝可重新通过 -->
|
||||
<div v-if="noteStatus === 'pending' || noteStatus === 'rejected'" class="flex justify-end gap-2 pt-2">
|
||||
<button
|
||||
v-if="noteStatus === 'pending'"
|
||||
type="button"
|
||||
:disabled="isUpdatingPublic"
|
||||
class="px-4 py-2 rounded-lg bg-error-10 text-error border border-error-20 hover:bg-error-20 transition-colors text-xs font-black uppercase tracking-widest disabled:opacity-40"
|
||||
@click="$emit('reject')"
|
||||
>
|
||||
{{ locale.rejectButton }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="isUpdatingPublic"
|
||||
class="px-4 py-2 rounded-lg bg-primary text-white hover:opacity-90 transition-opacity text-xs font-black uppercase tracking-widest disabled:opacity-40"
|
||||
@click="$emit('approve')"
|
||||
>
|
||||
{{ locale.approveButton }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -97,9 +48,7 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
import { X } from '@lucide/vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
@@ -117,22 +66,12 @@ defineProps({
|
||||
isPublic: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
isUpdatingPublic: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
noteStatus: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'update:isPublic', 'approve', 'reject'])
|
||||
const { admin } = useLocale()
|
||||
const locale = computed(() => admin.value?.scheduleManager?.remarkDialog || {})
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
</script>
|
||||
+710
-1408
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,7 @@
|
||||
@click="handleOverlayClick"
|
||||
>
|
||||
<!-- 遮罩层 -->
|
||||
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<!-- 模态框面板 -->
|
||||
<transition
|
||||
@@ -26,58 +26,71 @@
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="relative w-full max-w-4xl max-h-[85vh] flex flex-col bg-bg-secondary border border-border-secondary rounded-xl shadow-2xl overflow-hidden"
|
||||
class="relative w-full max-w-4xl max-h-[85vh] flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-border-secondary bg-bg-secondary"
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50 backdrop-blur-sm"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-text-primary">{{ locale.title }}</h3>
|
||||
<h3 class="text-lg font-semibold text-white">用户歌曲信息</h3>
|
||||
<button
|
||||
class="p-2 text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary rounded-lg transition-colors"
|
||||
class="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 18 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-bg-secondary">
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-zinc-900">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center">
|
||||
<LoadingState :message="locale.loading" spinner-type="circle" />
|
||||
<LoadingState message="加载数据中..." spinner-type="circle" />
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex-1 flex flex-col items-center justify-center text-text-tertiary gap-4"
|
||||
class="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-4"
|
||||
>
|
||||
<div class="text-4xl">⚠️</div>
|
||||
<Icon class="text-4xl text-yellow-500" name="alert-triangle" size="48" />
|
||||
<p>{{ error }}</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-primary-hover hover:bg-primary text-text-primary rounded-lg transition-colors text-sm font-medium"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
@click="retryFetch"
|
||||
>
|
||||
{{ commonLocale.retry }}
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div v-else-if="userSongs" class="flex-1 flex flex-col min-h-0">
|
||||
<!-- 用户信息头部 -->
|
||||
<div class="px-6 py-4 bg-bg-secondary border-b border-border-secondary">
|
||||
<div class="px-6 py-4 bg-zinc-900 border-b border-zinc-800">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h4 class="text-base font-medium text-text-primary">{{ userSongs.user.name }}</h4>
|
||||
<span class="text-sm text-text-tertiary font-mono">{{
|
||||
<h4 class="text-base font-medium text-white">{{ userSongs.user.name }}</h4>
|
||||
<span class="text-sm text-zinc-500 font-mono">{{
|
||||
userSongs.user.username
|
||||
}}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="userSongs.user.grade || userSongs.user.class"
|
||||
class="text-sm text-text-tertiary"
|
||||
class="text-sm text-zinc-400"
|
||||
>
|
||||
{{ userSongs.user.grade || '' }} {{ userSongs.user.class || '' }}
|
||||
</p>
|
||||
@@ -86,7 +99,7 @@
|
||||
|
||||
<!-- 工具栏 (标签页 + 过滤) -->
|
||||
<div
|
||||
class="flex flex-col sm:flex-row items-center justify-between border-b border-border-secondary bg-bg-secondary-50"
|
||||
class="flex flex-col sm:flex-row items-center justify-between border-b border-zinc-800 bg-zinc-900/50"
|
||||
>
|
||||
<!-- 标签页 -->
|
||||
<div class="flex w-full sm:w-auto overflow-x-auto no-scrollbar">
|
||||
@@ -96,8 +109,8 @@
|
||||
class="relative px-6 py-3 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0"
|
||||
:class="
|
||||
activeTab === tab.id
|
||||
? 'text-primary'
|
||||
: 'text-text-tertiary hover:text-text-primary hover:bg-bg-tertiary-50'
|
||||
? 'text-blue-500'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
|
||||
"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
@@ -106,8 +119,8 @@
|
||||
class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full"
|
||||
:class="
|
||||
activeTab === tab.id
|
||||
? 'bg-primary-10 text-primary'
|
||||
: 'bg-bg-tertiary text-text-tertiary'
|
||||
? 'bg-blue-500/10 text-blue-500'
|
||||
: 'bg-zinc-800 text-zinc-500'
|
||||
"
|
||||
>
|
||||
{{ getTabCount(tab.id) }}
|
||||
@@ -115,20 +128,20 @@
|
||||
<!-- 激活指示器 -->
|
||||
<div
|
||||
v-if="activeTab === tab.id"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-primary"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 学期过滤器 -->
|
||||
<div
|
||||
class="w-full sm:w-auto px-4 py-2 border-t sm:border-t-0 sm:border-l border-border-secondary flex items-center gap-3 bg-bg-secondary-30"
|
||||
class="w-full sm:w-auto px-4 py-2 border-t sm:border-t-0 sm:border-l border-zinc-800 flex items-center gap-3 bg-zinc-900/30"
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="selectedSemester"
|
||||
:options="[{ label: locale.allSemesters, value: null }, ...semesterOptions]"
|
||||
:label="locale.semesterFilter"
|
||||
:placeholder="locale.allSemesters"
|
||||
:options="[{ label: '全部学期', value: null }, ...semesterOptions]"
|
||||
label="学期过滤"
|
||||
placeholder="全部学期"
|
||||
class-name="w-full sm:w-48"
|
||||
/>
|
||||
</div>
|
||||
@@ -138,38 +151,38 @@
|
||||
<div class="flex-1 overflow-y-auto p-4 sm:p-6 space-y-3 custom-scrollbar">
|
||||
<div
|
||||
v-if="filteredSongs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 text-text-tertiary"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-500"
|
||||
>
|
||||
<div class="text-5xl mb-4 opacity-50">{{ activeTabIcon }}</div>
|
||||
<p>{{ locale.empty }}</p>
|
||||
<Icon class="text-5xl mb-4 opacity-50" :name="activeTabIcon" size="48" />
|
||||
<p>没有找到相关记录</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="song in filteredSongs"
|
||||
:key="song.id"
|
||||
class="group flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-lg border border-border-secondary bg-bg-tertiary-20 hover:bg-bg-tertiary-50 hover:border-border-tertiary transition-all duration-200"
|
||||
class="group flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-lg border border-zinc-800 bg-zinc-800/20 hover:bg-zinc-800/50 hover:border-zinc-700 transition-all duration-200"
|
||||
>
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h5 class="text-sm font-medium text-text-primary truncate">{{ song.title }}</h5>
|
||||
<h5 class="text-sm font-medium text-white truncate">{{ song.title }}</h5>
|
||||
<span
|
||||
v-if="song.semester"
|
||||
class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-bg-tertiary text-text-tertiary border border-border-tertiary"
|
||||
class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-zinc-800 text-zinc-400 border border-zinc-700"
|
||||
>
|
||||
{{ song.semester }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-text-tertiary truncate">{{ song.artist }}</div>
|
||||
<div class="text-sm text-zinc-400 truncate">{{ song.artist }}</div>
|
||||
|
||||
<!-- Meta Info (Mobile) -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 mt-3 sm:hidden text-xs text-text-tertiary"
|
||||
class="flex flex-wrap items-center gap-3 mt-3 sm:hidden text-xs text-zinc-500"
|
||||
>
|
||||
<span>{{ getMetaTime(song) }}</span>
|
||||
<span v-if="song.voteCount !== undefined">{{ formatLocale(locale.votes, song.voteCount) }}</span>
|
||||
<span v-if="song.voteCount !== undefined">{{ song.voteCount }} 票</span>
|
||||
<span v-if="song.requestCount !== undefined"
|
||||
>{{ formatLocale(locale.requests, song.requestCount) }}</span
|
||||
>{{ song.requestCount }} 人申请</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
@@ -188,21 +201,21 @@
|
||||
|
||||
<!-- Meta Info (Desktop) -->
|
||||
<div class="hidden sm:flex flex-col items-end gap-0.5 min-w-[100px]">
|
||||
<span class="text-xs text-text-tertiary">{{ getMetaTime(song) }}</span>
|
||||
<span class="text-xs text-zinc-400">{{ getMetaTime(song) }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="song.voteCount !== undefined" class="text-xs text-text-tertiary"
|
||||
>{{ formatLocale(locale.votes, song.voteCount) }}</span
|
||||
<span v-if="song.voteCount !== undefined" class="text-xs text-zinc-500"
|
||||
>{{ song.voteCount }} 票</span
|
||||
>
|
||||
<span v-if="song.requestCount !== undefined" class="text-xs text-text-tertiary"
|
||||
>{{ formatLocale(locale.requests, song.requestCount) }}</span
|
||||
<span v-if="song.requestCount !== undefined" class="text-xs text-zinc-500"
|
||||
>{{ song.requestCount }} 人申请</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submitter Info (if available) -->
|
||||
<div v-if="song.requester" class="hidden sm:block text-right min-w-[80px]">
|
||||
<div class="text-xs text-text-secondary">{{ song.requester.name }}</div>
|
||||
<div class="text-[10px] text-text-tertiary">
|
||||
<div class="text-xs text-zinc-300">{{ song.requester.name }}</div>
|
||||
<div class="text-[10px] text-zinc-500">
|
||||
{{ song.requester.grade }}{{ song.requester.class }}
|
||||
</div>
|
||||
</div>
|
||||
@@ -211,12 +224,12 @@
|
||||
<!-- 投稿人信息 (移动端) -->
|
||||
<div
|
||||
v-if="song.requester"
|
||||
class="sm:hidden pt-3 mt-1 border-t border-border-secondary-50 flex justify-between items-center text-xs"
|
||||
class="sm:hidden pt-3 mt-1 border-t border-zinc-800/50 flex justify-between items-center text-xs"
|
||||
>
|
||||
<span class="text-text-tertiary">{{ locale.requester }}</span>
|
||||
<span class="text-text-secondary">
|
||||
<span class="text-zinc-500">投稿人:</span>
|
||||
<span class="text-zinc-300">
|
||||
{{ song.requester.name }}
|
||||
<span class="text-text-tertiary ml-1">
|
||||
<span class="text-zinc-500 ml-1">
|
||||
{{ song.requester.grade }}{{ song.requester.class }}
|
||||
</span>
|
||||
</span>
|
||||
@@ -235,9 +248,7 @@
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useSemesters } from '~/composables/useSemesters'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import LoadingState from '~/components/UI/Common/LoadingState.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
@@ -261,16 +272,6 @@ const selectedSemester = ref(null)
|
||||
|
||||
// Composables
|
||||
const { semesters, fetchSemesterOptions } = useSemesters()
|
||||
const { common, currentLocale } = useLocale()
|
||||
const commonLocale = computed(() => common.value || {})
|
||||
const locale = computed(() => {
|
||||
const base = common.value?.userSongsModal || {}
|
||||
return {
|
||||
...base,
|
||||
tabs: { ...(base.tabs || {}) },
|
||||
statuses: { ...(base.statuses || {}) }
|
||||
}
|
||||
})
|
||||
|
||||
// Computed
|
||||
const semesterOptions = computed(() => {
|
||||
@@ -280,22 +281,22 @@ const semesterOptions = computed(() => {
|
||||
}))
|
||||
})
|
||||
|
||||
const tabs = computed(() => [
|
||||
{ id: 'submitted', label: locale.value.tabs.submitted },
|
||||
{ id: 'voted', label: locale.value.tabs.voted },
|
||||
{ id: 'replay', label: locale.value.tabs.replay }
|
||||
])
|
||||
const tabs = [
|
||||
{ id: 'submitted', label: '投稿歌曲' },
|
||||
{ id: 'voted', label: '投票歌曲' },
|
||||
{ id: 'replay', label: '重播申请' }
|
||||
]
|
||||
|
||||
const activeTabIcon = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'submitted':
|
||||
return '🎵'
|
||||
return 'music'
|
||||
case 'voted':
|
||||
return '❤️'
|
||||
return 'heart'
|
||||
case 'replay':
|
||||
return '🔁'
|
||||
return 'repeat'
|
||||
default:
|
||||
return '📄'
|
||||
return 'file-text'
|
||||
}
|
||||
})
|
||||
|
||||
@@ -370,7 +371,7 @@ const fetchUserSongs = async () => {
|
||||
userSongs.value = songsResponse
|
||||
} catch (err) {
|
||||
console.error('获取用户歌曲信息失败:', err)
|
||||
error.value = err.data?.message || locale.value.fetchFailed
|
||||
error.value = err.data?.message || '获取用户歌曲信息失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -385,25 +386,18 @@ const handleOverlayClick = () => {
|
||||
}
|
||||
|
||||
// Helpers
|
||||
const formatTimeAgo = (key, value) => {
|
||||
const message = commonLocale.value?.time?.[key]
|
||||
if (typeof message === 'function') return message(value)
|
||||
if (typeof message === 'string') return message.replace(/{(\d+)}/g, (match, index) => index === '0' ? String(value) : match)
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
const now = getSyncedDate()
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return commonLocale.value?.time?.justNow || ''
|
||||
if (diff < 3600000) return formatTimeAgo('minutesAgo', Math.floor(diff / 60000))
|
||||
if (diff < 86400000) return formatTimeAgo('hoursAgo', Math.floor(diff / 3600000))
|
||||
if (diff < 86400000 * 7) return formatTimeAgo('daysAgo', Math.floor(diff / 86400000))
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
if (diff < 86400000 * 7) return `${Math.floor(diff / 86400000)}天前`
|
||||
|
||||
return date.toLocaleDateString(currentLocale.value, {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
@@ -412,25 +406,25 @@ const formatDate = (dateString) => {
|
||||
|
||||
const getMetaTime = (song) => {
|
||||
if (activeTab.value === 'submitted') return formatDate(song.createdAt)
|
||||
if (activeTab.value === 'voted') return `${formatDate(song.votedAt)} ${locale.value.voted}`
|
||||
if (activeTab.value === 'replay') return `${formatDate(song.requestedAt)} ${locale.value.requested}`
|
||||
if (activeTab.value === 'voted') return `${formatDate(song.votedAt)} 投票`
|
||||
if (activeTab.value === 'replay') return `${formatDate(song.requestedAt)} 申请`
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStatusText = (song) => {
|
||||
if (song.played) return locale.value.statuses.played
|
||||
if (song.scheduled) return locale.value.statuses.scheduled
|
||||
return locale.value.statuses.pending
|
||||
if (song.played) return '已播放'
|
||||
if (song.scheduled) return '已排期'
|
||||
return '待排期'
|
||||
}
|
||||
|
||||
const getStatusClasses = (song) => {
|
||||
if (song.played) {
|
||||
return 'bg-success-10 text-success border-success-20'
|
||||
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
}
|
||||
if (song.scheduled) {
|
||||
return 'bg-primary-10 text-primary border-primary-20'
|
||||
return 'bg-blue-500/10 text-blue-500 border-blue-500/20'
|
||||
}
|
||||
return 'bg-warning-10 text-warning border-warning-20'
|
||||
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}
|
||||
|
||||
// 监听器
|
||||
@@ -470,12 +464,12 @@ watch(
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--usersongs-scroll-thumb);
|
||||
background-color: rgba(113, 113, 122, 0.3); /* zinc-500/30 */
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--usersongs-scroll-thumb-hover);
|
||||
background-color: rgba(113, 113, 122, 0.5);
|
||||
}
|
||||
|
||||
/* 标签页隐藏滚动条 */
|
||||
|
||||
@@ -1,175 +1,101 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[90] flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
|
||||
@click.self="closeModal"
|
||||
>
|
||||
<div
|
||||
class="bg-bg-secondary border border-border-secondary w-full max-w-xl rounded-2xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh]"
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div
|
||||
class="p-6 pb-4 flex items-center justify-between border-b border-border-secondary-50"
|
||||
>
|
||||
<div class="min-w-0">
|
||||
<h3
|
||||
class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-primary-hover-10 flex items-center justify-center text-primary shrink-0"
|
||||
>
|
||||
<Icon name="users" :size="20" />
|
||||
</div>
|
||||
{{ locale.title }}
|
||||
</h3>
|
||||
<p v-if="songInfo" class="text-xs text-text-tertiary mt-1 ml-13 truncate">
|
||||
{{ songInfo.title }}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary rounded-xl transition-all shrink-0"
|
||||
@click="closeModal"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="show" class="modal-overlay" @click="closeModal">
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">投票人员列表</h3>
|
||||
<button class="close-btn" @click="closeModal">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<line x1="18" x2="6" y1="6" y2="18" />
|
||||
<line x1="6" x2="18" y1="6" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主体 -->
|
||||
<div class="flex-1 overflow-y-auto p-6 custom-scrollbar">
|
||||
<!-- 歌曲信息 -->
|
||||
<div
|
||||
v-if="songInfo"
|
||||
class="mb-5 p-5 bg-bg-primary-50 border border-border-secondary-50 rounded-xl"
|
||||
>
|
||||
<div class="flex items-start gap-4">
|
||||
<div
|
||||
class="w-11 h-11 rounded-xl bg-error-10 flex items-center justify-center text-error shrink-0"
|
||||
>
|
||||
<Icon name="music" :size="20" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-base font-black text-text-primary truncate">
|
||||
{{ songInfo.title }}
|
||||
</h4>
|
||||
<p class="text-xs text-text-tertiary mt-1 truncate">{{ songInfo.artist }}</p>
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 px-3 py-1.5 bg-error-10 border border-error-20 rounded-full text-error shrink-0"
|
||||
>
|
||||
<Icon name="heart" :size="12" class="fill-current" />
|
||||
<span class="text-xs font-black">{{
|
||||
formatLocale(locale.votes, totalVotes)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<LoadingState v-if="loading" :message="locale.loading" spinner-type="circle" />
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex flex-col items-center justify-center py-16 text-center px-8"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-error-10 flex items-center justify-center text-error mb-4"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm text-text-tertiary mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-6 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="fetchVoters"
|
||||
>
|
||||
{{ commonLocale.retry }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 投票人员列表 -->
|
||||
<div v-else-if="voters.length > 0">
|
||||
<div class="flex items-center justify-between mb-3 px-1">
|
||||
<div
|
||||
class="flex items-center gap-2 text-[10px] font-black text-text-tertiary uppercase tracking-widest"
|
||||
>
|
||||
<Icon name="users" :size="12" class="text-primary" />
|
||||
{{ formatLocale(locale.voters, voters.length) }}
|
||||
</div>
|
||||
<div class="text-[10px] font-black text-text-disabled uppercase tracking-widest">
|
||||
{{ formatLocale(locale.votes, totalVotes) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2 max-h-[45vh] overflow-y-auto custom-scrollbar pr-1">
|
||||
<div
|
||||
v-for="(voter, index) in voters"
|
||||
:key="voter.id"
|
||||
class="flex items-center gap-4 p-3 rounded-xl border border-transparent hover:bg-bg-tertiary-50 hover:border-border-secondary transition-all"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-bg-tertiary border border-border-secondary flex items-center justify-center text-text-secondary text-sm font-black shrink-0"
|
||||
>
|
||||
{{ getAvatarText(voter.name) }}
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="text-sm font-black text-text-primary truncate">
|
||||
{{ voter.name }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-[10px] text-text-disabled mt-0.5 font-medium"
|
||||
>
|
||||
<Icon name="clock" :size="11" />
|
||||
{{ formatVoteTime(voter.votedAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] font-black text-text-disabled px-2 py-1 bg-bg-tertiary rounded-lg shrink-0"
|
||||
>
|
||||
#{{ index + 1 }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无投票状态 -->
|
||||
<div v-else class="flex flex-col items-center justify-center py-16 text-text-disabled">
|
||||
<Icon name="heart" :size="48" class="mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold">{{ locale.empty }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部 -->
|
||||
<div
|
||||
class="p-6 border-t border-border-secondary-50 bg-bg-secondary-50 flex items-center justify-end"
|
||||
>
|
||||
<button
|
||||
class="px-6 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="closeModal"
|
||||
>
|
||||
{{ commonLocale.close }}
|
||||
</button>
|
||||
<div class="modal-body">
|
||||
<!-- 歌曲信息 -->
|
||||
<div v-if="songInfo" class="song-info">
|
||||
<h4 class="song-title">{{ songInfo.title }}</h4>
|
||||
<p class="song-artist">{{ songInfo.artist }}</p>
|
||||
<div class="vote-summary">
|
||||
<svg class="heart-icon" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="vote-count">{{ totalVotes }} 票</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<div class="spinner" />
|
||||
<p>正在加载投票人员...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<svg
|
||||
class="error-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" x2="9" y1="9" y2="15" />
|
||||
<line x1="9" x2="15" y1="9" y2="15" />
|
||||
</svg>
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<button class="retry-btn" @click="fetchVoters">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- 投票人员列表 -->
|
||||
<div v-else-if="voters.length > 0" class="voters-list">
|
||||
<div class="voters-header">
|
||||
<span class="voters-title">投票人员 ({{ voters.length }})</span>
|
||||
</div>
|
||||
<div class="voters-container">
|
||||
<div v-for="(voter, index) in voters" :key="voter.id" class="voter-item">
|
||||
<div class="voter-info">
|
||||
<div class="voter-avatar">
|
||||
{{ getAvatarText(voter.name) }}
|
||||
</div>
|
||||
<div class="voter-details">
|
||||
<span class="voter-name">{{ voter.name }}</span>
|
||||
<span class="vote-time">{{ formatVoteTime(voter.votedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="voter-number">#{{ index + 1 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无投票状态 -->
|
||||
<div v-else class="empty-state">
|
||||
<svg
|
||||
class="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
|
||||
/>
|
||||
</svg>
|
||||
<p>暂无投票</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="close-button" @click="closeModal">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import LoadingState from '~/components/UI/Common/LoadingState.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useServerErrors } from '~/composables/useLocaleText'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
@@ -192,10 +118,6 @@ const error = ref('')
|
||||
const songInfo = ref(null)
|
||||
const voters = ref([])
|
||||
const totalVotes = ref(0)
|
||||
const { common, currentLocale } = useLocale()
|
||||
const commonLocale = computed(() => common.value || {})
|
||||
const locale = computed(() => common.value?.votersModal || {})
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
|
||||
// 方法
|
||||
const closeModal = () => {
|
||||
@@ -216,7 +138,7 @@ const fetchVoters = async () => {
|
||||
totalVotes.value = response.totalVotes || 0
|
||||
} catch (err) {
|
||||
console.error('获取投票人员失败:', err)
|
||||
error.value = localizeServerError(err, locale.value.fetchFailed)
|
||||
error.value = err.data?.message || '获取投票人员失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -229,25 +151,17 @@ const getAvatarText = (name) => {
|
||||
return cleanName.slice(-1).toUpperCase()
|
||||
}
|
||||
|
||||
const formatTimeAgo = (key, value) => {
|
||||
const message = commonLocale.value?.time?.[key]
|
||||
if (typeof message === 'function') return message(value)
|
||||
if (typeof message === 'string')
|
||||
return message.replace(/{(\d+)}/g, (match, index) => (index === '0' ? String(value) : match))
|
||||
return ''
|
||||
}
|
||||
|
||||
const formatVoteTime = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const now = getSyncedDate()
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return commonLocale.value?.time?.justNow || ''
|
||||
if (diff < 3600000) return formatTimeAgo('minutesAgo', Math.floor(diff / 60000))
|
||||
if (diff < 86400000) return formatTimeAgo('hoursAgo', Math.floor(diff / 3600000))
|
||||
if (diff < 604800000) return formatTimeAgo('daysAgo', Math.floor(diff / 86400000))
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`
|
||||
|
||||
return date.toLocaleDateString(currentLocale.value, {
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
@@ -273,21 +187,347 @@ watch(
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 列表自定义滚动条 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--surface-primary, var(--bg-primary, #1a1a1a));
|
||||
border-radius: 16px;
|
||||
border: 1px solid var(--border-primary, #2a2a2a);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #ffffff);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-tertiary, #888888);
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
color: var(--text-primary, #ffffff);
|
||||
}
|
||||
|
||||
.close-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid var(--border-secondary, #3a3a3a);
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #ffffff);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
color: var(--text-tertiary, #888888);
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vote-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.heart-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.vote-count {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--border-secondary, #3a3a3a);
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
|
||||
.voters-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.voters-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid var(--border-secondary, #3a3a3a);
|
||||
}
|
||||
|
||||
.voters-title {
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #ffffff);
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.voters-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.voter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-secondary, #3a3a3a);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.voter-item:hover {
|
||||
background: var(--bg-quaternary, #333333);
|
||||
border-color: var(--border-secondary, #4a4a4a);
|
||||
}
|
||||
|
||||
.voter-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.voter-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.voter-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.voter-name {
|
||||
color: var(--text-primary, #ffffff);
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vote-time {
|
||||
color: var(--text-tertiary, #888888);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.voter-number {
|
||||
color: var(--text-tertiary, #888888);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--text-quaternary, #666666);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: var(--text-tertiary, #888888);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 0 24px 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: var(--bg-quaternary, #3a3a3a);
|
||||
color: var(--text-primary, #ffffff);
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.voters-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--text-muted);
|
||||
.voters-container::-webkit-scrollbar-track {
|
||||
background: var(--bg-tertiary, #2a2a2a);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--text-muted);
|
||||
.voters-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.voters-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-body,
|
||||
.modal-footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.voter-item {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.voter-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="space-y-6 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
|
||||
<div class="flex flex-col gap-2">
|
||||
<h2 class="text-2xl md:text-3xl font-bold tracking-tight text-zinc-100">工作流与使用教程</h2>
|
||||
<p class="text-zinc-400">了解校园广播站点歌系统的标准操作流程和功能说明,帮助您更高效地管理点歌与排期。</p>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<!-- 歌曲管理流程 -->
|
||||
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
|
||||
<div class="flex items-center gap-3 mb-4 text-blue-400">
|
||||
<CheckCircle2 class="w-6 h-6" />
|
||||
<h3 class="text-xl font-semibold">1. 歌曲管理与审核</h3>
|
||||
</div>
|
||||
<ol class="space-y-4 text-zinc-300">
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center text-sm font-bold">1</span>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100">查看所有歌曲</p>
|
||||
<p class="text-sm text-zinc-500 mt-1">进入“歌曲管理”页面,系统会列出所有用户提交的歌曲。</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center text-sm font-bold">2</span>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100">试听与检查</p>
|
||||
<p class="text-sm text-zinc-500 mt-1">点击播放按钮试听歌曲质量,检查留言内容是否合规,确保没有不当言论。</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center text-sm font-bold">3</span>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100">拒绝或修改状态</p>
|
||||
<p class="text-sm text-zinc-500 mt-1">如不符合要求,可以在歌曲管理中拒绝该歌曲,并填写退回原因;审核通过的歌曲可直接用于排期。</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- 排期流程 -->
|
||||
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
|
||||
<div class="flex items-center gap-3 mb-4 text-emerald-400">
|
||||
<CalendarDays class="w-6 h-6" />
|
||||
<h3 class="text-xl font-semibold">2. 节目排期流程</h3>
|
||||
</div>
|
||||
<ol class="space-y-4 text-zinc-300">
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-sm font-bold">1</span>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100">选择日期与时段</p>
|
||||
<p class="text-sm text-zinc-500 mt-1">在“排期管理”中,选择目标播放日期和播出时段。</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-sm font-bold">2</span>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100">拖拽编排</p>
|
||||
<p class="text-sm text-zinc-500 mt-1">将左侧“待排歌曲”拖拽至右侧的“播放列表”中,可上下拖拽调整播放顺序。</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-sm font-bold">3</span>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100">发布排期</p>
|
||||
<p class="text-sm text-zinc-500 mt-1">排期完成后,点击“发布排期”。发布后用户即可在前端页面看到该日期的节目单。</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- 播音流程 -->
|
||||
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
|
||||
<div class="flex items-center gap-3 mb-4 text-purple-400">
|
||||
<Mic2 class="w-6 h-6" />
|
||||
<h3 class="text-xl font-semibold">3. 播音与打印</h3>
|
||||
</div>
|
||||
<ol class="space-y-4 text-zinc-300">
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 flex items-center justify-center text-sm font-bold">1</span>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100">生成歌单图片/打印</p>
|
||||
<p class="text-sm text-zinc-500 mt-1">播音前,前往“图片歌单”生成今日节目单图片用于宣传,或在“打印排期”中生成播音员用的纸质稿。</p>
|
||||
</div>
|
||||
</li>
|
||||
<li class="flex gap-3">
|
||||
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 flex items-center justify-center text-sm font-bold">2</span>
|
||||
<div>
|
||||
<p class="font-medium text-zinc-100">标记已播</p>
|
||||
<p class="text-sm text-zinc-500 mt-1">播音结束后,系统可自动/手动将排期内的歌曲标记为“已播”,方便统计数据。</p>
|
||||
</div>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<!-- 常见问题 -->
|
||||
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
|
||||
<div class="flex items-center gap-3 mb-4 text-amber-400">
|
||||
<HelpCircle class="w-6 h-6" />
|
||||
<h3 class="text-xl font-semibold">常见问题 (FAQ)</h3>
|
||||
</div>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-zinc-200">Q: 为什么找不到某些待排歌曲?</h4>
|
||||
<p class="text-sm text-zinc-500 mt-1">A: 请检查是否在排期页面的左侧选错了标签(如普通投稿/重播申请),或者该歌曲还未通过审核。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-zinc-200">Q: 什么是选曲日期?</h4>
|
||||
<p class="text-sm text-zinc-500 mt-1">A: 选曲日期是用户在点歌时可选择的期望播放日期。请在“选曲日期”设置中提前开放可用的日期。</p>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="text-sm font-medium text-zinc-200">Q: 权限不足怎么办?</h4>
|
||||
<p class="text-sm text-zinc-500 mt-1">A: 不同的功能需要不同的管理员角色(如歌曲管理员、超级管理员),请联系超级管理员调整您的角色权限。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { CheckCircle2, CalendarDays, Mic2, HelpCircle } from '@lucide/vue'
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 可以在此添加特定样式 */
|
||||
</style>
|
||||
@@ -4,12 +4,12 @@
|
||||
<div v-if="!isFirstLogin" class="space-y-2">
|
||||
<label
|
||||
for="current-password"
|
||||
class="text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
|
||||
>{{ locale.currentPassword }}</label
|
||||
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>当前密码</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary group-focus-within:text-primary transition-colors"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<Lock :size="18" />
|
||||
</div>
|
||||
@@ -19,21 +19,17 @@
|
||||
:class="[
|
||||
inputClass,
|
||||
error
|
||||
? 'border-error shadow-[0_0_15px_var(--auth-error-input-shadow)]'
|
||||
: 'border-border-secondary focus:border-primary-30'
|
||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
||||
: 'border-zinc-800 focus:border-blue-500/30'
|
||||
]"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
autocomplete="current-password"
|
||||
:placeholder="locale.currentPasswordPlaceholder"
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
/>
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
:aria-label="
|
||||
showCurrentPassword ? locale.hideCurrentPassword : locale.showCurrentPassword
|
||||
"
|
||||
@click="showCurrentPassword = !showCurrentPassword"
|
||||
>
|
||||
<Eye v-if="!showCurrentPassword" :size="18" />
|
||||
@@ -45,12 +41,12 @@
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="new-password"
|
||||
class="text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
|
||||
>{{ locale.newPassword }}</label
|
||||
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>新密码</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary group-focus-within:text-primary transition-colors"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<KeyRound :size="18" />
|
||||
</div>
|
||||
@@ -60,19 +56,20 @@
|
||||
:class="[
|
||||
inputClass,
|
||||
error
|
||||
? 'border-error shadow-[0_0_15px_var(--auth-error-input-shadow)]'
|
||||
: 'border-border-secondary focus:border-primary-30'
|
||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
||||
: 'border-zinc-800 focus:border-blue-500/30'
|
||||
]"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:placeholder="locale.newPasswordPlaceholder"
|
||||
placeholder="请输入新密码"
|
||||
required
|
||||
@input="handleNewPasswordInput"
|
||||
/>
|
||||
@input="
|
||||
error = '';
|
||||
validatePassword()
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
:aria-label="showNewPassword ? locale.hideNewPassword : locale.showNewPassword"
|
||||
@click="showNewPassword = !showNewPassword"
|
||||
>
|
||||
<Eye v-if="!showNewPassword" :size="18" />
|
||||
@@ -82,7 +79,7 @@
|
||||
|
||||
<!-- 密码强度指示器 -->
|
||||
<div v-if="newPassword" class="px-1 pt-1 space-y-2">
|
||||
<div class="h-1 w-full bg-bg-tertiary rounded-full overflow-hidden">
|
||||
<div class="h-1 w-full bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-500"
|
||||
:class="passwordStrength.colorClass"
|
||||
@@ -90,8 +87,8 @@
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-text-tertiary"
|
||||
>{{ locale.passwordStrength }}</span
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-zinc-500"
|
||||
>密码强度</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-black uppercase tracking-widest"
|
||||
@@ -106,12 +103,12 @@
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="confirm-password"
|
||||
class="text-xs font-black text-text-tertiary uppercase tracking-widest ml-1"
|
||||
>{{ locale.confirmPassword }}</label
|
||||
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>确认新密码</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-tertiary group-focus-within:text-primary transition-colors"
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<CheckCircle2 :size="18" />
|
||||
</div>
|
||||
@@ -121,21 +118,17 @@
|
||||
:class="[
|
||||
inputClass,
|
||||
error || (confirmPassword && newPassword !== confirmPassword)
|
||||
? 'border-error shadow-[0_0_15px_var(--auth-error-input-shadow)]'
|
||||
: 'border-border-secondary focus:border-primary-30'
|
||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
||||
: 'border-zinc-800 focus:border-blue-500/30'
|
||||
]"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
autocomplete="new-password"
|
||||
:placeholder="locale.confirmPasswordPlaceholder"
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
/>
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
:aria-label="
|
||||
showConfirmPassword ? locale.hideConfirmPassword : locale.showConfirmPassword
|
||||
"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<Eye v-if="!showConfirmPassword" :size="18" />
|
||||
@@ -147,14 +140,14 @@
|
||||
<div v-if="confirmPassword" class="px-1">
|
||||
<div
|
||||
v-if="newPassword !== confirmPassword"
|
||||
class="flex items-center gap-1.5 text-error"
|
||||
class="flex items-center gap-1.5 text-rose-500"
|
||||
>
|
||||
<XCircle :size="12" />
|
||||
<span class="text-[10px] font-bold">{{ locale.mismatch }}</span>
|
||||
<span class="text-[10px] font-bold">密码不匹配</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.5 text-success">
|
||||
<div v-else class="flex items-center gap-1.5 text-emerald-500">
|
||||
<CheckCircle2 :size="12" />
|
||||
<span class="text-[10px] font-bold">{{ locale.matched }}</span>
|
||||
<span class="text-[10px] font-bold">密码匹配</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -162,36 +155,34 @@
|
||||
<!-- 状态消息 -->
|
||||
<div
|
||||
v-if="error"
|
||||
aria-live="polite"
|
||||
class="p-3 bg-error-10 border border-error-20 rounded-xl flex items-center gap-3"
|
||||
class="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-center gap-3"
|
||||
>
|
||||
<AlertCircle :size="16" class="text-error shrink-0" />
|
||||
<span class="text-xs text-error font-medium">{{ error }}</span>
|
||||
<AlertCircle :size="16" class="text-rose-500 shrink-0" />
|
||||
<span class="text-xs text-rose-500 font-medium">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="success"
|
||||
aria-live="polite"
|
||||
class="p-3 bg-success-10 border border-success-20 rounded-xl flex items-center gap-3"
|
||||
class="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-3"
|
||||
>
|
||||
<CheckCircle2 :size="16" class="text-success shrink-0" />
|
||||
<span class="text-xs text-success font-medium">{{ success }}</span>
|
||||
<CheckCircle2 :size="16" class="text-emerald-500 shrink-0" />
|
||||
<span class="text-xs text-emerald-500 font-medium">{{ success }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="loading || !isFormValid"
|
||||
class="w-full flex items-center justify-center gap-2 py-3 bg-primary-hover hover:bg-primary text-text-primary text-sm font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
class="w-full flex items-center justify-center gap-2 py-3 bg-blue-600 hover:bg-blue-500 text-white text-sm font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
type="submit"
|
||||
>
|
||||
<Loader2 v-if="loading" :size="18" class="animate-spin" />
|
||||
<span>{{ loading ? locale.processing : isFirstLogin ? locale.setInitial : locale.submitChange }}</span>
|
||||
<span>{{ loading ? '处理中...' : isFirstLogin ? '设置初始密码' : '确认修改密码' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onBeforeUnmount, ref } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
Lock,
|
||||
KeyRound,
|
||||
@@ -202,9 +193,6 @@ import {
|
||||
AlertCircle,
|
||||
Loader2
|
||||
} from '@lucide/vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { usePasswordStrength } from '~/composables/usePasswordStrength'
|
||||
import { validateInitialPasswordPolicy, validatePasswordPolicy } from '~/utils/password-policy'
|
||||
|
||||
// 组件属性
|
||||
const props = defineProps({
|
||||
@@ -216,9 +204,6 @@ const props = defineProps({
|
||||
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const { auth: authLocale } = useLocale()
|
||||
const locale = computed(() => authLocale.value?.changePasswordForm || {})
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
@@ -226,20 +211,10 @@ const confirmPassword = ref('')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const loading = ref(false)
|
||||
let redirectTimer = null
|
||||
|
||||
const scheduleRedirect = (callback) => {
|
||||
if (redirectTimer) clearTimeout(redirectTimer)
|
||||
redirectTimer = setTimeout(callback, 2000)
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (redirectTimer) clearTimeout(redirectTimer)
|
||||
})
|
||||
|
||||
// 样式类
|
||||
const inputClass =
|
||||
'w-full bg-bg-primary border rounded-xl pl-11 pr-11 py-3 text-sm text-text-primary placeholder:text-text-disabled focus:outline-none transition-all'
|
||||
'w-full bg-zinc-950 border rounded-xl pl-11 pr-11 py-3 text-sm text-zinc-200 placeholder:text-zinc-600 focus:outline-none transition-all'
|
||||
|
||||
// 密码显示状态
|
||||
const showCurrentPassword = ref(false)
|
||||
@@ -247,43 +222,84 @@ const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
|
||||
// 密码强度计算
|
||||
const passwordStrength = usePasswordStrength(newPassword)
|
||||
const passwordStrength = computed(() => {
|
||||
const password = newPassword.value
|
||||
if (!password) return { width: '0%', colorClass: '', textColorClass: '', text: '' }
|
||||
|
||||
const passwordPolicyError = computed(() =>
|
||||
props.isFirstLogin
|
||||
? validateInitialPasswordPolicy(newPassword.value)
|
||||
: validatePasswordPolicy(newPassword.value)
|
||||
)
|
||||
let score = 0
|
||||
|
||||
if (password.length >= 8) score += 25
|
||||
if (/[A-Z]/.test(password)) score += 25
|
||||
if (/[a-z]/.test(password)) score += 25
|
||||
if (/[0-9]/.test(password) && /[^A-Za-z0-9]/.test(password)) score += 25
|
||||
|
||||
if (score < 50) {
|
||||
return {
|
||||
width: `${score || 10}%`,
|
||||
colorClass: 'bg-rose-500',
|
||||
textColorClass: 'text-rose-500',
|
||||
text: '弱'
|
||||
}
|
||||
} else if (score < 75) {
|
||||
return {
|
||||
width: `${score}%`,
|
||||
colorClass: 'bg-amber-500',
|
||||
textColorClass: 'text-amber-500',
|
||||
text: '中等'
|
||||
}
|
||||
} else if (score < 100) {
|
||||
return {
|
||||
width: `${score}%`,
|
||||
colorClass: 'bg-blue-500',
|
||||
textColorClass: 'text-blue-500',
|
||||
text: '强'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
width: '100%',
|
||||
colorClass: 'bg-emerald-500',
|
||||
textColorClass: 'text-emerald-500',
|
||||
text: '极强'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
return Boolean(
|
||||
(props.isFirstLogin || currentPassword.value) &&
|
||||
newPassword.value &&
|
||||
confirmPassword.value &&
|
||||
newPassword.value === confirmPassword.value &&
|
||||
!passwordPolicyError.value
|
||||
)
|
||||
if (props.isFirstLogin) {
|
||||
return (
|
||||
newPassword.value &&
|
||||
confirmPassword.value &&
|
||||
newPassword.value === confirmPassword.value &&
|
||||
newPassword.value.length >= 8
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
currentPassword.value &&
|
||||
newPassword.value &&
|
||||
confirmPassword.value &&
|
||||
newPassword.value === confirmPassword.value &&
|
||||
newPassword.value.length >= 8
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const handleNewPasswordInput = () => {
|
||||
error.value = newPassword.value ? passwordPolicyError.value || '' : ''
|
||||
}
|
||||
|
||||
const resetForm = () => {
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
const validatePassword = () => {
|
||||
if (newPassword.value && newPassword.value.length < 8) {
|
||||
error.value = '密码长度至少为8位'
|
||||
} else {
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = locale.value.newPasswordMismatch
|
||||
error.value = '新密码和确认密码不匹配'
|
||||
return
|
||||
}
|
||||
|
||||
if (passwordPolicyError.value) {
|
||||
error.value = passwordPolicyError.value
|
||||
if (newPassword.value.length < 8) {
|
||||
error.value = '新密码长度至少为8位'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -294,33 +310,52 @@ const handleChangePassword = async () => {
|
||||
try {
|
||||
if (props.isFirstLogin) {
|
||||
await auth.setInitialPassword(newPassword.value)
|
||||
success.value = locale.value.initialSuccess
|
||||
success.value = '密码设置成功!正在跳转...'
|
||||
|
||||
resetForm()
|
||||
// 清空表单
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
|
||||
// 密码设置完成后跳转
|
||||
scheduleRedirect(async () => {
|
||||
setTimeout(async () => {
|
||||
// 更新用户状态
|
||||
await auth.refreshUser()
|
||||
|
||||
if (auth.isAdmin.value) {
|
||||
router.replace('/dashboard')
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
router.replace('/')
|
||||
router.push('/')
|
||||
}
|
||||
})
|
||||
}, 2000)
|
||||
} else {
|
||||
await auth.changePassword(currentPassword.value, newPassword.value)
|
||||
success.value = locale.value.changeSuccess
|
||||
success.value = '密码修改成功!请重新登录'
|
||||
|
||||
resetForm()
|
||||
// 清空表单
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
|
||||
// 密码修改后登出
|
||||
scheduleRedirect(async () => {
|
||||
await auth.logout(false)
|
||||
await router.replace('/login')
|
||||
})
|
||||
setTimeout(() => {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
}
|
||||
} catch (err) {
|
||||
// 统一按错误码本地化服务端错误,未命中再回退到默认文案
|
||||
error.value = localizeServerError(err, locale.value.failed)
|
||||
// 提取错误信息,支持多种错误格式
|
||||
if (err.data && err.data.statusMessage) {
|
||||
error.value = err.data.statusMessage
|
||||
} else if (err.data && err.data.message) {
|
||||
error.value = err.data.message
|
||||
} else if (err.statusMessage) {
|
||||
error.value = err.statusMessage
|
||||
} else if (err.message) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = '操作失败,请重试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
+129
-1017
File diff suppressed because it is too large
Load Diff
@@ -1,172 +1,182 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 :size="24" class="text-primary animate-spin mb-3" />
|
||||
<p class="text-text-tertiary text-xs font-medium">{{ locale.loading }}</p>
|
||||
<Loader2 :size="24" class="text-blue-500 animate-spin mb-3" />
|
||||
<p class="text-zinc-500 text-xs font-medium">加载绑定状态...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div
|
||||
v-for="provider in enabledProviders"
|
||||
:key="provider.key"
|
||||
:class="[itemClass, 'items-start']"
|
||||
>
|
||||
<div class="flex items-start gap-4 flex-1 min-w-0">
|
||||
<!-- GitHub -->
|
||||
<div v-if="config.public.oauth.github" :class="itemClass">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-bg-primary flex items-center justify-center border border-border-secondary text-text-primary"
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<AuthProvidersGitHubIcon v-if="provider.key === 'github'" class="w-5 h-5" />
|
||||
<AuthProvidersCasdoorIcon v-else-if="provider.key === 'casdoor'" class="w-5 h-5" />
|
||||
<AuthProvidersGoogleIcon v-else-if="provider.key === 'google'" class="w-5 h-5" />
|
||||
<Icon
|
||||
v-else-if="provider.routeProvider === 'aggregate'"
|
||||
:name="getAggregateOAuthLoginTypeIcon(provider.loginType)"
|
||||
:size="23"
|
||||
:class="aggregateIconClass(provider.loginType)"
|
||||
/>
|
||||
<Shield v-else :size="20" />
|
||||
<AuthProvidersGitHubIcon class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-sm font-bold text-text-primary">{{
|
||||
provider.name || getProviderDisplayName(provider.key)
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-zinc-200">{{
|
||||
getProviderDisplayName('github')
|
||||
}}</span>
|
||||
<span
|
||||
v-if="getIdentitiesByProvider(provider).length === 0"
|
||||
class="text-[11px] text-text-tertiary mt-0.5"
|
||||
>{{ locale.unbound }}</span
|
||||
>
|
||||
<div
|
||||
v-if="getIdentitiesByProvider(provider).length > 0"
|
||||
class="flex flex-col gap-2 mt-2"
|
||||
>
|
||||
<div
|
||||
v-for="identity in getIdentitiesByProvider(provider)"
|
||||
:key="identity.id"
|
||||
class="flex items-center gap-3 p-2.5 bg-bg-primary-20 border border-border-secondary rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-bg-primary border border-border-secondary overflow-hidden flex items-center justify-center text-xs font-bold text-text-secondary shrink-0"
|
||||
>
|
||||
<img
|
||||
v-if="identity.avatar && !failedAvatarIds.includes(identity.id)"
|
||||
:src="identity.avatar"
|
||||
:alt="identity.providerUsername || provider.name"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
@error="markAvatarFailed(identity.id)"
|
||||
>
|
||||
<span v-else>{{
|
||||
(identity.providerUsername || provider.name || '?').charAt(0)
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex flex-col flex-1 min-w-0">
|
||||
<span class="text-xs font-medium text-text-secondary truncate">{{
|
||||
identity.providerUsername
|
||||
}}</span>
|
||||
<span
|
||||
v-if="identity.isAvatarSource"
|
||||
class="text-[10px] font-bold text-success bg-success-10 border border-success-20 px-2 py-0.5 rounded-full w-fit mt-1"
|
||||
>{{ locale.currentAvatar }}</span
|
||||
>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5 shrink-0">
|
||||
<button
|
||||
v-if="!identity.isAvatarSource"
|
||||
class="px-2.5 py-1.5 bg-primary-hover hover:bg-primary text-text-primary text-[11px] font-bold rounded-lg transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="avatarLoadingId === identity.id || actionLoading"
|
||||
@click="setAsAvatar(identity)"
|
||||
>
|
||||
{{ avatarLoadingId === identity.id ? locale.processing : locale.setAsAvatar }}
|
||||
</button>
|
||||
<button
|
||||
class="px-2.5 py-1.5 bg-error-10 border border-error-20 hover:bg-error-20 text-error text-[11px] font-bold rounded-lg transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="confirmUnbind(provider, identity)"
|
||||
>
|
||||
{{ locale.unbind }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<span v-if="githubIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
|
||||
githubIdentity.providerUsername
|
||||
}}</span>
|
||||
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="getIdentitiesByProvider(provider).length === 0"
|
||||
class="px-4 py-1.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95 disabled:opacity-50"
|
||||
v-if="githubIdentity"
|
||||
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind(provider)"
|
||||
@click="confirmUnbind('github')"
|
||||
>
|
||||
{{ actionLoading ? locale.redirecting : locale.bindNow }}
|
||||
{{ actionLoading ? '处理中...' : '解绑' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind('github')"
|
||||
>
|
||||
{{ actionLoading ? '跳转中...' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Casdoor (如果启用) -->
|
||||
<div v-if="config.public.oauth.casdoor" :class="itemClass">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<Shield :size="20" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-zinc-200">{{
|
||||
getProviderDisplayName('casdoor')
|
||||
}}</span>
|
||||
<span v-if="casdoorIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
|
||||
casdoorIdentity.providerUsername
|
||||
}}</span>
|
||||
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="casdoorIdentity"
|
||||
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="confirmUnbind('casdoor')"
|
||||
>
|
||||
{{ actionLoading ? '处理中...' : '解绑' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind('casdoor')"
|
||||
>
|
||||
{{ actionLoading ? '跳转中...' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Google (如果启用) -->
|
||||
<div v-if="config.public.oauth.google" :class="itemClass">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<AuthProvidersGoogleIcon class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-zinc-200">{{
|
||||
getProviderDisplayName('google')
|
||||
}}</span>
|
||||
<span v-if="googleIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
|
||||
googleIdentity.providerUsername
|
||||
}}</span>
|
||||
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="googleIdentity"
|
||||
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="confirmUnbind('google')"
|
||||
>
|
||||
{{ actionLoading ? '处理中...' : '解绑' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind('google')"
|
||||
>
|
||||
{{ actionLoading ? '跳转中...' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- WebAuthn / Passkey -->
|
||||
<div
|
||||
<div
|
||||
v-if="isWebAuthnSupported || webauthnIdentities.length > 0 || !isSecureContext"
|
||||
:class="[
|
||||
itemClass,
|
||||
webauthnIdentities.length > 0 ? 'cursor-pointer hover:bg-bg-secondary-70' : ''
|
||||
]"
|
||||
itemClass,
|
||||
webauthnIdentities.length > 0 ? 'cursor-pointer hover:bg-zinc-900/70' : ''
|
||||
]"
|
||||
@click="toggleWebAuthnList"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-bg-primary flex items-center justify-center border border-border-secondary text-text-primary"
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<Fingerprint :size="20" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-text-primary">Passkey</span>
|
||||
<ChevronDown
|
||||
<span class="text-sm font-bold text-zinc-200">Windows Hello / Passkey</span>
|
||||
<ChevronDown
|
||||
v-if="webauthnIdentities.length > 0"
|
||||
:size="14"
|
||||
class="text-text-tertiary transition-transform duration-300"
|
||||
:size="14"
|
||||
class="text-zinc-500 transition-transform duration-300"
|
||||
:class="{ 'rotate-180': isWebAuthnExpanded }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[11px] text-text-tertiary mt-0.5"
|
||||
>{{ locale.boundDevices }} {{ webauthnIdentities.length }} {{ locale.devices }}</span
|
||||
<span class="text-[11px] text-zinc-500 mt-0.5"
|
||||
>已绑定 {{ webauthnIdentities.length }} 个设备</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isWebAuthnSupported"
|
||||
class="px-4 py-1.5 bg-primary-hover hover:bg-primary text-text-primary text-xs font-black rounded-xl shadow-lg shadow-[var(--primary-glow)] transition-all active:scale-95 disabled:opacity-50"
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click.stop="handleWebAuthnRegister"
|
||||
>
|
||||
{{ actionLoading ? locale.processing : locale.addDevice }}
|
||||
{{ actionLoading ? '处理中...' : '添加设备' }}
|
||||
</button>
|
||||
<div
|
||||
v-else-if="!isSecureContext"
|
||||
class="flex items-center gap-1 text-warning bg-warning-10 px-3 py-1.5 rounded-lg border border-warning-20"
|
||||
>
|
||||
<div v-else-if="!isSecureContext" class="flex items-center gap-1 text-amber-500 bg-amber-500/10 px-3 py-1.5 rounded-lg border border-amber-500/20">
|
||||
<AlertTriangle :size="12" />
|
||||
<span class="text-[10px] font-medium">{{ locale.httpsRequired }}</span>
|
||||
<span class="text-[10px] font-medium">需要 HTTPS 环境</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebAuthn 设备列表 -->
|
||||
<Transition name="expand">
|
||||
<div
|
||||
v-if="isWebAuthnExpanded && webauthnIdentities.length > 0"
|
||||
class="pl-16 -mt-2 overflow-hidden"
|
||||
>
|
||||
<div v-if="isWebAuthnExpanded && webauthnIdentities.length > 0" class="pl-16 -mt-2 overflow-hidden">
|
||||
<div class="space-y-2 pt-2">
|
||||
<div
|
||||
v-for="cred in webauthnIdentities"
|
||||
:key="cred.id"
|
||||
class="flex items-center justify-between p-3 bg-bg-primary-20 border border-border-secondary rounded-xl group/item"
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/20 border border-zinc-900 rounded-xl group/item"
|
||||
>
|
||||
<div class="flex flex-col flex-1 mr-4">
|
||||
<div v-if="editingId === cred.id" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="editingName"
|
||||
type="text"
|
||||
class="bg-bg-secondary border border-border-tertiary rounded px-2 py-0.5 text-xs text-text-primary focus:outline-none focus:border-primary w-full"
|
||||
class="bg-zinc-900 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 w-full"
|
||||
:disabled="isRenaming"
|
||||
@keyup.enter="saveEditing(cred.id)"
|
||||
@keyup.esc="cancelEditing"
|
||||
@@ -175,18 +185,18 @@
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-xs font-medium text-text-secondary">{{ cred.providerUsername }}</span>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-text-secondary opacity-0 group-hover/item:opacity-100 transition-opacity p-0.5"
|
||||
<span class="text-xs font-medium text-zinc-300">{{ cred.providerUsername }}</span>
|
||||
<button
|
||||
class="text-zinc-500 hover:text-zinc-300 opacity-0 group-hover/item:opacity-100 transition-opacity p-0.5"
|
||||
@click.stop="startEditing(cred)"
|
||||
:title="locale.rename"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-[10px] text-text-disabled"
|
||||
>{{ locale.addedAt }} {{ new Date(cred.createdAt).toLocaleString(currentLocale.value, {
|
||||
|
||||
<span class="text-[10px] text-zinc-600"
|
||||
>添加于 {{ new Date(cred.createdAt).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
@@ -201,28 +211,28 @@
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="editingId === cred.id">
|
||||
<button
|
||||
class="text-text-tertiary hover:text-success transition-colors p-1"
|
||||
class="text-zinc-400 hover:text-green-400 transition-colors p-1"
|
||||
:disabled="isRenaming"
|
||||
@click.stop="saveEditing(cred.id)"
|
||||
:title="locale.save"
|
||||
title="保存"
|
||||
>
|
||||
<Check :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-text-primary transition-colors p-1"
|
||||
class="text-zinc-400 hover:text-zinc-200 transition-colors p-1"
|
||||
:disabled="isRenaming"
|
||||
@click.stop="cancelEditing"
|
||||
:title="locale.cancel"
|
||||
title="取消"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
class="text-xs text-error hover:text-error font-medium px-2 py-1 opacity-0 group-hover/item:opacity-100 transition-opacity"
|
||||
class="text-xs text-rose-500 hover:text-rose-400 font-medium px-2 py-1 opacity-0 group-hover/item:opacity-100 transition-opacity"
|
||||
@click="confirmUnbindWebAuthn(cred)"
|
||||
>
|
||||
{{ locale.remove }}
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -244,60 +254,42 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import {
|
||||
Loader2,
|
||||
Shield,
|
||||
Fingerprint,
|
||||
ChevronDown,
|
||||
Pencil,
|
||||
Check,
|
||||
X,
|
||||
AlertTriangle
|
||||
} from '@lucide/vue'
|
||||
import { Loader2, Shield, Fingerprint, ChevronDown, Pencil, Check, X, AlertTriangle } from '@lucide/vue'
|
||||
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { getAggregateOAuthLoginTypeIcon, getProviderDisplayName } from '~/utils/oauth'
|
||||
import { browserSupportsWebAuthn } from '@simplewebauthn/browser'
|
||||
import {
|
||||
getWebAuthnErrorMessage,
|
||||
signalUnknownWebAuthnCredential,
|
||||
startWebAuthnRegistration
|
||||
} from '~/utils/webauthn'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useServerErrors } from '~/composables/useLocaleText'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { getProviderDisplayName } from '~/utils/oauth'
|
||||
import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser'
|
||||
|
||||
const { oauthProviders, refreshSiteConfig } = useSiteConfig()
|
||||
const config = useRuntimeConfig()
|
||||
const { showToast } = useToast()
|
||||
const { refreshUser } = useAuth()
|
||||
const { localize: localizeServerError } = useServerErrors()
|
||||
const { auth, currentLocale } = useLocale()
|
||||
const locale = computed(() => auth.value?.oauthBindingCard || {})
|
||||
const { t: callLocale } = useLocaleText(locale)
|
||||
const identities = ref([])
|
||||
const loading = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const avatarLoadingId = ref(null)
|
||||
const failedAvatarIds = ref([])
|
||||
const isWebAuthnSupported = ref(false)
|
||||
const isSecureContext = ref(true)
|
||||
|
||||
// 编辑相关
|
||||
const editingId = ref(null)
|
||||
const editingId = ref<string | null>(null)
|
||||
const editingName = ref('')
|
||||
const isRenaming = ref(false)
|
||||
const editInput = ref(null)
|
||||
const editInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
const startEditing = async (cred) => {
|
||||
interface WebAuthnCredential {
|
||||
id: string
|
||||
providerUsername: string
|
||||
createdAt: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const startEditing = async (cred: WebAuthnCredential) => {
|
||||
editingId.value = cred.id
|
||||
editingName.value = cred.providerUsername
|
||||
// 聚焦输入框
|
||||
await nextTick()
|
||||
if (editInput.value) {
|
||||
editInput.value?.focus()
|
||||
(editInput.value as HTMLInputElement)?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -306,28 +298,28 @@ const cancelEditing = () => {
|
||||
editingName.value = ''
|
||||
}
|
||||
|
||||
const saveEditing = async (id) => {
|
||||
const saveEditing = async (id: string) => {
|
||||
if (!editingName.value.trim()) {
|
||||
showToast(locale.value.nameRequired, 'error')
|
||||
showToast('设备名称不能为空', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
if (editingName.value.trim().length > 50) {
|
||||
showToast(locale.value.nameTooLong, 'error')
|
||||
showToast('设备名称过长 (最大50个字符)', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
isRenaming.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/webauthn/rename', {
|
||||
method: 'POST',
|
||||
body: { id, name: editingName.value }
|
||||
})
|
||||
showToast(locale.value.renameSuccess, 'success')
|
||||
showToast('设备名称修改成功', 'success')
|
||||
await fetchIdentities()
|
||||
cancelEditing()
|
||||
} catch (e) {
|
||||
showToast(localizeServerError(e, locale.value.renameFailed), 'error')
|
||||
} catch (e: any) {
|
||||
showToast(e.data?.message || '修改失败', 'error')
|
||||
} finally {
|
||||
isRenaming.value = false
|
||||
}
|
||||
@@ -353,65 +345,17 @@ const confirmDialog = ref({
|
||||
|
||||
// 样式类
|
||||
const itemClass =
|
||||
'flex items-center justify-between p-4 bg-bg-primary-30 border border-border-secondary rounded-2xl hover:bg-bg-secondary-50 transition-all group'
|
||||
|
||||
const aggregateIconClass = (loginType) => {
|
||||
const classes = {
|
||||
qq: 'text-[var(--oauth-qq)]',
|
||||
wx: 'text-[var(--oauth-wx)]',
|
||||
alipay: 'text-[var(--oauth-alipay)]',
|
||||
sina: 'text-[var(--oauth-sina)]',
|
||||
baidu: 'text-[var(--oauth-baidu)]',
|
||||
douyin: 'text-[var(--oauth-douyin)]',
|
||||
huawei: 'text-[var(--oauth-huawei)]',
|
||||
xiaomi: 'text-[var(--oauth-xiaomi)]',
|
||||
gitee: 'text-[var(--oauth-gitee)]',
|
||||
gitea: 'text-text-primary [--gitea-cutout:var(--panel-bg-dialog)]',
|
||||
bilibili: 'text-[var(--oauth-bilibili)]',
|
||||
kuaishou: 'text-[var(--oauth-kuaishou)]'
|
||||
}
|
||||
return classes[loginType] || 'text-text-primary'
|
||||
}
|
||||
|
||||
const enabledProviders = computed(() => oauthProviders.value || [])
|
||||
|
||||
const getProviderName = (provider) => {
|
||||
const matched = enabledProviders.value.find((item) => item.key === provider)
|
||||
return matched?.name || getProviderDisplayName(provider)
|
||||
}
|
||||
|
||||
const getIdentitiesByProvider = (provider) =>
|
||||
identities.value.filter((item) => item.provider === provider.key)
|
||||
|
||||
const markAvatarFailed = (id) => {
|
||||
if (!failedAvatarIds.value.includes(id)) {
|
||||
failedAvatarIds.value.push(id)
|
||||
}
|
||||
}
|
||||
|
||||
const setAsAvatar = async (identity) => {
|
||||
avatarLoadingId.value = identity.id
|
||||
try {
|
||||
await $fetch('/api/user/avatar', {
|
||||
method: 'POST',
|
||||
body: { identityId: identity.id }
|
||||
})
|
||||
showToast(locale.value.avatarSetSuccess, 'success')
|
||||
await Promise.all([fetchIdentities(), refreshUser()])
|
||||
} catch (e) {
|
||||
showToast(localizeServerError(e, locale.value.avatarSetFailed), 'error')
|
||||
} finally {
|
||||
avatarLoadingId.value = null
|
||||
}
|
||||
}
|
||||
'flex items-center justify-between p-4 bg-zinc-950/30 border border-zinc-900 rounded-2xl hover:bg-zinc-900/50 transition-all group'
|
||||
|
||||
const githubIdentity = computed(() => identities.value.find((i) => i.provider === 'github'))
|
||||
const casdoorIdentity = computed(() => identities.value.find((i) => i.provider === 'casdoor'))
|
||||
const googleIdentity = computed(() => identities.value.find((i) => i.provider === 'google'))
|
||||
const webauthnIdentities = computed(() => identities.value.filter((i) => i.provider === 'webauthn'))
|
||||
|
||||
const fetchIdentities = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
identities.value = await $fetch('/api/auth/identities')
|
||||
failedAvatarIds.value = []
|
||||
} catch (e) {
|
||||
console.error('获取绑定信息失败', e)
|
||||
} finally {
|
||||
@@ -422,24 +366,18 @@ const fetchIdentities = async () => {
|
||||
const handleBind = (provider) => {
|
||||
actionLoading.value = true
|
||||
// 绑定也是通过 OAuth 流程,最终回调时会自动识别已登录状态并执行绑定
|
||||
const routeProvider = provider.routeProvider || provider.key
|
||||
const query = new URLSearchParams()
|
||||
if (provider.loginType) query.set('type', provider.loginType)
|
||||
const queryString = query.toString()
|
||||
navigateTo(`/api/auth/${routeProvider}${queryString ? `?${queryString}` : ''}`, {
|
||||
external: true
|
||||
})
|
||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||
}
|
||||
|
||||
const confirmUnbind = (provider, identity) => {
|
||||
const providerName = getProviderName(provider.key)
|
||||
const confirmUnbind = (provider) => {
|
||||
const providerName = getProviderDisplayName(provider)
|
||||
|
||||
confirmDialog.value = {
|
||||
title: locale.value.unbindTitle,
|
||||
message: callLocale('unbindMessage', '', providerName),
|
||||
title: '解除绑定',
|
||||
message: `确定要解除 ${providerName} 账号的绑定吗?解除后您将无法使用该账号登录。`,
|
||||
type: 'danger',
|
||||
loading: false,
|
||||
onConfirm: () => handleUnbind(provider.key, identity.id),
|
||||
onConfirm: () => handleUnbind(provider),
|
||||
onCancel: () => {
|
||||
showConfirmDialog.value = false
|
||||
}
|
||||
@@ -449,8 +387,8 @@ const confirmUnbind = (provider, identity) => {
|
||||
|
||||
const confirmUnbindWebAuthn = (cred) => {
|
||||
confirmDialog.value = {
|
||||
title: locale.value.removePasskeyTitle,
|
||||
message: callLocale('removePasskeyMessage', '', cred.providerUsername),
|
||||
title: '移除 Passkey',
|
||||
message: `确定要移除设备 "${cred.providerUsername}" 吗?移除后将无法使用该设备登录。`,
|
||||
type: 'danger',
|
||||
loading: false,
|
||||
onConfirm: () => handleUnbind('webauthn', cred.id),
|
||||
@@ -465,28 +403,15 @@ const handleUnbind = async (provider, id = null) => {
|
||||
confirmDialog.value.loading = true
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const result = await $fetch('/api/auth/unbind', {
|
||||
await $fetch('/api/auth/unbind', {
|
||||
method: 'POST',
|
||||
body: { provider, id }
|
||||
})
|
||||
const cleanupResults = await Promise.all(
|
||||
(result.passkeyCleanup || []).map(signalUnknownWebAuthnCredential)
|
||||
)
|
||||
await fetchIdentities()
|
||||
try {
|
||||
await refreshUser()
|
||||
} catch (e) {
|
||||
// 头像来源变化后的用户刷新失败不阻塞解绑流程
|
||||
}
|
||||
const deviceCleanupSucceeded = cleanupResults.length > 0 && cleanupResults.every(Boolean)
|
||||
if (provider === 'webauthn' && !deviceCleanupSucceeded) {
|
||||
showToast(locale.value.passkeyCleanupRequired, 'warning', 6000)
|
||||
} else {
|
||||
showToast(locale.value.unbindSuccess, 'success')
|
||||
}
|
||||
showToast('解除绑定成功', 'success')
|
||||
showConfirmDialog.value = false
|
||||
} catch (e) {
|
||||
showToast(localizeServerError(e, locale.value.unbindFailed), 'error')
|
||||
showToast(e.data?.message || '解绑失败', 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
confirmDialog.value.loading = false
|
||||
@@ -495,27 +420,39 @@ const handleUnbind = async (provider, id = null) => {
|
||||
|
||||
const handleWebAuthnRegister = async () => {
|
||||
if (!isWebAuthnSupported.value) {
|
||||
showToast(locale.value.browserNotSupported, 'error')
|
||||
showToast('您的浏览器不支持 Windows Hello / Passkey', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const options = await $fetch('/api/auth/webauthn/register/options')
|
||||
const attResp = await startWebAuthnRegistration(options)
|
||||
|
||||
let attResp
|
||||
try {
|
||||
attResp = await startRegistration(options)
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
throw new Error('用户取消了操作')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
// 提示用户输入设备名称(可选,这里先用默认的)
|
||||
// attResp.label = 'Windows Hello'
|
||||
|
||||
// attResp.label = 'Windows Hello'
|
||||
|
||||
await $fetch('/api/auth/webauthn/register/verify', {
|
||||
method: 'POST',
|
||||
body: attResp
|
||||
})
|
||||
showToast(locale.value.addDeviceSuccess, 'success')
|
||||
|
||||
showToast('设备添加成功', 'success')
|
||||
await fetchIdentities()
|
||||
} catch (e) {
|
||||
console.error('WebAuthn 注册错误:', e)
|
||||
const message = getWebAuthnErrorMessage(e, locale.value, locale.value.addDeviceFailed)
|
||||
const apiError = e as { data?: { message?: string }; message?: string }
|
||||
const err = e as Error
|
||||
const message = apiError.data?.message || err.message || '添加设备失败'
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
@@ -523,9 +460,8 @@ const handleWebAuthnRegister = async () => {
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshSiteConfig()
|
||||
fetchIdentities()
|
||||
|
||||
|
||||
isSecureContext.value = window.isSecureContext
|
||||
|
||||
const isApiSupported = browserSupportsWebAuthn()
|
||||
@@ -533,8 +469,7 @@ onMounted(async () => {
|
||||
|
||||
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
|
||||
try {
|
||||
isPlatformAuthenticatorAvailable =
|
||||
await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
isPlatformAuthenticatorAvailable = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
} catch (e) {
|
||||
console.warn('WebAuthn 平台认证器检查失败:', e)
|
||||
}
|
||||
|
||||
@@ -1,117 +1,194 @@
|
||||
<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">{{ locale.divider }}</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 flex-wrap justify-center gap-4">
|
||||
<div class="oauth-buttons">
|
||||
<button
|
||||
v-for="provider in enabledProviders"
|
||||
:key="provider.key"
|
||||
v-if="config.public.oauth.github"
|
||||
type="button"
|
||||
:class="providerButtonClass(provider)"
|
||||
:title="formatLocale(locale.loginWith, provider.name)"
|
||||
@click="loginWith(provider)"
|
||||
class="oauth-btn oauth-btn-github"
|
||||
title="使用 GitHub 登录"
|
||||
@click="loginWith('github')"
|
||||
>
|
||||
<AuthProvidersGitHubIcon v-if="provider.key === 'github'" />
|
||||
<AuthProvidersCasdoorIcon v-else-if="provider.key === 'casdoor'" />
|
||||
<AuthProvidersGoogleIcon v-else-if="provider.key === 'google'" />
|
||||
<Icon
|
||||
v-else-if="provider.routeProvider === 'aggregate'"
|
||||
:name="getAggregateOAuthLoginTypeIcon(provider.loginType)"
|
||||
:size="26"
|
||||
/>
|
||||
<Shield v-else :size="18" />
|
||||
<AuthProvidersGitHubIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="config.public.oauth.google"
|
||||
type="button"
|
||||
class="oauth-btn oauth-btn-google"
|
||||
title="使用 Google 登录"
|
||||
@click="loginWith('google')"
|
||||
>
|
||||
<AuthProvidersGoogleIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="config.public.oauth.aggregate"
|
||||
type="button"
|
||||
class="oauth-btn oauth-btn-aggregate"
|
||||
title="使用 QQ / 微信登录"
|
||||
@click="loginWith('aggregate')"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
|
||||
<path d="M8.5 4C4.9 4 2 6.8 2 10.3c0 2 1.1 3.7 2.8 4.9L4 18l3-1.5c.5.1 1 .2 1.5.2h.6c-.1-.4-.1-.8 0-1.2H8.5C5.5 15.5 3 13.2 3 10.3 3 7.4 5.5 5 8.5 5h1.6c.3-1.1 1.3-1.9 2.5-1.9.5 0 1 .2 1.4.5C13.3 3 10.7 4 8.5 4zM10.1 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm8.4 1.2c-2.5 0-4.5 2-4.5 4.5s2 4.5 4.5 4.5c.6 0 1.2-.1 1.7-.3L22 17.5l-.8-2.3c1.4-1 2.3-2.6 2.3-4.3C23.5 9.2 21.5 7.2 19 7.2c-.5 0-1 0-1.4.1C16.9 7.8 17.9 7.2 19 7.2zM17 9.7c-.8 0-1.5.7-1.5 1.5s.7 1.5 1.5 1.5 1.5-.7 1.5-1.5-.7-1.5-1.5-1.5z"/>
|
||||
</svg>
|
||||
</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>
|
||||
import { Shield } from '@lucide/vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { getAggregateOAuthLoginTypeIcon } from '~/utils/oauth'
|
||||
|
||||
const { oauthProviders, refreshSiteConfig } = useSiteConfig()
|
||||
const route = useRoute()
|
||||
const { auth } = useLocale()
|
||||
const locale = computed(() => auth.value?.oauthButtons || {})
|
||||
const formatLocale = (value, ...args) => {
|
||||
if (typeof value === 'function') return value(...args)
|
||||
if (typeof value === 'string') {
|
||||
return value.replace(/{(\d+)}/g, (match, index) =>
|
||||
args[Number(index)] !== undefined ? String(args[Number(index)]) : match
|
||||
)
|
||||
}
|
||||
return 'OAuth 登录'
|
||||
<script setup lang="ts">
|
||||
interface Props {
|
||||
alwaysShow?: boolean
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
await refreshSiteConfig()
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
alwaysShow: false
|
||||
})
|
||||
|
||||
const enabledProviders = computed(() => oauthProviders.value || [])
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const hasEnabledProviders = computed(() => {
|
||||
return enabledProviders.value.length > 0
|
||||
return config.public.oauth && Object.values(config.public.oauth).some((enabled) => enabled)
|
||||
})
|
||||
|
||||
const providerButtonClass = (provider) => {
|
||||
const key = provider.routeProvider || provider.key
|
||||
const baseClass =
|
||||
'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)]'
|
||||
const showPlaceholderHint = computed(() => props.alwaysShow && !hasEnabledProviders.value)
|
||||
|
||||
if (key === 'github') {
|
||||
return `${baseClass} hover:bg-[var(--panel-bg-subtle)] hover:text-text-primary hover:border-[var(--panel-bg-subtle)]`
|
||||
}
|
||||
if (key === 'casdoor') {
|
||||
return `${baseClass} hover:border-[var(--oauth-casdoor-green)]`
|
||||
}
|
||||
if (key === 'google') {
|
||||
return `${baseClass} hover:bg-bg-secondary hover:text-black hover:border-[var(--panel-border)]`
|
||||
}
|
||||
if (key === 'aggregate') {
|
||||
const aggregateClasses = {
|
||||
qq: 'hover:bg-[var(--oauth-qq)] hover:text-text-primary hover:border-[var(--oauth-qq)]',
|
||||
wx: 'hover:bg-[var(--oauth-wx)] hover:text-text-primary hover:border-[var(--oauth-wx)]',
|
||||
alipay: 'hover:bg-[var(--oauth-alipay)] hover:text-text-primary hover:border-[var(--oauth-alipay)]',
|
||||
sina: 'hover:bg-[var(--oauth-sina)] hover:text-text-primary hover:border-[var(--oauth-sina)]',
|
||||
baidu: 'hover:bg-[var(--oauth-baidu)] hover:text-text-primary hover:border-[var(--oauth-baidu)]',
|
||||
douyin: 'hover:bg-[var(--panel-bg-darkest)] hover:text-text-primary hover:border-[var(--oauth-douyin)]',
|
||||
huawei: 'hover:bg-[var(--oauth-huawei)] hover:text-text-primary hover:border-[var(--oauth-huawei)]',
|
||||
xiaomi: 'hover:bg-[var(--oauth-xiaomi)] hover:text-text-primary hover:border-[var(--oauth-xiaomi)]',
|
||||
gitee: 'hover:bg-[var(--oauth-gitee)] hover:text-text-primary hover:border-[var(--oauth-gitee)]',
|
||||
gitea:
|
||||
'[--gitea-cutout:var(--bg-secondary)] hover:[--gitea-cutout:var(--text-primary)] hover:bg-bg-secondary hover:text-black hover:border-primary',
|
||||
bilibili: 'hover:bg-[var(--oauth-bilibili)] hover:text-text-primary hover:border-[var(--oauth-bilibili)]',
|
||||
kuaishou: 'hover:bg-[var(--oauth-kuaishou)] hover:text-text-primary hover:border-[var(--oauth-kuaishou)]'
|
||||
}
|
||||
return `${baseClass} ${aggregateClasses[provider.loginType] || ''}`
|
||||
}
|
||||
if (key === 'oauth2') {
|
||||
return `${baseClass} hover:bg-[var(--status-success-icon)] hover:text-text-primary hover:border-[var(--status-success-icon)]`
|
||||
}
|
||||
|
||||
return `${baseClass} hover:border-primary-40 hover:text-primary`
|
||||
}
|
||||
|
||||
const loginWith = (provider) => {
|
||||
const queryRedirect = route.query.redirect
|
||||
const redirect = Array.isArray(queryRedirect) ? queryRedirect[0] : queryRedirect
|
||||
const safeRedirect =
|
||||
redirect?.startsWith('/') && !redirect.startsWith('//') && !redirect.startsWith('/\\')
|
||||
? redirect
|
||||
: null
|
||||
const query = new URLSearchParams()
|
||||
if (provider.loginType) query.set('type', provider.loginType)
|
||||
if (safeRedirect) query.set('redirect', safeRedirect)
|
||||
const queryString = query.toString()
|
||||
const routeProvider = provider.routeProvider || provider.key
|
||||
// 外部导航到 API 端点
|
||||
navigateTo(`/api/auth/${routeProvider}${queryString ? `?${queryString}` : ''}`, {
|
||||
external: true
|
||||
})
|
||||
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: var(--bg-tertiary, #24292e);
|
||||
color: var(--text-primary, #ffffff);
|
||||
border-color: var(--border-secondary, #24292e);
|
||||
}
|
||||
|
||||
.oauth-btn-google:hover {
|
||||
background: var(--card-bg, #ffffff);
|
||||
color: var(--text-primary, #111111);
|
||||
border-color: #dadce0;
|
||||
}
|
||||
|
||||
.oauth-btn-aggregate:hover {
|
||||
background: var(--bg-secondary, #12b7f5);
|
||||
color: var(--text-primary, #ffffff);
|
||||
border-color: var(--border-secondary, #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>
|
||||
|
||||
@@ -0,0 +1,252 @@
|
||||
<template>
|
||||
<div class="bg-zinc-900/40 border border-zinc-900 rounded-3xl p-6 md:p-8 shadow-2xl">
|
||||
<div class="flex items-center gap-3 border-b border-zinc-800/50 pb-5 mb-6">
|
||||
<div class="p-2.5 bg-green-500/10 rounded-xl">
|
||||
<UserCircle2 :size="20" class="text-green-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 class="text-base font-black text-zinc-100">个人信息设置</h2>
|
||||
<p class="text-xs text-zinc-500 mt-0.5">修改您的个人资料和学校信息</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleUpdateProfile" class="space-y-5 max-w-md">
|
||||
<!-- 昵称 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">昵称</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
placeholder="留空则显示用户名"
|
||||
class="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 年级与班级 -->
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">年级</label>
|
||||
<CustomSelect
|
||||
v-model="form.grade"
|
||||
:options="gradeOptions"
|
||||
placeholder="请选择年级"
|
||||
class-name="w-full custom-select-profile"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">班级</label>
|
||||
<CustomSelect
|
||||
v-model="form.class"
|
||||
:options="classOptions"
|
||||
placeholder="请选择班级"
|
||||
class-name="w-full custom-select-profile"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 学号 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">学号</label>
|
||||
<input
|
||||
v-model="form.studentId"
|
||||
type="text"
|
||||
placeholder="选填"
|
||||
class="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 邮箱 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">邮箱</label>
|
||||
<input
|
||||
v-model="form.email"
|
||||
type="email"
|
||||
placeholder="选填"
|
||||
class="w-full px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all placeholder:text-zinc-600"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="emailChangedAndNotEmpty" class="space-y-2">
|
||||
<label class="text-sm font-medium text-zinc-300">邮箱验证码</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="form.emailCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="输入6位数字验证码"
|
||||
class="flex-1 px-4 py-3 bg-zinc-950 border border-zinc-800 rounded-xl text-sm text-zinc-100 focus:outline-none focus:border-green-500/50 focus:ring-1 focus:ring-green-500/50 transition-all"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!form.email || emailActionLoading"
|
||||
class="px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap border border-zinc-700"
|
||||
@click="sendProfileEmailCode"
|
||||
>
|
||||
{{ emailActionLoading ? '发送中...' : '发送验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 保存按钮 -->
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="loading || !isChanged"
|
||||
class="w-full py-3.5 bg-green-600 hover:bg-green-500 disabled:opacity-50 disabled:hover:bg-green-600 text-white rounded-xl text-sm font-black tracking-widest transition-all shadow-lg shadow-green-900/20 mt-6"
|
||||
>
|
||||
<span v-if="loading" class="flex items-center justify-center gap-2">
|
||||
<Loader2 class="w-4 h-4 animate-spin" />
|
||||
保存中...
|
||||
</span>
|
||||
<span v-else>保存修改</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { UserCircle2, Loader2 } from '@lucide/vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
|
||||
const auth = useAuth()
|
||||
const { showToast } = useToast()
|
||||
const loading = ref(false)
|
||||
const emailActionLoading = ref(false)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
grade: '',
|
||||
class: '',
|
||||
studentId: '',
|
||||
email: '',
|
||||
emailCode: ''
|
||||
})
|
||||
|
||||
const gradeOptions = [
|
||||
{ label: '高一', value: '高一' },
|
||||
{ label: '高二', value: '高二' },
|
||||
{ label: '高三', value: '高三' }
|
||||
]
|
||||
|
||||
const classOptions = Array.from({ length: 16 }, (_, i) => ({
|
||||
label: `${i + 1}班`,
|
||||
value: (i + 1).toString()
|
||||
}))
|
||||
|
||||
// 初始数据用于比较是否被修改
|
||||
const initialData = ref({})
|
||||
|
||||
onMounted(() => {
|
||||
if (auth.user.value) {
|
||||
form.name = auth.user.value.name || ''
|
||||
form.grade = auth.user.value.grade || ''
|
||||
form.class = auth.user.value.class || ''
|
||||
form.studentId = auth.user.value.studentId || ''
|
||||
form.email = auth.user.value.email || ''
|
||||
form.emailCode = ''
|
||||
|
||||
initialData.value = { ...form }
|
||||
}
|
||||
})
|
||||
|
||||
const emailChangedAndNotEmpty = computed(() => {
|
||||
const currentEmail = (form.email || '').trim().toLowerCase()
|
||||
const prevEmail = (initialData.value?.email || '').trim().toLowerCase()
|
||||
return currentEmail && currentEmail !== prevEmail
|
||||
})
|
||||
|
||||
const isChanged = computed(() => {
|
||||
return form.name !== initialData.value.name ||
|
||||
form.grade !== initialData.value.grade ||
|
||||
form.class !== initialData.value.class ||
|
||||
form.studentId !== initialData.value.studentId ||
|
||||
form.email !== initialData.value.email ||
|
||||
(emailChangedAndNotEmpty.value && form.emailCode.length === 6)
|
||||
})
|
||||
|
||||
const sendProfileEmailCode = async () => {
|
||||
const emailStr = (form.email || '').toString().trim()
|
||||
if (!emailStr) return
|
||||
|
||||
emailActionLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/user/email/send-code', {
|
||||
method: 'POST',
|
||||
body: { email: emailStr },
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
showToast('验证码已发送,请查收邮箱', 'success')
|
||||
} catch (err) {
|
||||
showToast(err?.data?.message || err?.message || '验证码发送失败', 'error')
|
||||
} finally {
|
||||
emailActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleUpdateProfile = async () => {
|
||||
if (form.name) {
|
||||
const chineseRegex = /^[\u4e00-\u9fa5]+$/
|
||||
if (!chineseRegex.test(form.name)) {
|
||||
showToast('昵称只能包含汉字', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (emailChangedAndNotEmpty.value && !form.emailCode) {
|
||||
showToast('请填写邮箱验证码', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/user/profile', {
|
||||
method: 'PUT',
|
||||
body: form
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
showToast('个人信息更新成功', 'success')
|
||||
initialData.value = { ...form, emailCode: '' }
|
||||
form.emailCode = ''
|
||||
await auth.refreshUser()
|
||||
}
|
||||
} catch (error) {
|
||||
showToast(error.data?.message || '更新失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-select-profile :deep(> .flex) {
|
||||
padding: 10px 16px;
|
||||
background-color: var(--input-bg, #09090b);
|
||||
border: 1px solid var(--input-border, #27272a);
|
||||
border-radius: 0.75rem;
|
||||
color: var(--input-text, #f4f4f5);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
height: 46px;
|
||||
}
|
||||
|
||||
.custom-select-profile :deep(> .flex:hover) {
|
||||
border-color: var(--input-border-focus, #3f3f46);
|
||||
}
|
||||
|
||||
.custom-select-profile :deep(> .border-blue-500\/50) {
|
||||
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: var(--input-bg, #09090b) !important;
|
||||
}
|
||||
|
||||
.custom-select-profile :deep(.text-\[11px\]) {
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.25rem;
|
||||
font-weight: normal;
|
||||
}
|
||||
</style>
|
||||
@@ -2,19 +2,19 @@
|
||||
<svg viewBox="0 0 24 24" width="1.2em" height="1.2em" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
|
||||
<path
|
||||
fill="var(--oauth-google-blue)"
|
||||
fill="#4285F4"
|
||||
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
|
||||
/>
|
||||
<path
|
||||
fill="var(--oauth-google-green)"
|
||||
fill="#34A853"
|
||||
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
|
||||
/>
|
||||
<path
|
||||
fill="var(--oauth-google-yellow)"
|
||||
fill="#FBBC05"
|
||||
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
|
||||
/>
|
||||
<path
|
||||
fill="var(--oauth-google-red)"
|
||||
fill="#EA4335"
|
||||
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
|
||||
/>
|
||||
</g>
|
||||
|
||||
@@ -0,0 +1,720 @@
|
||||
<template>
|
||||
<div class="login-form">
|
||||
<div class="form-header">
|
||||
<h2>注册账号</h2>
|
||||
<p>创建您的VoiceHub账户</p>
|
||||
</div>
|
||||
|
||||
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleRegister">
|
||||
<div class="form-group">
|
||||
<label for="username">真实姓名(大名)</label>
|
||||
<div class="input-wrapper">
|
||||
<svg
|
||||
class="input-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
:class="{ 'input-error': error }"
|
||||
placeholder="请输入真实姓名(大名),仅限汉字"
|
||||
required
|
||||
type="text"
|
||||
@input="error = ''"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<div class="input-wrapper">
|
||||
<svg
|
||||
class="input-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect height="11" rx="2" ry="2" width="18" x="3" y="11" />
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:class="{ 'input-error': error }"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
>
|
||||
<button class="password-toggle" type="button" @click="showPassword = !showPassword">
|
||||
<svg
|
||||
v-if="showPassword"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
|
||||
/>
|
||||
<line x1="1" x2="23" y1="1" y2="23" />
|
||||
</svg>
|
||||
<svg v-else fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-row" style="display: flex; gap: 12px;">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="grade">年级</label>
|
||||
<div class="input-wrapper">
|
||||
<CustomSelect
|
||||
v-model="grade"
|
||||
:options="gradeOptions"
|
||||
placeholder="请选择年级"
|
||||
class-name="w-full custom-select-override"
|
||||
@update:model-value="error = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<label for="class">班级</label>
|
||||
<div class="input-wrapper">
|
||||
<CustomSelect
|
||||
v-model="studentClass"
|
||||
:options="classOptions"
|
||||
placeholder="请选择班级"
|
||||
class-name="w-full custom-select-override"
|
||||
@update:model-value="error = ''"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="studentId">学号 (选填)</label>
|
||||
<div class="input-wrapper">
|
||||
<svg class="input-icon" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
<input
|
||||
id="studentId"
|
||||
v-model="studentId"
|
||||
:class="{ 'input-error': error }"
|
||||
placeholder="请输入学号"
|
||||
type="text"
|
||||
@input="error = ''"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="email">邮箱 (选填)</label>
|
||||
<div class="input-wrapper">
|
||||
<svg
|
||||
class="input-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M4 4h16v16H4z" />
|
||||
<path d="m4 6 8 7 8-7" />
|
||||
</svg>
|
||||
<input
|
||||
id="email"
|
||||
v-model="email"
|
||||
:class="{ 'input-error': error }"
|
||||
placeholder="例如:name@example.com"
|
||||
type="email"
|
||||
@input="error = ''"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="email" class="form-group">
|
||||
<label for="emailCode">邮箱验证码</label>
|
||||
<div class="flex gap-2" style="display: flex; gap: 8px;">
|
||||
<div class="input-wrapper flex-1" style="flex: 1;">
|
||||
<svg
|
||||
class="input-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
|
||||
</svg>
|
||||
<input
|
||||
id="emailCode"
|
||||
v-model="emailCode"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="6位数字验证码"
|
||||
class="form-input"
|
||||
:disabled="loading"
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
:disabled="!email || emailActionLoading"
|
||||
class="px-4 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap border border-zinc-700"
|
||||
@click="sendRegisterEmailCode"
|
||||
>
|
||||
{{ emailActionLoading ? '发送中...' : '发送验证码' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-container">
|
||||
<svg
|
||||
class="error-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
<span class="error-message">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<button :disabled="loading" class="submit-btn register-btn" type="submit">
|
||||
<svg v-if="loading" class="loading-spinner" viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
fill="none"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-dasharray="31.416"
|
||||
stroke-dashoffset="31.416"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dasharray"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
values="0 31.416;15.708 15.708;0 31.416"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
values="0;-15.708;-31.416"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span v-if="loading">注册中...</span>
|
||||
<span v-else>注 册</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div class="form-footer" style="margin-top: 12px; margin-bottom: 12px;">
|
||||
<p class="help-text">
|
||||
已有账号? <NuxtLink to="/login" style="color: var(--primary);">去登录</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const email = ref('')
|
||||
const emailCode = ref('')
|
||||
const grade = ref('')
|
||||
const studentClass = ref('')
|
||||
const studentId = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const emailActionLoading = ref(false)
|
||||
const pendingRedirectPath = ref('/')
|
||||
|
||||
const auth = useAuth()
|
||||
|
||||
const gradeOptions = [
|
||||
{ label: '高一', value: '高一' },
|
||||
{ label: '高二', value: '高二' },
|
||||
{ label: '高三', value: '高三' }
|
||||
]
|
||||
|
||||
const classOptions = Array.from({ length: 16 }, (_, i) => ({
|
||||
label: `${i + 1}班`,
|
||||
value: (i + 1).toString()
|
||||
}))
|
||||
|
||||
const sendRegisterEmailCode = async () => {
|
||||
if (!email.value || !email.value.trim()) {
|
||||
error.value = '请先输入邮箱'
|
||||
return
|
||||
}
|
||||
|
||||
emailActionLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/email/send-code', {
|
||||
method: 'POST',
|
||||
body: { email: email.value.trim() }
|
||||
})
|
||||
if ((window as any).$showNotification) {
|
||||
;(window as any).$showNotification('验证码已发送,请查收邮箱', 'success')
|
||||
}
|
||||
} catch (err: any) {
|
||||
if ((window as any).$showNotification) {
|
||||
;(window as any).$showNotification(err.data?.message || err.message || '验证码发送失败', 'error')
|
||||
}
|
||||
} finally {
|
||||
emailActionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleRegister = async () => {
|
||||
if (!username.value || !password.value || !grade.value || !studentClass.value) {
|
||||
error.value = '请填写完整的注册信息'
|
||||
return
|
||||
}
|
||||
|
||||
const chineseRegex = /^[\u4e00-\u9fa5]+$/
|
||||
if (!chineseRegex.test(username.value)) {
|
||||
error.value = '真实姓名(大名)只能包含汉字'
|
||||
return
|
||||
}
|
||||
|
||||
if (email.value && !emailCode.value) {
|
||||
error.value = '请填写邮箱验证码'
|
||||
return
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
const response = await auth.register({
|
||||
username: username.value,
|
||||
password: password.value,
|
||||
email: email.value,
|
||||
emailCode: emailCode.value,
|
||||
grade: grade.value,
|
||||
class: studentClass.value,
|
||||
studentId: studentId.value
|
||||
})
|
||||
|
||||
if (response?.success) {
|
||||
pendingRedirectPath.value = auth.isAdmin.value ? '/dashboard' : '/'
|
||||
await navigateTo(pendingRedirectPath.value)
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.message || err.message || '注册失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
animation: fadeInUp 0.4s ease both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
height: 2px;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transform: scaleX(0.2);
|
||||
transition:
|
||||
transform var(--transition-normal),
|
||||
opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within::after {
|
||||
opacity: 0.35;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-quaternary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.custom-select-override {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.custom-select-override :deep(> .flex) {
|
||||
padding: 14px 16px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
height: 54px;
|
||||
}
|
||||
|
||||
.custom-select-override :deep(> .flex:hover) {
|
||||
filter: brightness(1.03);
|
||||
}
|
||||
|
||||
.custom-select-override :deep(> .border-blue-500\/50) {
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.custom-select-override :deep(.text-\[11px\]) {
|
||||
font-size: 16px;
|
||||
font-weight: normal;
|
||||
}
|
||||
|
||||
.custom-select-override :deep(.text-\[8px\]) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
width: 100%;
|
||||
padding: 16px 16px 16px 48px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: 16px;
|
||||
transition:
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-wrapper input::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus + .input-icon,
|
||||
.input-wrapper input:not(:placeholder-shown) + .input-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.input-wrapper input:hover {
|
||||
filter: brightness(1.03);
|
||||
}
|
||||
|
||||
.input-wrapper input.input-error {
|
||||
border-color: var(--error);
|
||||
box-shadow: 0 0 0 3px var(--error-light);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-quaternary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
transform var(--transition-fast);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.password-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.password-toggle svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--error-light);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.auth-form.has-error {
|
||||
animation: shake 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
15% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
30% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
45% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
border: none;
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 16px;
|
||||
font-weight: var(--font-semibold);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-normal),
|
||||
box-shadow var(--transition-normal),
|
||||
transform var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--primary-dark);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.submit-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.register-btn {
|
||||
padding: 18px;
|
||||
font-size: 18px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-text code {
|
||||
background: var(--input-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
color: var(--primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.webauthn-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
color: var(--text-quaternary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.webauthn-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.webauthn-btn:hover:not(:disabled) {
|
||||
background: var(--surface-tertiary);
|
||||
border-color: var(--input-border-focus);
|
||||
}
|
||||
|
||||
.webauthn-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.webauthn-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.form-header h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
padding: 14px 14px 14px 44px;
|
||||
font-size: 16px; /* 防止iOS缩放 */
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,18 +1,18 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 状态显示 -->
|
||||
<div class="flex items-center justify-between p-4 bg-bg-primary-50 rounded-xl border border-border-secondary">
|
||||
<div class="flex items-center justify-between p-4 bg-zinc-950/50 rounded-xl border border-zinc-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="p-2 rounded-lg flex items-center justify-center"
|
||||
:class="isEnabled ? 'bg-success-10 text-success' : 'bg-bg-tertiary text-text-tertiary'"
|
||||
class="p-2 rounded-lg"
|
||||
:class="isEnabled ? 'bg-green-500/10 text-green-500' : 'bg-zinc-800 text-zinc-500'"
|
||||
>
|
||||
<ShieldCheck :size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-text-primary">{{ locale.title }}</h3>
|
||||
<p class="text-xs text-text-tertiary">
|
||||
{{ isEnabled ? locale.enabledDesc : locale.disabledDesc }}
|
||||
<h3 class="font-bold text-zinc-100">双重认证 (2FA)</h3>
|
||||
<p class="text-xs text-zinc-500">
|
||||
{{ isEnabled ? '您的账户已开启双重认证保护' : '未开启,建议开启以提升账户安全性' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -20,36 +20,36 @@
|
||||
<button
|
||||
v-if="!isEnabled"
|
||||
@click="startSetup"
|
||||
class="px-4 py-2 bg-primary-hover hover:bg-primary text-text-primary text-sm font-bold rounded-lg transition-colors"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-lg transition-colors"
|
||||
>
|
||||
{{ locale.enable }}
|
||||
开启
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="confirmDisable"
|
||||
class="px-4 py-2 bg-error-10 hover:bg-error-20 text-error border border-error-20 text-sm font-bold rounded-lg transition-colors"
|
||||
class="px-4 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 text-sm font-bold rounded-lg transition-colors"
|
||||
>
|
||||
{{ locale.disable }}
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 开启流程 -->
|
||||
<div v-if="showSetup" class="bg-bg-primary-30 rounded-xl border border-border-secondary p-6 space-y-6 animate-in fade-in slide-in-from-top-4">
|
||||
<div v-if="showSetup" class="bg-zinc-950/30 rounded-xl border border-zinc-800 p-6 space-y-6 animate-in fade-in slide-in-from-top-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 class="font-bold text-text-primary mb-1">{{ locale.setupTitle }}</h4>
|
||||
<p class="text-sm text-text-tertiary">{{ locale.setupDesc }}</p>
|
||||
<h4 class="font-bold text-zinc-100 mb-1">设置双重认证</h4>
|
||||
<p class="text-sm text-zinc-400">请使用 Google Authenticator 或其他验证器应用扫描下方二维码</p>
|
||||
</div>
|
||||
<button @click="cancelSetup" class="text-text-tertiary hover:text-text-secondary">
|
||||
<button @click="cancelSetup" class="text-zinc-500 hover:text-zinc-300">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8 items-center md:items-start">
|
||||
<!-- 二维码区域 -->
|
||||
<div class="flex-shrink-0 bg-bg-secondary p-2 rounded-lg">
|
||||
<div class="flex-shrink-0 bg-white p-2 rounded-lg">
|
||||
<img v-if="qrCodeUrl" :src="qrCodeUrl" alt="2FA QR Code" class="w-48 h-48" />
|
||||
<div v-else class="w-48 h-48 flex items-center justify-center bg-bg-secondary text-text-tertiary">
|
||||
<div v-else class="w-48 h-48 flex items-center justify-center bg-zinc-100 text-zinc-400">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -57,17 +57,17 @@
|
||||
<!-- 验证输入区域 -->
|
||||
<div class="flex-1 space-y-4 w-full">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-text-tertiary uppercase tracking-wider mb-2">
|
||||
{{ locale.manualKey }}
|
||||
<label class="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">
|
||||
手动输入密钥
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-3 py-2 bg-bg-secondary rounded-lg text-text-secondary font-mono text-sm border border-border-secondary select-all">
|
||||
<code class="px-3 py-2 bg-zinc-900 rounded-lg text-zinc-300 font-mono text-sm border border-zinc-800 select-all">
|
||||
{{ secret }}
|
||||
</code>
|
||||
<button
|
||||
@click="copySecret"
|
||||
class="p-2 hover:bg-bg-tertiary rounded-lg text-text-tertiary transition-colors"
|
||||
:title="locale.copyKey"
|
||||
class="p-2 hover:bg-zinc-800 rounded-lg text-zinc-400 transition-colors"
|
||||
title="复制密钥"
|
||||
>
|
||||
<Copy :size="16" />
|
||||
</button>
|
||||
@@ -75,25 +75,25 @@
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-text-tertiary uppercase tracking-wider mb-2">
|
||||
{{ locale.code }}
|
||||
<label class="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">
|
||||
验证码
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="verificationCode"
|
||||
type="text"
|
||||
:placeholder="locale.codePlaceholder"
|
||||
placeholder="请输入6位验证码"
|
||||
maxlength="6"
|
||||
class="flex-1 bg-bg-secondary border border-border-secondary rounded-lg px-4 py-2 text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all font-mono text-center tracking-widest"
|
||||
class="flex-1 bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-2 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-mono text-center tracking-widest"
|
||||
@keyup.enter="enable2FA"
|
||||
/>
|
||||
<button
|
||||
@click="enable2FA"
|
||||
:disabled="loading || verificationCode.length !== 6"
|
||||
class="px-6 py-2 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary font-bold rounded-lg transition-colors flex items-center gap-2"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Loader2 v-if="loading" class="animate-spin" :size="16" />
|
||||
<span>{{ locale.verifyAndEnable }}</span>
|
||||
<span>验证并开启</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -103,12 +103,12 @@
|
||||
|
||||
<UIConfirmDialog
|
||||
:show="showDisableConfirm"
|
||||
:title="locale.disableTitle"
|
||||
:message="locale.disableContent"
|
||||
:confirm-text="locale.verifyAndDisable"
|
||||
type="danger"
|
||||
title="关闭双重认证"
|
||||
content="关闭后,您的账户将不再受到双重认证保护。为确保安全,请输入您的登录密码以确认。"
|
||||
confirm-text="验证并关闭"
|
||||
confirm-type="danger"
|
||||
:show-input="true"
|
||||
:input-placeholder="locale.passwordPlaceholder"
|
||||
input-placeholder="请输入当前登录密码"
|
||||
input-type="password"
|
||||
@confirm="disable2FA"
|
||||
@cancel="showDisableConfirm = false"
|
||||
@@ -116,18 +116,14 @@
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ShieldCheck, X, Copy, Loader2 } from '@lucide/vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const props = defineProps({
|
||||
initialEnabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
const props = defineProps<{
|
||||
initialEnabled?: boolean
|
||||
}>()
|
||||
|
||||
const isEnabled = ref(props.initialEnabled || false)
|
||||
const showSetup = ref(false)
|
||||
@@ -137,8 +133,6 @@ const qrCodeUrl = ref('')
|
||||
const secret = ref('')
|
||||
const verificationCode = ref('')
|
||||
const { showToast } = useToast()
|
||||
const { auth } = useLocale()
|
||||
const locale = computed(() => auth.value?.twoFactorSetup || {})
|
||||
|
||||
// 获取当前状态(可选,如果父组件没传可以通过API获取,这里暂时假设父组件会传或者初始化为false,
|
||||
// 实际生产中最好有一个 API check status,但为了简化,这里先依赖父组件或默认)
|
||||
@@ -161,8 +155,8 @@ const startSetup = async () => {
|
||||
showSetup.value = true
|
||||
verificationCode.value = ''
|
||||
}
|
||||
} catch (err) {
|
||||
showToast(err.data?.message || err.message || locale.value.fetchFailed, 'error')
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '获取验证码失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -178,9 +172,9 @@ const cancelSetup = () => {
|
||||
const copySecret = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret.value)
|
||||
showToast(locale.value.copied, 'success')
|
||||
showToast('密钥已复制', 'success')
|
||||
} catch (err) {
|
||||
showToast(locale.value.copyFailed, 'error')
|
||||
showToast('复制失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
@@ -199,11 +193,11 @@ const enable2FA = async () => {
|
||||
|
||||
if (error.value) throw error.value
|
||||
|
||||
showToast(locale.value.enabled, 'success')
|
||||
showToast('双重认证已开启', 'success')
|
||||
isEnabled.value = true
|
||||
cancelSetup()
|
||||
} catch (err) {
|
||||
showToast(err.data?.message || err.message || locale.value.verifyFailed, 'error')
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '验证失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@@ -213,9 +207,9 @@ const confirmDisable = () => {
|
||||
showDisableConfirm.value = true
|
||||
}
|
||||
|
||||
const disable2FA = async (password) => {
|
||||
const disable2FA = async (password: string) => {
|
||||
if (!password) {
|
||||
showToast(locale.value.passwordRequired, 'warning')
|
||||
showToast('请输入密码', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
@@ -228,11 +222,11 @@ const disable2FA = async (password) => {
|
||||
|
||||
if (error.value) throw error.value
|
||||
|
||||
showToast(locale.value.disabled, 'success')
|
||||
showToast('双重认证已关闭', 'success')
|
||||
isEnabled.value = false
|
||||
showDisableConfirm.value = false
|
||||
} catch (err) {
|
||||
showToast(err.data?.message || err.message || locale.value.disableFailed, 'error')
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '关闭失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
@@ -7,35 +7,35 @@
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[2000] bg-bg-primary-80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
class="fixed inset-0 z-[2000] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-bg-primary border border-border-secondary rounded-2xl p-6 shadow-2xl space-y-6"
|
||||
class="w-full max-w-md bg-zinc-950 border border-zinc-800 rounded-2xl p-6 shadow-2xl space-y-6"
|
||||
@click.stop
|
||||
>
|
||||
<div class="text-center space-y-2">
|
||||
<h3 class="text-xl font-bold text-text-primary">{{ locale.title }}</h3>
|
||||
<p class="text-sm text-text-tertiary">
|
||||
{{ method === 'totp' ? locale.totpDesc : locale.emailDesc }}
|
||||
<h3 class="text-xl font-bold text-zinc-100">双重认证</h3>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ method === 'totp' ? '请输入您的认证器应用生成的6位验证码' : '请输入发送到您邮箱的验证码' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 邮箱输入 (仅 Email 模式) -->
|
||||
<div v-if="method === 'email'" class="space-y-2">
|
||||
<label class="block text-sm text-text-tertiary">
|
||||
{{ locale.emailLabel }}
|
||||
<span v-if="maskedEmail" class="block text-xs text-text-tertiary mt-1">
|
||||
{{ locale.maskedEmailTip }} {{ maskedEmail }}
|
||||
<label class="block text-sm text-zinc-400">
|
||||
请补全您的邮箱以接收验证码
|
||||
<span v-if="maskedEmail" class="block text-xs text-zinc-500 mt-1">
|
||||
提示: {{ maskedEmail }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="emailInput"
|
||||
type="email"
|
||||
:placeholder="locale.emailPlaceholder"
|
||||
class="w-full bg-bg-secondary border border-border-secondary rounded-lg px-4 py-3 text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
placeholder="请输入完整邮箱地址"
|
||||
class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
:disabled="emailSent && cooldown > 0"
|
||||
@keyup.enter="!emailSent && sendEmailCode()"
|
||||
/>
|
||||
@@ -50,7 +50,7 @@
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="000000"
|
||||
class="w-full bg-bg-secondary border border-border-secondary rounded-lg px-4 py-3 text-center text-2xl font-mono tracking-widest text-text-primary placeholder-text-disabled focus:outline-none focus:border-primary focus:ring-1 focus:ring-primary transition-all"
|
||||
class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-center text-2xl font-mono tracking-widest text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
@keyup.enter="handleVerify"
|
||||
ref="inputRef"
|
||||
/>
|
||||
@@ -63,13 +63,13 @@
|
||||
type="button"
|
||||
@click="sendEmailCode"
|
||||
:disabled="cooldown > 0 || sending || !emailInput"
|
||||
class="text-sm text-primary hover:text-primary disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
class="text-sm text-blue-500 hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ resendButtonText }}
|
||||
{{ cooldown > 0 ? `${cooldown}秒后重新发送` : (emailSent ? '重新发送验证码' : '发送验证码') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-3 bg-error-10 border border-error-20 rounded-lg flex items-center gap-2 text-error text-sm">
|
||||
<div v-if="error" class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2 text-red-500 text-sm">
|
||||
<AlertCircle :size="16" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
@@ -77,26 +77,26 @@
|
||||
<button
|
||||
@click="handleVerify"
|
||||
:disabled="loading || code.length !== 6"
|
||||
class="w-full py-3 bg-primary-hover hover:bg-primary disabled:opacity-50 disabled:cursor-not-allowed text-text-primary font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
class="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Loader2 v-if="loading" class="animate-spin" :size="18" />
|
||||
<span>{{ locale.verifyLogin }}</span>
|
||||
<span>验证登录</span>
|
||||
</button>
|
||||
|
||||
<!-- 切换验证方式按钮 -->
|
||||
<button
|
||||
v-if="hasMultipleMethods"
|
||||
@click="toggleMethod"
|
||||
class="w-full py-2 text-primary hover:text-primary text-sm transition-colors"
|
||||
class="w-full py-2 text-blue-500 hover:text-blue-400 text-sm transition-colors"
|
||||
>
|
||||
{{ method === 'totp' ? locale.switchToEmail : locale.switchToTotp }}
|
||||
{{ method === 'totp' ? '无法使用验证器?切换到邮箱验证' : '使用验证器应用验证' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="$emit('cancel')"
|
||||
class="w-full py-2 text-text-tertiary hover:text-text-secondary text-sm transition-colors"
|
||||
class="w-full py-2 text-zinc-500 hover:text-zinc-300 text-sm transition-colors"
|
||||
>
|
||||
{{ locale.backLogin }}
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,7 +109,6 @@
|
||||
import { ref, watch, nextTick, computed, onUnmounted } from 'vue'
|
||||
import { Loader2, AlertCircle } from '@lucide/vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
@@ -125,15 +124,6 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const { showToast } = useToast()
|
||||
const { auth } = useLocale()
|
||||
const locale = computed(() => auth.value?.twoFactorVerify || {})
|
||||
const { format: formatLocaleValue } = useLocaleText(locale)
|
||||
const resendButtonText = computed(() => {
|
||||
if (cooldown.value > 0) {
|
||||
return formatLocaleValue(locale.value.resendCountdown, '', cooldown.value)
|
||||
}
|
||||
return emailSent.value ? locale.value.resendCode : locale.value.sendCode
|
||||
})
|
||||
// 默认优先使用 TOTP,如果没有则使用 Email
|
||||
const defaultMethod = computed(() => props.availableMethods?.includes('totp') ? 'totp' : 'email')
|
||||
const method = ref(defaultMethod.value)
|
||||
@@ -170,7 +160,7 @@ const toggleMethod = () => {
|
||||
const sendEmailCode = async () => {
|
||||
try {
|
||||
if (!emailInput.value) {
|
||||
error.value = locale.value.emailRequired || '请输入邮箱'
|
||||
error.value = '请输入您的邮箱地址'
|
||||
return
|
||||
}
|
||||
|
||||
@@ -186,7 +176,7 @@ const sendEmailCode = async () => {
|
||||
}
|
||||
})
|
||||
|
||||
showToast(locale.value.codeSent, 'success')
|
||||
showToast('验证码已发送', 'success')
|
||||
emailSent.value = true
|
||||
cooldown.value = 60
|
||||
timer = setInterval(() => {
|
||||
@@ -200,7 +190,7 @@ const sendEmailCode = async () => {
|
||||
// 发送成功后自动聚焦验证码输入框
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.message || err.message || locale.value.sendFailed || '发送失败'
|
||||
error.value = err.data?.message || err.message || '发送失败'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
@@ -218,7 +208,7 @@ const handleVerify = async () => {
|
||||
|
||||
emit('success', response)
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.message || err.message || locale.value.verifyFailed || '验证失败'
|
||||
error.value = err.data?.message || err.message || '验证失败'
|
||||
code.value = '' // 出错清空
|
||||
} finally {
|
||||
loading.value = false
|
||||
|
||||
+1682
-3366
File diff suppressed because it is too large
Load Diff
@@ -6,29 +6,26 @@
|
||||
|
||||
<Transition name="entry" appear>
|
||||
<div v-show="active" class="content">
|
||||
<div class="label">{{ yearReview.favoriteArtist }}</div>
|
||||
<div class="label">特别偏爱</div>
|
||||
|
||||
<p class="text-intro">{{ yearReview.topArtistIntro }}</p>
|
||||
<p class="text-intro">你的年度歌手是</p>
|
||||
|
||||
<div class="artist-container">
|
||||
<div class="artist-bg-blur" />
|
||||
<h2 class="artist-name">{{ data.topArtist }}</h2>
|
||||
</div>
|
||||
|
||||
<p class="text-desc">{{ yearReview.artistDesc }}</p>
|
||||
<p class="text-desc">Ta 的声音似乎特别吸引你,<br >陪伴了你无数个日夜。</p>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
defineProps({
|
||||
data: Object,
|
||||
active: Boolean
|
||||
})
|
||||
const { yearReview } = useLocale()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -40,13 +37,13 @@ const { yearReview } = useLocale()
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: var(--panel-bg-tertiary);
|
||||
background-color: var(--surface-primary, var(--bg-primary, #1a0b2e));
|
||||
}
|
||||
|
||||
.bg-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top, var(--bg-primary), transparent, transparent);
|
||||
background: linear-gradient(to top, var(--bg-primary, #111111), transparent, transparent);
|
||||
opacity: 0.8;
|
||||
z-index: 10;
|
||||
}
|
||||
@@ -56,7 +53,7 @@ const { yearReview } = useLocale()
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(to bottom, var(--color-collab-hover), var(--bg-primary));
|
||||
background: linear-gradient(to bottom, #581c87, #000);
|
||||
opacity: 0.5;
|
||||
animation: pulse 8s infinite;
|
||||
}
|
||||
@@ -72,19 +69,19 @@ const { yearReview } = useLocale()
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--color-collab-light); /* purple-300 */
|
||||
color: #d8b4fe; /* purple-300 */
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
border-bottom: 1px solid var(--year-review-artist-label-border);
|
||||
border-bottom: 1px solid rgba(168, 85, 247, 0.3);
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.text-intro {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-primary-lighter);
|
||||
color: #d1d5db;
|
||||
font-weight: 300;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
@@ -101,7 +98,7 @@ const { yearReview } = useLocale()
|
||||
transform: translate(-50%, -50%);
|
||||
width: 120%;
|
||||
height: 120%;
|
||||
background: var(--year-review-artist-bg-blur);
|
||||
background: rgba(168, 85, 247, 0.2);
|
||||
filter: blur(60px);
|
||||
border-radius: 50%;
|
||||
}
|
||||
@@ -112,18 +109,18 @@ const { yearReview } = useLocale()
|
||||
font-weight: 900;
|
||||
font-style: italic;
|
||||
letter-spacing: -0.05em;
|
||||
background-image: linear-gradient(to bottom right, var(--color-collab-light), var(--color-pink), var(--color-orange));
|
||||
background-image: linear-gradient(to bottom right, #e9d5ff, #f472b6, #fb923c);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 25px 25px var(--mask-15));
|
||||
filter: drop-shadow(0 25px 25px rgba(0, 0, 0, 0.15));
|
||||
word-break: break-word;
|
||||
padding: 0 0.5rem;
|
||||
}
|
||||
|
||||
.text-desc {
|
||||
margin-top: 3rem;
|
||||
color: var(--text-muted);
|
||||
color: #9ca3af;
|
||||
max-width: 24rem;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
|
||||
@@ -9,9 +9,9 @@
|
||||
|
||||
<Transition name="entry" appear>
|
||||
<div v-show="active" class="content">
|
||||
<span class="badge">{{ yearReview.firstSongBadge }}</span>
|
||||
<span class="badge">初次相遇</span>
|
||||
|
||||
<p class="text-intro">{{ yearReview.firstSongIntro }}</p>
|
||||
<p class="text-intro">这一年,你点的<span class="highlight">第一首歌</span>是</p>
|
||||
|
||||
<div class="vinyl-container">
|
||||
<div class="vinyl-record">
|
||||
@@ -53,17 +53,14 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
defineProps({
|
||||
data: Object,
|
||||
active: Boolean
|
||||
})
|
||||
const { yearReview } = useLocale()
|
||||
|
||||
const formatDate = (dateStr) => {
|
||||
const date = new Date(dateStr)
|
||||
return yearReview.value.date(date.getMonth() + 1, date.getDate())
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -77,7 +74,7 @@ const formatDate = (dateStr) => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-primary);
|
||||
background-color: var(--bg-primary, #111111);
|
||||
}
|
||||
|
||||
.bg-blur {
|
||||
@@ -105,25 +102,25 @@ const formatDate = (dateStr) => {
|
||||
display: inline-block;
|
||||
padding: 0.25rem 1rem;
|
||||
border-radius: 9999px;
|
||||
border: 1px solid var(--overlay-20);
|
||||
background: var(--overlay-5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(12px);
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 1.5rem;
|
||||
letter-spacing: 0.1em;
|
||||
color: var(--color-pink); /* brand-pink */
|
||||
color: #f472b6; /* brand-pink */
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.text-intro {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-primary-lighter);
|
||||
color: #d1d5db;
|
||||
font-weight: 300;
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
.highlight {
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
font-weight: 700;
|
||||
margin: 0 0.5rem;
|
||||
}
|
||||
@@ -142,9 +139,9 @@ const formatDate = (dateStr) => {
|
||||
height: 100%;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px var(--mask-50);
|
||||
border: 1px solid var(--overlay-10);
|
||||
background: var(--panel-bg-dark);
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
background: var(--bg-secondary, #111827);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -162,13 +159,13 @@ const formatDate = (dateStr) => {
|
||||
}
|
||||
|
||||
.placeholder-icon {
|
||||
color: var(--text-muted);
|
||||
color: #4b5563;
|
||||
}
|
||||
|
||||
.glossy-overlay {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: linear-gradient(to top right, var(--overlay-10), transparent);
|
||||
background: linear-gradient(to top right, rgba(255, 255, 255, 0.1), transparent);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
@@ -184,12 +181,12 @@ const formatDate = (dateStr) => {
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #ffffff);
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-muted);
|
||||
color: #9ca3af;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
@@ -198,7 +195,7 @@ const formatDate = (dateStr) => {
|
||||
.date-text {
|
||||
margin-top: 2rem;
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-muted);
|
||||
color: #9ca3af;
|
||||
font-family:
|
||||
'MiSans',
|
||||
system-ui,
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<Transition name="entry" appear>
|
||||
<div v-show="active" class="content">
|
||||
<div class="year-bg">{{ data.year }}</div>
|
||||
<div class="sub-title">{{ yearReview.reportTitle }}</div>
|
||||
<div class="sub-title">年度点歌报告</div>
|
||||
<h1 class="main-title">VoiceHub</h1>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -15,13 +15,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
defineProps({
|
||||
data: Object,
|
||||
active: Boolean
|
||||
})
|
||||
const { yearReview } = useLocale()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -34,7 +31,7 @@ const { yearReview } = useLocale()
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background: radial-gradient(circle at center, var(--panel-bg-dark), var(--bg-primary));
|
||||
background: radial-gradient(circle at center, var(--bg-secondary, #111827), #000000);
|
||||
}
|
||||
|
||||
.blob {
|
||||
@@ -49,7 +46,7 @@ const { yearReview } = useLocale()
|
||||
left: -10%;
|
||||
width: 500px;
|
||||
height: 500px;
|
||||
background: var(--year-review-blob-1-bg);
|
||||
background: rgba(139, 92, 246, 0.4); /* brand-purple */
|
||||
animation: spin 20s linear infinite;
|
||||
}
|
||||
|
||||
@@ -58,7 +55,7 @@ const { yearReview } = useLocale()
|
||||
right: -10%;
|
||||
width: 600px;
|
||||
height: 600px;
|
||||
background: var(--year-review-blob-2-bg);
|
||||
background: rgba(244, 114, 182, 0.2); /* brand-pink */
|
||||
animation: reverse-spin 25s linear infinite;
|
||||
}
|
||||
|
||||
@@ -76,7 +73,7 @@ const { yearReview } = useLocale()
|
||||
transform: translate(-50%, -50%);
|
||||
font-size: 14rem;
|
||||
font-weight: 900;
|
||||
color: var(--overlay-3);
|
||||
color: rgba(255, 255, 255, 0.03);
|
||||
pointer-events: none;
|
||||
user-select: none;
|
||||
line-height: 1;
|
||||
@@ -85,7 +82,7 @@ const { yearReview } = useLocale()
|
||||
.sub-title {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 300;
|
||||
color: var(--text-muted);
|
||||
color: #9ca3af;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
@@ -94,12 +91,12 @@ const { yearReview } = useLocale()
|
||||
.main-title {
|
||||
font-size: 5rem;
|
||||
font-weight: 900;
|
||||
background-image: linear-gradient(to right, var(--text-primary), var(--panel-border-light), var(--text-muted));
|
||||
background-image: linear-gradient(to right, #ffffff, #e5e7eb, #6b7280);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
margin-bottom: 2rem;
|
||||
filter: drop-shadow(0 25px 25px var(--mask-15));
|
||||
filter: drop-shadow(0 25px 25px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -5,18 +5,18 @@
|
||||
<Transition name="entry" appear>
|
||||
<div v-show="active" class="content">
|
||||
<div v-if="data.topPlatform" class="card platform-card">
|
||||
<span class="label">{{ yearReview.commonPlatform }}</span>
|
||||
<span class="label">常用平台</span>
|
||||
<span class="platform-value">{{ formatPlatform(data.topPlatform) }}</span>
|
||||
</div>
|
||||
|
||||
<div class="grid-container">
|
||||
<div v-if="data.activeMonth" class="card active-card">
|
||||
<span class="label">{{ yearReview.mostActive }}</span>
|
||||
<span class="active-value">{{ yearReview.activeMonth(data.activeMonth) }}</span>
|
||||
<span class="label">最活跃</span>
|
||||
<span class="active-value">{{ data.activeMonth }}<span class="unit">月</span></span>
|
||||
</div>
|
||||
|
||||
<div class="card votes-card">
|
||||
<span class="label">{{ yearReview.votesCast }}</span>
|
||||
<span class="label">投出票数</span>
|
||||
<span class="votes-value">{{ data.totalVotes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -26,16 +26,21 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
defineProps({
|
||||
data: Object,
|
||||
active: Boolean
|
||||
})
|
||||
const { yearReview } = useLocale()
|
||||
|
||||
const formatPlatform = (platform) => {
|
||||
const map = yearReview.value.platforms
|
||||
const map = {
|
||||
netease: '网易云音乐',
|
||||
bilibili: '哔哩哔哩',
|
||||
qq: 'QQ音乐',
|
||||
kugou: '酷狗音乐',
|
||||
kuwo: '酷我音乐',
|
||||
migu: '咪咕音乐',
|
||||
tencent: 'QQ音乐'
|
||||
}
|
||||
return map[platform] || platform
|
||||
}
|
||||
</script>
|
||||
@@ -49,7 +54,7 @@ const formatPlatform = (platform) => {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: var(--panel-bg-dark); /* gray-900 */
|
||||
background-color: var(--bg-secondary, #111827); /* gray-900 */
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
@@ -58,7 +63,7 @@ const formatPlatform = (platform) => {
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 50%;
|
||||
background: linear-gradient(to bottom, var(--panel-border-subtle), transparent);
|
||||
background: linear-gradient(to bottom, var(--bg-secondary, #1f2937), transparent);
|
||||
opacity: 0.2;
|
||||
}
|
||||
|
||||
@@ -74,8 +79,8 @@ const formatPlatform = (platform) => {
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
border-radius: 1.5rem;
|
||||
background: var(--overlay-5);
|
||||
border: 1px solid var(--overlay-10);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(12px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@@ -85,7 +90,7 @@ const formatPlatform = (platform) => {
|
||||
|
||||
.label {
|
||||
font-size: 0.75rem;
|
||||
color: var(--year-review-text-tertiary);
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 0.5rem; /* adjusted */
|
||||
@@ -94,7 +99,7 @@ const formatPlatform = (platform) => {
|
||||
.platform-value {
|
||||
font-size: 1.5rem; /* text-2xl */
|
||||
font-weight: 700;
|
||||
background-image: linear-gradient(to right, var(--color-error-light), var(--color-orange));
|
||||
background-image: linear-gradient(to right, #f87171, #fb923c);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
@@ -114,19 +119,19 @@ const formatPlatform = (platform) => {
|
||||
.active-value {
|
||||
font-size: 2.25rem; /* text-4xl */
|
||||
font-weight: 700;
|
||||
color: var(--year-review-text-yellow);
|
||||
color: #fde047; /* yellow-300 */
|
||||
}
|
||||
|
||||
.unit {
|
||||
font-size: 1rem;
|
||||
margin-left: 0.25rem;
|
||||
color: var(--text-muted);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.votes-value {
|
||||
font-size: 1.875rem; /* text-3xl */
|
||||
font-weight: 700;
|
||||
color: var(--year-review-text-cyan); /* cyan-300 */
|
||||
color: #67e8f9; /* cyan-300 */
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
|
||||
@@ -4,14 +4,14 @@
|
||||
|
||||
<Transition name="entry" appear>
|
||||
<div v-show="active" class="content">
|
||||
<div class="label">{{ yearReview.future }}</div>
|
||||
<div class="label">未来可期</div>
|
||||
|
||||
<div class="year-container">
|
||||
<h2 class="year-next">{{ data.year + 1 }}</h2>
|
||||
<div class="year-underline" />
|
||||
</div>
|
||||
|
||||
<p class="text-intro">{{ yearReview.futureDesc }}</p>
|
||||
<p class="text-intro">愿音乐继续陪伴你的每一天</p>
|
||||
|
||||
<div class="action-buttons">
|
||||
<NuxtLink to="/" class="btn-primary group">
|
||||
@@ -30,7 +30,7 @@
|
||||
<path d="m3 9 9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z" />
|
||||
<polyline points="9 22 9 12 15 12 15 22" />
|
||||
</svg>
|
||||
{{ yearReview.backHome }}
|
||||
返回首页
|
||||
</span>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
@@ -40,14 +40,11 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
defineProps({
|
||||
data: Object,
|
||||
active: Boolean
|
||||
})
|
||||
defineEmits(['share'])
|
||||
const { yearReview } = useLocale()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -59,13 +56,13 @@ const { yearReview } = useLocale()
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: var(--bg-primary);
|
||||
background-color: var(--bg-primary, #111111);
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: radial-gradient(circle at center, var(--panel-border-subtle), var(--bg-primary), var(--bg-primary));
|
||||
background: radial-gradient(circle at center, var(--bg-secondary, #1f2937), #000, #000);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
@@ -78,7 +75,7 @@ const { yearReview } = useLocale()
|
||||
}
|
||||
|
||||
.label {
|
||||
color: var(--year-review-text-tertiary);
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
letter-spacing: 0.5em;
|
||||
text-transform: uppercase;
|
||||
@@ -95,7 +92,7 @@ const { yearReview } = useLocale()
|
||||
font-size: 6rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
background-image: linear-gradient(to bottom, var(--text-primary), var(--panel-border-subtle));
|
||||
background-image: linear-gradient(to bottom, #fff, #1f2937);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
@@ -107,12 +104,12 @@ const { yearReview } = useLocale()
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
background: linear-gradient(to right, transparent, var(--overlay-20), transparent);
|
||||
background: linear-gradient(to right, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
}
|
||||
|
||||
.text-intro {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-muted);
|
||||
color: #9ca3af;
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
|
||||
@@ -135,8 +132,8 @@ const { yearReview } = useLocale()
|
||||
width: 100%;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--text-primary);
|
||||
color: var(--bg-primary);
|
||||
background: var(--card-bg, #fff);
|
||||
color: var(--text-primary, #000);
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
overflow: hidden;
|
||||
@@ -153,7 +150,7 @@ const { yearReview } = useLocale()
|
||||
.btn-bg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: var(--panel-border-light);
|
||||
background: #e5e7eb;
|
||||
transform: translateY(100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
@@ -174,9 +171,9 @@ const { yearReview } = useLocale()
|
||||
width: 100%;
|
||||
padding: 1rem 2rem;
|
||||
border-radius: 9999px;
|
||||
background: var(--overlay-10);
|
||||
border: 1px solid var(--overlay-20);
|
||||
color: var(--text-primary);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
color: var(--text-primary, #ffffff);
|
||||
font-weight: 700;
|
||||
font-size: 1.125rem;
|
||||
backdrop-filter: blur(12px);
|
||||
@@ -188,7 +185,7 @@ const { yearReview } = useLocale()
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: var(--overlay-20);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.btn-secondary:active {
|
||||
|
||||
@@ -12,27 +12,27 @@
|
||||
</div>
|
||||
|
||||
<div class="title-section">
|
||||
<h1 class="main-title">{{ yearReview.reportTitle }}</h1>
|
||||
<h1 class="main-title">年度点歌报告</h1>
|
||||
<p class="subtitle">Music Journey</p>
|
||||
</div>
|
||||
|
||||
<div class="stats-grid">
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ data.totalRequests }}</div>
|
||||
<div class="stat-label">{{ yearReview.requests }}</div>
|
||||
<div class="stat-label">点歌次数</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ data.playedRequests }}</div>
|
||||
<div class="stat-label">{{ yearReview.played }}</div>
|
||||
<div class="stat-label">已播放</div>
|
||||
</div>
|
||||
<div class="stat-item">
|
||||
<div class="stat-value">{{ data.totalVotes }}</div>
|
||||
<div class="stat-label">{{ yearReview.votes }}</div>
|
||||
<div class="stat-label">投票数</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<div class="footer-text">{{ yearReview.footerSlogan }}</div>
|
||||
<div class="footer-text">用音乐记录每一刻</div>
|
||||
<div class="footer-line" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -41,7 +41,6 @@
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
defineProps({
|
||||
data: {
|
||||
@@ -51,7 +50,6 @@ defineProps({
|
||||
})
|
||||
|
||||
const shareCardRef = ref(null)
|
||||
const { yearReview } = useLocale()
|
||||
|
||||
defineExpose({
|
||||
shareCardRef
|
||||
@@ -65,7 +63,7 @@ defineExpose({
|
||||
height: 100%;
|
||||
min-height: 600px;
|
||||
overflow: hidden;
|
||||
background: linear-gradient(135deg, var(--panel-bg-tertiary) 0%, var(--panel-bg-tertiary) 50%, var(--panel-bg-tertiary) 100%);
|
||||
background: linear-gradient(135deg, var(--bg-secondary, #0f0f23) 0%, #1a1a2e 50%, #16213e 100%);
|
||||
}
|
||||
|
||||
.card-background {
|
||||
@@ -77,9 +75,9 @@ defineExpose({
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background:
|
||||
radial-gradient(circle at 20% 30%, var(--index-card-glow) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 70%, var(--year-review-gradient-overlay-end) 0%, transparent 50%),
|
||||
radial-gradient(circle at 50% 50%, var(--primary-10) 0%, transparent 60%);
|
||||
radial-gradient(circle at 20% 30%, rgba(139, 92, 246, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 70%, rgba(244, 114, 182, 0.15) 0%, transparent 50%),
|
||||
radial-gradient(circle at 50% 50%, rgba(59, 130, 246, 0.1) 0%, transparent 60%);
|
||||
}
|
||||
|
||||
.noise-overlay {
|
||||
@@ -108,7 +106,7 @@ defineExpose({
|
||||
.logo {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, var(--color-collab), var(--color-pink));
|
||||
background: linear-gradient(135deg, #8b5cf6, #ec4899);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
@@ -117,7 +115,7 @@ defineExpose({
|
||||
.year {
|
||||
font-size: 3rem;
|
||||
font-weight: 900;
|
||||
color: var(--overlay-10);
|
||||
color: rgba(255, 255, 255, 0.1);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
@@ -129,7 +127,7 @@ defineExpose({
|
||||
.main-title {
|
||||
font-size: 3.5rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, var(--text-primary) 0%, var(--panel-border-light) 50%, var(--text-muted) 100%);
|
||||
background: linear-gradient(135deg, var(--card-bg, #ffffff) 0%, #e5e7eb 50%, #9ca3af 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
@@ -139,7 +137,7 @@ defineExpose({
|
||||
|
||||
.subtitle {
|
||||
font-size: 1.25rem;
|
||||
color: var(--overlay-50);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.3em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
@@ -154,16 +152,16 @@ defineExpose({
|
||||
.stat-item {
|
||||
text-align: center;
|
||||
padding: 1.5rem;
|
||||
background: var(--overlay-3);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
border-radius: 1rem;
|
||||
border: 1px solid var(--overlay-8);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2.5rem;
|
||||
font-weight: 900;
|
||||
background: linear-gradient(135deg, var(--color-accent-light), var(--color-accent-light));
|
||||
background: linear-gradient(135deg, #60a5fa, #3b82f6);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
@@ -172,7 +170,7 @@ defineExpose({
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: var(--overlay-50);
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
@@ -183,7 +181,7 @@ defineExpose({
|
||||
|
||||
.footer-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--overlay-40);
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
letter-spacing: 0.1em;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
@@ -191,7 +189,7 @@ defineExpose({
|
||||
.footer-line {
|
||||
width: 100px;
|
||||
height: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--overlay-20), transparent);
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent);
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,23 +4,23 @@
|
||||
|
||||
<Transition name="entry" appear>
|
||||
<div v-show="active" class="content">
|
||||
<div class="label">{{ yearReview.musicJourney }}</div>
|
||||
<div class="label">音乐足迹</div>
|
||||
|
||||
<p class="text-intro">{{ yearReview.totalRequestsIntro }}</p>
|
||||
<p class="text-intro">这一年,你一共发起了</p>
|
||||
|
||||
<div class="number-container">
|
||||
<span class="big-number">{{ data.totalRequests.toLocaleString() }}</span>
|
||||
<span class="suffix">{{ yearReview.requestCount }}</span>
|
||||
<span class="suffix">次点歌</span>
|
||||
</div>
|
||||
|
||||
<div class="sub-stats">
|
||||
<div v-if="data.playedRequests > 0" class="sub-content">
|
||||
<p class="text-base">
|
||||
{{ yearReview.playedSummary(data.playedRequests) }}
|
||||
其中,<span class="highlight-green">{{ data.playedRequests }}</span> 首
|
||||
</p>
|
||||
<p class="text-sub">{{ yearReview.playedDesc }}</p>
|
||||
<p class="text-sub">在广播站被播出,传递给了更多人</p>
|
||||
</div>
|
||||
<p v-else class="text-sub">{{ yearReview.pendingPlayedDesc }}</p>
|
||||
<p v-else class="text-sub">期待你的歌曲早日被播出</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
@@ -28,13 +28,10 @@
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
defineProps({
|
||||
data: Object,
|
||||
active: Boolean
|
||||
})
|
||||
const { yearReview } = useLocale()
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -46,7 +43,7 @@ const { yearReview } = useLocale()
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
background-color: var(--panel-bg-darkest); /* gray-950 */
|
||||
background-color: var(--surface-primary, var(--bg-primary, #030712));
|
||||
}
|
||||
|
||||
.bg-gradient {
|
||||
@@ -54,7 +51,7 @@ const { yearReview } = useLocale()
|
||||
inset: 0;
|
||||
background: radial-gradient(
|
||||
ellipse at top right,
|
||||
var(--year-review-stats-bg-gradient),
|
||||
rgba(30, 58, 138, 0.4),
|
||||
transparent,
|
||||
transparent
|
||||
);
|
||||
@@ -69,7 +66,7 @@ const { yearReview } = useLocale()
|
||||
|
||||
.label {
|
||||
margin-bottom: 1rem;
|
||||
color: var(--color-accent-light); /* brand-blue */
|
||||
color: #3b82f6; /* brand-blue */
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 1.125rem;
|
||||
@@ -77,7 +74,7 @@ const { yearReview } = useLocale()
|
||||
|
||||
.text-intro {
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-muted);
|
||||
color: #9ca3af;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
@@ -94,16 +91,16 @@ const { yearReview } = useLocale()
|
||||
font-size: 10rem;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
background-image: linear-gradient(to bottom, var(--color-accent-light), var(--color-accent-light-hover));
|
||||
background-image: linear-gradient(to bottom, #60a5fa, #1d4ed8);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
filter: drop-shadow(0 0 30px var(--year-review-stat-number-shadow));
|
||||
filter: drop-shadow(0 0 30px rgba(37, 99, 235, 0.5));
|
||||
}
|
||||
|
||||
.suffix {
|
||||
font-size: 1.5rem;
|
||||
color: var(--year-review-text-tertiary);
|
||||
color: #6b7280;
|
||||
font-weight: 500;
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
@@ -112,8 +109,8 @@ const { yearReview } = useLocale()
|
||||
margin-top: 3rem;
|
||||
padding: 1.5rem;
|
||||
border-radius: 1rem;
|
||||
background: var(--overlay-5);
|
||||
border: 1px solid var(--overlay-10);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
@@ -125,19 +122,19 @@ const { yearReview } = useLocale()
|
||||
|
||||
.text-base {
|
||||
font-size: 1.125rem;
|
||||
color: var(--text-primary-lighter);
|
||||
color: #d1d5db;
|
||||
}
|
||||
|
||||
.highlight-green {
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: var(--color-success-light);
|
||||
color: #4ade80;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.text-sub {
|
||||
font-size: 1rem;
|
||||
color: var(--text-muted);
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
+110
-2314
File diff suppressed because it is too large
Load Diff
+423
-262
@@ -1,17 +1,12 @@
|
||||
import {bigint, boolean, index, integer, pgEnum, pgTable, serial, text, timestamp, uniqueIndex, uuid, varchar, unique} from 'drizzle-orm/pg-core';
|
||||
import {bigint, boolean, integer, pgEnum, pgTable, serial, text, timestamp, uuid, varchar, unique, index, uniqueIndex} from 'drizzle-orm/pg-core';
|
||||
import {relations, sql} from 'drizzle-orm';
|
||||
|
||||
// 枚举定义
|
||||
export const blacklistTypeEnum = pgEnum('BlacklistType', ['SONG', 'KEYWORD', 'LANGUAGE', 'GENRE']);
|
||||
export const userStatusEnum = pgEnum('user_status', ['active', 'pending', 'withdrawn', 'graduate', 'rejected']);
|
||||
export const userStatusEnum = pgEnum('user_status', ['active', 'withdrawn', 'graduate', 'pending', 'rejected']);
|
||||
export const collaboratorStatusEnum = pgEnum('collaborator_status', ['PENDING', 'ACCEPTED', 'REJECTED']);
|
||||
export const replayRequestStatusEnum = pgEnum('replay_request_status', ['PENDING', 'FULFILLED', 'REJECTED']);
|
||||
export const cardCodeStatusEnum = pgEnum('card_code_status', [
|
||||
'AVAILABLE',
|
||||
'LOCKED',
|
||||
'REDEEMED',
|
||||
'INVALID'
|
||||
]);
|
||||
export const cardCodeStatusEnum = pgEnum('card_code_status', ['AVAILABLE', 'LOCKED', 'REDEEMED', 'INVALID']);
|
||||
|
||||
// 用户表
|
||||
export const users = pgTable('User', {
|
||||
@@ -22,6 +17,7 @@ export const users = pgTable('User', {
|
||||
name: text('name'),
|
||||
grade: text('grade'),
|
||||
class: text('class'),
|
||||
studentId: text('studentId'),
|
||||
role: text('role').default('USER').notNull(),
|
||||
password: text('password').notNull(),
|
||||
email: text('email'),
|
||||
@@ -30,27 +26,31 @@ export const users = pgTable('User', {
|
||||
lastLoginIp: text('lastLoginIp'),
|
||||
passwordChangedAt: timestamp('passwordChangedAt'),
|
||||
forcePasswordChange: boolean('forcePasswordChange').default(false).notNull(),
|
||||
tokenVersion: integer('tokenVersion').default(0).notNull(),
|
||||
meowNickname: text('meowNickname'),
|
||||
meowBoundAt: timestamp('meowBoundAt'),
|
||||
avatarProvider: text('avatarProvider'),
|
||||
avatarProviderUserId: text('avatarProviderUserId'),
|
||||
status: userStatusEnum('status').default('active').notNull(),
|
||||
statusChangedAt: timestamp('statusChangedAt').defaultNow(),
|
||||
statusChangedBy: integer('statusChangedBy'),
|
||||
// 注册时可选填写的备注,管理员审核时可修改
|
||||
tokenVersion: integer('tokenVersion').default(0).notNull(),
|
||||
avatarProvider: text('avatarProvider'),
|
||||
avatarProviderUserId: text('avatarProviderUserId'),
|
||||
remark: text('remark'),
|
||||
}, (table) => [uniqueIndex('User_username_unique').on(table.username)]);
|
||||
}, (table) => [
|
||||
uniqueIndex('User_username_unique').on(table.username),
|
||||
]);
|
||||
|
||||
// 登录会话表,id 与 JWT 的 jti 一致
|
||||
// 认证会话表
|
||||
export const authSessions = pgTable('auth_sessions', {
|
||||
id: uuid('id').primaryKey(),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
sessionToken: varchar('session_token', { length: 255 }).notNull().unique(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
userAgent: text('user_agent'),
|
||||
lastActiveAt: timestamp('last_active_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
isRevoked: boolean('is_revoked').default(false).notNull(),
|
||||
browser: text('browser'),
|
||||
operatingSystem: text('operating_system'),
|
||||
device: text('device'),
|
||||
@@ -58,12 +58,23 @@ export const authSessions = pgTable('auth_sessions', {
|
||||
revokedAt: timestamp('revoked_at', { withTimezone: true }),
|
||||
revokedReason: text('revoked_reason')
|
||||
}, (table) => [
|
||||
index('auth_sessions_user_active_idx').on(table.userId, table.revokedAt, table.expiresAt),
|
||||
index('auth_sessions_last_active_idx').on(table.lastActiveAt)
|
||||
index('auth_sessions_user_active_idx').on(table.userId, table.isRevoked, table.expiresAt),
|
||||
index('auth_sessions_expires_at_idx').on(table.expiresAt),
|
||||
index('auth_sessions_last_active_idx').on(table.lastActiveAt),
|
||||
uniqueIndex('auth_sessions_session_token_unique').on(table.sessionToken),
|
||||
]);
|
||||
|
||||
export type AuthSession = typeof authSessions.$inferSelect;
|
||||
export type NewAuthSession = typeof authSessions.$inferInsert;
|
||||
// 年级班级配置表
|
||||
export const gradeClasses = pgTable('GradeClass', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
grade: text('grade').notNull(),
|
||||
class: text('class').notNull(),
|
||||
enabled: boolean('enabled').default(true).notNull(),
|
||||
}, (table) => [
|
||||
unique('grade_class_unique').on(table.grade, table.class),
|
||||
]);
|
||||
|
||||
// 播出时段表
|
||||
export const playTimes = pgTable('PlayTime', {
|
||||
@@ -77,25 +88,15 @@ export const playTimes = pgTable('PlayTime', {
|
||||
description: text('description'),
|
||||
});
|
||||
|
||||
// 年级班级配置表(选择器选项来源,优先于从用户提取)
|
||||
export const gradeClass = pgTable(
|
||||
'GradeClass',
|
||||
{
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
grade: text('grade').notNull(),
|
||||
class: text('class').notNull(),
|
||||
},
|
||||
(table) => [unique('GradeClass_grade_class_unique').on(table.grade, table.class)],
|
||||
);
|
||||
|
||||
// 歌曲表
|
||||
export const songs = pgTable('Song', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
title: text('title').notNull(),
|
||||
kgHash: varchar('kg_hash', { length: 64 }),
|
||||
title: varchar('title', { length: 100 }).notNull(),
|
||||
singer: varchar('singer', { length: 100 }),
|
||||
coverUrl: varchar('cover_url', { length: 255 }),
|
||||
artist: text('artist').notNull(),
|
||||
requesterId: integer('requesterId').notNull(),
|
||||
played: boolean('played').default(false).notNull(),
|
||||
@@ -106,17 +107,18 @@ export const songs = pgTable('Song', {
|
||||
playUrl: text('playUrl'),
|
||||
musicPlatform: text('musicPlatform'),
|
||||
musicId: text('musicId'),
|
||||
durationSeconds: integer('durationSeconds'),
|
||||
submissionNote: text('submissionNote'),
|
||||
submissionNotePublic: boolean('submissionNotePublic').default(false).notNull(),
|
||||
// 公开留言审核状态:'pending' | 'approved' | 'rejected' | null(null 表示未启用审核或不涉及)
|
||||
submissionNotePublicStatus: text('submissionNotePublicStatus'),
|
||||
hitRequestId: integer(),
|
||||
expectedPlayDate: text('expectedPlayDate'),
|
||||
expectedPlaySequence: integer('expectedPlaySequence'),
|
||||
durationSeconds: integer('durationSeconds'),
|
||||
submissionNotePublicStatus: text('submissionNotePublicStatus'),
|
||||
cardCodeId: integer('cardCodeId').references(() => cardCodes.id, { onDelete: 'set null' }),
|
||||
}, (table) => [
|
||||
index('song_card_code_id_idx').on(table.cardCodeId),
|
||||
index('song_semester_created_at_idx').on(table.semester, table.createdAt),
|
||||
index('song_requester_id_idx').on(table.requesterId)
|
||||
index('song_requester_id_idx').on(table.requesterId),
|
||||
]);
|
||||
|
||||
// 投票表
|
||||
@@ -127,9 +129,18 @@ export const votes = pgTable('Vote', {
|
||||
userId: integer('userId').notNull(),
|
||||
}, (table) => [
|
||||
unique('vote_song_user_unique').on(table.songId, table.userId),
|
||||
index('vote_user_song_idx').on(table.userId, table.songId)
|
||||
index('vote_user_song_idx').on(table.userId, table.songId),
|
||||
]);
|
||||
|
||||
export const songComments = pgTable('SongComment', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
songId: integer('songId').notNull(),
|
||||
userId: integer('userId').notNull(),
|
||||
content: text('content').notNull(),
|
||||
});
|
||||
|
||||
// 排期表
|
||||
export const schedules = pgTable('Schedule', {
|
||||
id: serial('id').primaryKey(),
|
||||
@@ -140,27 +151,39 @@ export const schedules = pgTable('Schedule', {
|
||||
played: boolean('played').default(false).notNull(),
|
||||
sequence: integer('sequence').default(1).notNull(),
|
||||
playTimeId: integer('playTimeId'),
|
||||
// 草稿支持字段
|
||||
isDraft: boolean('isDraft').default(false).notNull(),
|
||||
publishedAt: timestamp('publishedAt'),
|
||||
replayRequestId: integer('replay_request_id'),
|
||||
}, (table) => [
|
||||
index('schedule_published_song_idx').on(table.isDraft, table.songId, table.playDate),
|
||||
index('schedule_published_date_idx').on(table.isDraft, table.playDate)
|
||||
index('schedule_published_song_idx').on(table.publishedAt, table.songId),
|
||||
index('schedule_published_date_idx').on(table.publishedAt, table.playDate),
|
||||
]);
|
||||
|
||||
// 排期备选池表
|
||||
export const scheduleSongPool = pgTable('ScheduleSongPool', {
|
||||
export const scheduleSongPool = pgTable('schedule_song_pool', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
songId: integer('songId').notNull().references(() => songs.id, { onDelete: 'cascade' }),
|
||||
addedBy: integer('addedBy').references(() => users.id, { onDelete: 'set null' })
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
songId: integer('song_id').notNull().references(() => songs.id, { onDelete: 'cascade' }),
|
||||
targetPlayDate: timestamp('target_play_date'),
|
||||
priority: integer('priority').default(0).notNull(),
|
||||
addedBy: integer('added_by'),
|
||||
note: text('note'),
|
||||
status: text('status').default('PENDING').notNull(),
|
||||
}, (table) => [
|
||||
unique('schedule_song_pool_song_unique').on(table.songId)
|
||||
index('schedule_song_pool_song_id_idx').on(table.songId),
|
||||
index('schedule_song_pool_target_date_idx').on(table.targetPlayDate),
|
||||
]);
|
||||
|
||||
export type ScheduleSongPool = typeof scheduleSongPool.$inferSelect;
|
||||
export type NewScheduleSongPool = typeof scheduleSongPool.$inferInsert;
|
||||
// 期望播出日期表
|
||||
export const expectedPlayDates = pgTable('ExpectedPlayDate', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
date: text('date').notNull().unique(),
|
||||
availableSequences: text('availableSequences').notNull(),
|
||||
enabled: boolean('enabled').default(true).notNull(),
|
||||
});
|
||||
|
||||
// 通知表
|
||||
export const notifications = pgTable('Notification', {
|
||||
@@ -168,27 +191,21 @@ export const notifications = pgTable('Notification', {
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
type: text('type').notNull(),
|
||||
batchId: text('batchId'),
|
||||
source: text('source').default('SYSTEM').notNull(),
|
||||
senderId: integer('senderId'),
|
||||
senderName: text('senderName'),
|
||||
senderUsername: text('senderUsername'),
|
||||
title: text('title'),
|
||||
message: text('message').notNull(),
|
||||
important: boolean('important').default(false).notNull(),
|
||||
read: boolean('read').default(false).notNull(),
|
||||
userDeleted: boolean('userDeleted').default(false).notNull(),
|
||||
userId: integer('userId').notNull(),
|
||||
songId: integer('songId'),
|
||||
batchId: uuid('batch_id'),
|
||||
source: text('source').default('SYSTEM').notNull(),
|
||||
senderId: integer('sender_id'),
|
||||
senderName: text('sender_name'),
|
||||
senderUsername: text('sender_username'),
|
||||
title: text('title'),
|
||||
important: boolean('important').default(false).notNull(),
|
||||
userDeleted: boolean('user_deleted').default(false).notNull(),
|
||||
}, (table) => [
|
||||
index('notification_user_important_read_created_idx').on(
|
||||
table.userId,
|
||||
table.userDeleted,
|
||||
table.important,
|
||||
table.read,
|
||||
table.createdAt
|
||||
),
|
||||
index('notification_batch_id_idx').on(table.batchId)
|
||||
index('notification_user_important_read_created_idx').on(table.userId, table.important, table.read, table.createdAt),
|
||||
index('notification_batch_id_idx').on(table.batchId),
|
||||
]);
|
||||
|
||||
// 通知设置表
|
||||
@@ -219,30 +236,22 @@ export const systemSettings = pgTable('SystemSettings', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
instanceId: text('instance_id'),
|
||||
telemetryEnabled: boolean('telemetryEnabled').default(true).notNull(),
|
||||
enablePlayTimeSelection: boolean('enablePlayTimeSelection').default(false).notNull(),
|
||||
siteTitle: text('siteTitle'),
|
||||
siteLogoUrl: text('siteLogoUrl'),
|
||||
schoolLogoHomeUrl: text('schoolLogoHomeUrl'),
|
||||
schoolLogoPrintUrl: text('schoolLogoPrintUrl'),
|
||||
siteDescription: text('siteDescription'),
|
||||
siteVersion: text('siteVersion'),
|
||||
submissionGuidelines: text('submissionGuidelines'),
|
||||
icpNumber: text('icpNumber'),
|
||||
gonganNumber: text('gonganNumber'),
|
||||
showBeianIcon: boolean('showBeianIcon').default(false).notNull(),
|
||||
enableSubmissionLimit: boolean('enableSubmissionLimit').default(false).notNull(),
|
||||
dailySubmissionLimit: integer('dailySubmissionLimit'),
|
||||
weeklySubmissionLimit: integer('weeklySubmissionLimit'),
|
||||
monthlySubmissionLimit: integer('monthlySubmissionLimit'),
|
||||
// 用户可见播出排期范围
|
||||
scheduleDaysBeforeEnabled: boolean('scheduleDaysBeforeEnabled').default(false).notNull(),
|
||||
scheduleDaysBefore: integer('scheduleDaysBefore').default(1).notNull(),
|
||||
scheduleDaysAfterEnabled: boolean('scheduleDaysAfterEnabled').default(false).notNull(),
|
||||
scheduleDaysAfter: integer('scheduleDaysAfter').default(1).notNull(),
|
||||
showBlacklistKeywords: boolean('showBlacklistKeywords').default(false).notNull(),
|
||||
hideStudentInfo: boolean('hideStudentInfo').default(true).notNull(),
|
||||
// SMTP 邮件配置
|
||||
smtpEnabled: boolean('smtpEnabled').default(false).notNull(),
|
||||
smtpHost: text('smtpHost'),
|
||||
smtpPort: integer('smtpPort').default(587),
|
||||
@@ -252,63 +261,75 @@ export const systemSettings = pgTable('SystemSettings', {
|
||||
smtpFromEmail: text('smtpFromEmail'),
|
||||
smtpFromName: text('smtpFromName').default('校园广播站'),
|
||||
enableRequestTimeLimitation: boolean('enableRequestTimeLimitation').default(false).notNull(),
|
||||
forceBlockAllRequests: boolean().default(false).notNull(),
|
||||
forcePasswordChangeOnFirstLogin: boolean('forcePasswordChangeOnFirstLogin').default(false).notNull(),
|
||||
forceBlockAllRequests: boolean('forceBlockAllRequests').default(false).notNull(),
|
||||
enableReplayRequests: boolean('enableReplayRequests').default(false).notNull(),
|
||||
enableCollaborativeSubmission: boolean('enableCollaborativeSubmission').default(true).notNull(),
|
||||
enableSubmissionRemarks: boolean('enableSubmissionRemarks').default(false).notNull(),
|
||||
// 卡密点歌相关开关(用于允许用户使用卡密或强制使用卡密投稿)
|
||||
enableCardCodeRequests: boolean('enableCardCodeRequests').default(false).notNull(),
|
||||
requireCardCodeForRequests: boolean('requireCardCodeForRequests').default(false).notNull(),
|
||||
enableCardCodeLimitBypass: boolean('enableCardCodeLimitBypass').default(false).notNull(),
|
||||
// 重复投稿限制:对同一首歌/同一歌手,在其进入排期后的设定时间窗口内禁止再次投稿
|
||||
submissionNoteRequiresApproval: boolean('submissionNoteRequiresApproval').default(false).notNull(),
|
||||
instanceId: text('instanceId'),
|
||||
telemetryEnabled: boolean('telemetryEnabled').default(true).notNull(),
|
||||
showBeianIcon: boolean('showBeianIcon').default(true).notNull(),
|
||||
scheduleDaysDisplay: integer('scheduleDaysDisplay').default(7).notNull(),
|
||||
scheduleDaysEditable: integer('scheduleDaysEditable').default(3).notNull(),
|
||||
scheduleDaysRequestable: integer('scheduleDaysRequestable').default(14).notNull(),
|
||||
scheduleDaysBeforeEnabled: boolean('scheduleDaysBeforeEnabled').default(false).notNull(),
|
||||
scheduleDaysBefore: integer('scheduleDaysBefore').default(1).notNull(),
|
||||
scheduleDaysAfterEnabled: boolean('scheduleDaysAfterEnabled').default(false).notNull(),
|
||||
scheduleDaysAfter: integer('scheduleDaysAfter').default(1).notNull(),
|
||||
forcePasswordChangeOnFirstLogin: boolean('forcePasswordChangeOnFirstLogin').default(false).notNull(),
|
||||
enableSubmissionRestriction: boolean('enableSubmissionRestriction').default(false).notNull(),
|
||||
submissionRestrictionScope: text('submissionRestrictionScope').default('all').notNull(),
|
||||
sameSongRestrictionHours: integer('sameSongRestrictionHours'),
|
||||
sameArtistRestrictionHours: integer('sameArtistRestrictionHours'),
|
||||
|
||||
// 验证码配置
|
||||
captchaProvider: text('captchaProvider').default('graphic').notNull(),
|
||||
cardCodeEnabled: boolean('cardCodeEnabled').default(false).notNull(),
|
||||
cardCodeSubmissionRequired: boolean('cardCodeSubmissionRequired').default(false).notNull(),
|
||||
cardCodeVoteEnabled: boolean('cardCodeVoteEnabled').default(false).notNull(),
|
||||
cardCodeReplayEnabled: boolean('cardCodeReplayEnabled').default(false).notNull(),
|
||||
enableCardCodeRequests: boolean('enableCardCodeRequests').default(false).notNull(),
|
||||
requireCardCodeForRequests: boolean('requireCardCodeForRequests').default(false).notNull(),
|
||||
enableCardCodeLimitBypass: boolean('enableCardCodeLimitBypass').default(false).notNull(),
|
||||
submissionRestrictionEnabled: boolean('submissionRestrictionEnabled').default(false).notNull(),
|
||||
submissionRestrictionGradeEnabled: boolean('submissionRestrictionGradeEnabled').default(false).notNull(),
|
||||
submissionRestrictionClassEnabled: boolean('submissionRestrictionClassEnabled').default(false).notNull(),
|
||||
submissionRestrictionGraduateEnabled: boolean('submissionRestrictionGraduateEnabled').default(false).notNull(),
|
||||
submissionRestrictionWithdrawnEnabled: boolean('submissionRestrictionWithdrawnEnabled').default(false).notNull(),
|
||||
captchaProvider: text('captchaProvider').default('TURNSTILE'),
|
||||
captchaEnabled: boolean('captchaEnabled').default(false).notNull(),
|
||||
turnstileSiteKey: text('turnstileSiteKey'),
|
||||
turnstileSecretKey: text('turnstileSecretKey'),
|
||||
|
||||
// 注册配置
|
||||
allowRegister: boolean('allowRegister').default(false).notNull(),
|
||||
registerRequiresApproval: boolean('registerRequiresApproval').default(true).notNull(),
|
||||
oauthRegisterRequiresApproval: boolean('oauthRegisterRequiresApproval').default(true).notNull(),
|
||||
// 注册邮箱(选填→管理员开关控制;需 SMTP 已配置)
|
||||
allowRegister: boolean('allowRegister').default(true).notNull(),
|
||||
registerRequireEmail: boolean('registerRequireEmail').default(false).notNull(),
|
||||
registerRequireEmailVerify: boolean('registerRequireEmailVerify').default(false).notNull(),
|
||||
registerDefaultRole: text('registerDefaultRole').default('USER'),
|
||||
registerEnabledGradeClasses: text('registerEnabledGradeClasses'),
|
||||
registerRequiresApproval: boolean('registerRequiresApproval').default(false).notNull(),
|
||||
oauthRegisterRequiresApproval: boolean('oauthRegisterRequiresApproval').default(false).notNull(),
|
||||
registerEmailRequired: boolean('registerEmailRequired').default(false).notNull(),
|
||||
// 注册时必须选择年级班级(本地注册与第三方创建账户均强制)
|
||||
registerRequiresGradeClass: boolean('registerRequiresGradeClass').default(false).notNull(),
|
||||
// 投稿公开留言审核
|
||||
submissionNoteRequiresApproval: boolean('submissionNoteRequiresApproval').default(false).notNull(),
|
||||
|
||||
// OAuth 配置
|
||||
oauthEnabled: boolean('oauthEnabled').default(false).notNull(),
|
||||
oauthAllowRegister: boolean('oauthAllowRegister').default(true).notNull(),
|
||||
oauthAllowLogin: boolean('oauthAllowLogin').default(true).notNull(),
|
||||
oauthAllowBind: boolean('oauthAllowBind').default(true).notNull(),
|
||||
allowOAuthRegistration: boolean('allowOAuthRegistration').default(false).notNull(),
|
||||
oauthRedirectUri: text('oauthRedirectUri'),
|
||||
oauthStateSecret: text('oauthStateSecret'),
|
||||
oauthProviders: text('oauthProviders').default('[]'),
|
||||
// GitHub OAuth
|
||||
githubOAuthEnabled: boolean('githubOAuthEnabled').default(false).notNull(),
|
||||
githubClientId: text('githubClientId'),
|
||||
githubClientSecret: text('githubClientSecret'),
|
||||
// Casdoor OAuth
|
||||
casdoorOAuthEnabled: boolean('casdoorOAuthEnabled').default(false).notNull(),
|
||||
casdoorServerUrl: text('casdoorServerUrl'),
|
||||
casdoorClientId: text('casdoorClientId'),
|
||||
casdoorClientSecret: text('casdoorClientSecret'),
|
||||
casdoorOrganizationName: text('casdoorOrganizationName'),
|
||||
// Google OAuth
|
||||
googleOAuthEnabled: boolean('googleOAuthEnabled').default(false).notNull(),
|
||||
googleClientId: text('googleClientId'),
|
||||
googleClientSecret: text('googleClientSecret'),
|
||||
// 聚合登陆
|
||||
aggregateOAuthEnabled: boolean('aggregateOAuthEnabled').default(false).notNull(),
|
||||
aggregateOAuthAppId: text('aggregateOAuthAppId'),
|
||||
aggregateOAuthAppKey: text('aggregateOAuthAppKey'),
|
||||
aggregateOAuthLoginType: text('aggregateOAuthLoginType').default('qq'),
|
||||
aggregateOAuthEndpoint: text('aggregateOAuthEndpoint'),
|
||||
// Custom OAuth2
|
||||
customOAuthEnabled: boolean('customOAuthEnabled').default(false).notNull(),
|
||||
customOAuthDisplayName: text('customOAuthDisplayName'),
|
||||
customOAuthAuthorizeUrl: text('customOAuthAuthorizeUrl'),
|
||||
@@ -322,24 +343,17 @@ export const systemSettings = pgTable('SystemSettings', {
|
||||
customOAuthNameField: text('customOAuthNameField'),
|
||||
customOAuthEmailField: text('customOAuthEmailField'),
|
||||
customOAuthAvatarField: text('customOAuthAvatarField'),
|
||||
// 图形验证码
|
||||
captchaEnabled: boolean('captchaEnabled').default(false).notNull(),
|
||||
captchaMaxFailures: integer('captchaMaxFailures').default(3).notNull(),
|
||||
|
||||
// 自动备份配置
|
||||
autoBackupEnabled: boolean('autoBackupEnabled').default(false).notNull(),
|
||||
autoBackupIntervalHours: integer('autoBackupIntervalHours').default(24).notNull(),
|
||||
autoBackupRetentionDays: integer('autoBackupRetentionDays').default(30).notNull(),
|
||||
autoBackupConfig: text('autoBackupConfig'),
|
||||
|
||||
// 站点统计代码(任意站点统计平台的 HTML/JS 片段,注入 SSR 页面 <head>)
|
||||
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
|
||||
statisticsCode: text('statisticsCode'),
|
||||
|
||||
// 主题管理配置
|
||||
defaultTheme: text('defaultTheme').default('System').notNull(),
|
||||
enabledThemes: text('enabledThemes').default('["System","ClassicDark","ClassicLight","ModernLight"]').notNull(),
|
||||
// 平台管理配置
|
||||
enabledPlatforms: text('enabledPlatforms').default('["netease","tencent","bilibili","migu"]'),
|
||||
platformOrder: text('platformOrder').default('["netease","tencent","bilibili","migu"]'),
|
||||
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
|
||||
defaultTheme: text('defaultTheme').default('ClassicLight').notNull(),
|
||||
enabledThemes: text('enabledThemes').default('[\"ClassicLight\",\"ModernLight\"]').notNull(),
|
||||
enabledPlatforms: text('enabledPlatforms'),
|
||||
platformOrder: text('platformOrder'),
|
||||
});
|
||||
|
||||
// 歌曲黑名单表
|
||||
@@ -354,6 +368,124 @@ export const songBlacklists = pgTable('SongBlacklist', {
|
||||
createdBy: integer('createdBy'),
|
||||
});
|
||||
|
||||
// 卡密表
|
||||
export const cardCodes = pgTable('card_codes', {
|
||||
id: serial('id').primaryKey(),
|
||||
code: varchar('code', { length: 64 }).notNull().unique(),
|
||||
status: cardCodeStatusEnum('status').default('AVAILABLE').notNull(),
|
||||
type: text('type').notNull(),
|
||||
value: integer('value').default(1).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
lockedAt: timestamp('locked_at', { withTimezone: true }),
|
||||
lockedBy: integer('locked_by'),
|
||||
batchId: varchar('batch_id', { length: 64 }),
|
||||
note: text('note'),
|
||||
}, (table) => [
|
||||
index('card_codes_status_idx').on(table.status),
|
||||
index('card_codes_batch_id_idx').on(table.batchId),
|
||||
index('card_codes_expires_at_idx').on(table.expiresAt),
|
||||
]);
|
||||
|
||||
// 卡密兑换记录表
|
||||
export const cardCodeRedemptions = pgTable('card_code_redemptions', {
|
||||
id: serial('id').primaryKey(),
|
||||
cardCodeId: integer('card_code_id').notNull().references(() => cardCodes.id, { onDelete: 'cascade' }),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
redeemedAt: timestamp('redeemed_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
songId: integer('song_id').references(() => songs.id, { onDelete: 'set null' }),
|
||||
purpose: text('purpose').notNull(),
|
||||
}, (table) => [
|
||||
index('card_code_redemptions_user_id_idx').on(table.userId),
|
||||
index('card_code_redemptions_card_code_id_idx').on(table.cardCodeId),
|
||||
]);
|
||||
|
||||
// OAuth 提供商表
|
||||
export const oauthProviders = pgTable('oauth_providers', {
|
||||
id: serial('id').primaryKey(),
|
||||
name: text('name').notNull(),
|
||||
displayName: text('displayName'),
|
||||
enabled: boolean('enabled').default(true).notNull(),
|
||||
clientId: text('clientId'),
|
||||
clientSecret: text('clientSecret'),
|
||||
authorizationEndpoint: text('authorizationEndpoint'),
|
||||
tokenEndpoint: text('tokenEndpoint'),
|
||||
userInfoEndpoint: text('userInfoEndpoint'),
|
||||
scope: text('scope'),
|
||||
redirectUri: text('redirectUri'),
|
||||
usernameClaim: text('usernameClaim').default('preferred_username'),
|
||||
nameClaim: text('nameClaim').default('name'),
|
||||
emailClaim: text('emailClaim').default('email'),
|
||||
avatarClaim: text('avatarClaim'),
|
||||
createdAt: timestamp('created_at').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at').defaultNow().notNull(),
|
||||
sortOrder: integer('sortOrder').default(0).notNull(),
|
||||
icon: text('icon'),
|
||||
});
|
||||
|
||||
// 用户 Passkeys 表
|
||||
export const userPasskeys = pgTable('user_passkeys', {
|
||||
id: text('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
name: text('name'),
|
||||
publicKey: text('public_key').notNull(),
|
||||
credentialDeviceType: text('credential_device_type').notNull(),
|
||||
credentialBackedUp: boolean('credential_backed_up').notNull(),
|
||||
transports: text('transports'),
|
||||
counter: integer('counter').default(0).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
}, (table) => [
|
||||
index('user_passkeys_user_id_idx').on(table.userId),
|
||||
]);
|
||||
|
||||
// Passkey Challenges 表
|
||||
export const passkeyChallenges = pgTable('passkey_challenges', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
challenge: text('challenge').notNull(),
|
||||
type: text('type').notNull(),
|
||||
userId: integer('user_id'),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }).notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index('passkey_challenges_challenge_idx').on(table.challenge),
|
||||
index('passkey_challenges_expires_at_idx').on(table.expiresAt),
|
||||
]);
|
||||
|
||||
// 备份记录表
|
||||
export const backupRecords = pgTable('backup_records', {
|
||||
id: serial('id').primaryKey(),
|
||||
filename: text('filename').notNull(),
|
||||
filePath: text('file_path').notNull(),
|
||||
fileSize: bigint('file_size', { mode: 'number' }).notNull(),
|
||||
type: text('type').default('MANUAL').notNull(),
|
||||
status: text('status').default('COMPLETED').notNull(),
|
||||
errorMessage: text('error_message'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
startedAt: timestamp('started_at', { withTimezone: true }),
|
||||
completedAt: timestamp('completed_at', { withTimezone: true }),
|
||||
createdBy: integer('created_by'),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
}, (table) => [
|
||||
index('backup_records_created_at_idx').on(table.createdAt),
|
||||
index('backup_records_status_idx').on(table.status),
|
||||
]);
|
||||
|
||||
// 投稿限制豁免表
|
||||
export const submissionRestrictionExemptions = pgTable('submission_restriction_exemptions', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
|
||||
exemptionType: text('exemption_type').notNull(),
|
||||
reason: text('reason'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
expiresAt: timestamp('expires_at', { withTimezone: true }),
|
||||
createdBy: integer('created_by'),
|
||||
}, (table) => [
|
||||
unique('submission_restriction_exemptions_user_type_unique').on(table.userId, table.exemptionType),
|
||||
index('submission_restriction_exemptions_user_id_idx').on(table.userId),
|
||||
]);
|
||||
|
||||
// API Keys表
|
||||
export const apiKeys = pgTable('api_keys', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
@@ -368,7 +500,6 @@ export const apiKeys = pgTable('api_keys', {
|
||||
lastUsedAt: timestamp('last_used_at', { withTimezone: true }),
|
||||
createdByUserId: integer('created_by_user_id').notNull(),
|
||||
usageCount: integer('usage_count').default(0).notNull(),
|
||||
|
||||
});
|
||||
|
||||
// API Key权限表
|
||||
@@ -399,9 +530,6 @@ export const apiLogs = pgTable('api_logs', {
|
||||
export const userStatusLogs = pgTable('user_status_logs', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('user_id').notNull(),
|
||||
// 审计快照:用户被删除后仍可追溯(如注册审核拒绝)
|
||||
username: text('username'),
|
||||
name: text('name'),
|
||||
oldStatus: userStatusEnum('old_status'),
|
||||
newStatus: userStatusEnum('new_status').notNull(),
|
||||
reason: text('reason'),
|
||||
@@ -418,34 +546,29 @@ export const requestTimes = pgTable("RequestTime", {
|
||||
endTime: timestamp({ mode: 'string' }).notNull(),
|
||||
enabled: boolean().default(true).notNull(),
|
||||
description: text(),
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
expected: bigint({ mode: "number" }).default(0).notNull(),
|
||||
// You can use { mode: "bigint" } if numbers are exceeding js number limitations
|
||||
accepted: bigint({ mode: "number" }).default(0).notNull(),
|
||||
past: boolean().default(false).notNull(),
|
||||
});
|
||||
|
||||
// 联合投稿人表
|
||||
export const songCollaborators = pgTable('song_collaborators', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
songId: integer('song_id').notNull(),
|
||||
userId: integer('user_id').notNull(),
|
||||
status: collaboratorStatusEnum('status').default('PENDING').notNull(),
|
||||
createdAt: timestamp('created_at', {withTimezone: true}).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', {withTimezone: true}).defaultNow().notNull(),
|
||||
}, (table) => [
|
||||
index('song_collaborators_song_status_idx').on(table.songId, table.status),
|
||||
index('song_collaborators_user_status_idx').on(table.userId, table.status)
|
||||
]);
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
songId: integer('song_id').notNull(),
|
||||
userId: integer('user_id').notNull(),
|
||||
status: collaboratorStatusEnum('status').default('PENDING').notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// 联合投稿审计日志表
|
||||
export const collaborationLogs = pgTable('collaboration_logs', {
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
collaboratorId: uuid('collaborator_id').notNull(),
|
||||
action: varchar('action', {length: 50}).notNull(), // INVITE, ACCEPT, REJECT, REMOVE
|
||||
operatorId: integer('operator_id').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
createdAt: timestamp('created_at', {withTimezone: true}).defaultNow().notNull(),
|
||||
id: uuid('id').primaryKey().defaultRandom(),
|
||||
collaboratorId: uuid('collaborator_id').notNull(),
|
||||
action: varchar('action', { length: 50 }).notNull(),
|
||||
operatorId: integer('operator_id').notNull(),
|
||||
ipAddress: text('ip_address'),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
});
|
||||
|
||||
// 歌曲重播申请表
|
||||
@@ -453,20 +576,12 @@ export const songReplayRequests = pgTable('song_replay_requests', {
|
||||
id: serial('id').primaryKey(),
|
||||
songId: integer('song_id').notNull(),
|
||||
userId: integer('user_id').notNull(),
|
||||
createdAt: timestamp('created_at', {withTimezone: true}).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', {withTimezone: true}).defaultNow().notNull(),
|
||||
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
updatedAt: timestamp('updated_at', { withTimezone: true }).defaultNow().notNull(),
|
||||
status: replayRequestStatusEnum('status').default('PENDING').notNull(),
|
||||
preferredPlayTimeId: integer('preferred_play_time_id'),
|
||||
submissionNote: text('submission_note'),
|
||||
submissionNotePublic: boolean('submission_note_public').default(false).notNull(),
|
||||
submissionNotePublicStatus: text('submission_note_public_status'),
|
||||
}, (table) => [
|
||||
// 同一用户对同一首歌同时最多一条待处理申请;历史申请(FULFILLED/REJECTED)允许多条
|
||||
uniqueIndex('song_replay_requests_pending_song_user_unique')
|
||||
.on(table.songId, table.userId)
|
||||
.where(sql`${table.status} = 'PENDING'`),
|
||||
index('song_replay_requests_user_status_song_idx').on(table.userId, table.status, table.songId)
|
||||
]);
|
||||
}, (t) => ({
|
||||
unq: unique().on(t.songId, t.userId),
|
||||
}));
|
||||
|
||||
// 第三方身份关联表
|
||||
export const userIdentities = pgTable('UserIdentity', {
|
||||
@@ -475,53 +590,58 @@ export const userIdentities = pgTable('UserIdentity', {
|
||||
provider: text('provider').notNull(),
|
||||
providerUserId: text('providerUserId').notNull(),
|
||||
providerUsername: text('providerUsername'),
|
||||
avatar: text('avatar'),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
}, (t) => ({
|
||||
unq: unique().on(t.provider, t.providerUserId),
|
||||
}));
|
||||
|
||||
export const passwordAuditLogs = pgTable('PasswordAuditLog', {
|
||||
export const changelogEntries = pgTable('ChangelogEntry', {
|
||||
id: serial('id').primaryKey(),
|
||||
userId: integer('userId').notNull(),
|
||||
actorId: integer('actorId'),
|
||||
action: text('action').notNull(),
|
||||
success: boolean('success').notNull(),
|
||||
ipAddress: text('ipAddress'),
|
||||
userAgent: text('userAgent'),
|
||||
failureReason: text('failureReason'),
|
||||
createdAt: timestamp('createdAt', { withTimezone: true }).defaultNow().notNull()
|
||||
}, (table) => ({
|
||||
userCreatedIdx: index('PasswordAuditLog_user_created_idx').on(table.userId, table.createdAt)
|
||||
}));
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
version: text('version').notNull(),
|
||||
title: text('title').notNull(),
|
||||
content: text('content').notNull(),
|
||||
published: boolean('published').default(false).notNull(),
|
||||
publishedAt: timestamp('publishedAt'),
|
||||
createdBy: integer('createdBy'),
|
||||
});
|
||||
|
||||
export const passwordRateLimits = pgTable('PasswordRateLimit', {
|
||||
key: text('key').primaryKey(),
|
||||
count: integer('count').notNull(),
|
||||
resetAt: timestamp('resetAt', { withTimezone: true }).notNull()
|
||||
}, (table) => ({
|
||||
resetIdx: index('PasswordRateLimit_reset_idx').on(table.resetAt)
|
||||
}));
|
||||
// 邮件模板表
|
||||
export const emailTemplates = pgTable('EmailTemplate', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
key: varchar('key', { length: 100 }).notNull(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
subject: varchar('subject', { length: 300 }).notNull(),
|
||||
html: text('html').notNull(),
|
||||
updatedByUserId: integer('updatedByUserId'),
|
||||
});
|
||||
|
||||
// 关系定义
|
||||
export const usersRelations = relations(users, ({ many, one }) => ({
|
||||
songs: many(songs),
|
||||
votes: many(votes),
|
||||
notifications: many(notifications),
|
||||
songComments: many(songComments),
|
||||
identities: many(userIdentities),
|
||||
notificationSettings: one(notificationSettings, {
|
||||
fields: [users.id],
|
||||
references: [notificationSettings.userId],
|
||||
}),
|
||||
apiKeys: many(apiKeys),
|
||||
sessions: many(authSessions),
|
||||
statusLogs: many(userStatusLogs),
|
||||
collaborations: many(songCollaborators),
|
||||
collaborations: many(songCollaborators),
|
||||
replayRequests: many(songReplayRequests),
|
||||
statusChangedByUser: one(users, {
|
||||
fields: [users.statusChangedBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
authSessions: many(authSessions),
|
||||
passkeys: many(userPasskeys),
|
||||
cardCodeRedemptions: many(cardCodeRedemptions),
|
||||
submissionRestrictionExemptions: many(submissionRestrictionExemptions),
|
||||
}));
|
||||
|
||||
export const userIdentitiesRelations = relations(userIdentities, ({ one }) => ({
|
||||
@@ -531,6 +651,20 @@ export const userIdentitiesRelations = relations(userIdentities, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const authSessionsRelations = relations(authSessions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [authSessions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const userPasskeysRelations = relations(userPasskeys, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [userPasskeys.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const songsRelations = relations(songs, ({ one, many }) => ({
|
||||
requester: one(users, {
|
||||
fields: [songs.requesterId],
|
||||
@@ -541,10 +675,28 @@ export const songsRelations = relations(songs, ({ one, many }) => ({
|
||||
references: [playTimes.id],
|
||||
}),
|
||||
votes: many(votes),
|
||||
comments: many(songComments),
|
||||
schedules: many(schedules),
|
||||
notifications: many(notifications),
|
||||
collaborators: many(songCollaborators),
|
||||
replayRequests: many(songReplayRequests),
|
||||
collaborators: many(songCollaborators),
|
||||
replayRequests: many(songReplayRequests),
|
||||
cardCode: one(cardCodes, {
|
||||
fields: [songs.cardCodeId],
|
||||
references: [cardCodes.id],
|
||||
}),
|
||||
scheduleSongPool: many(scheduleSongPool),
|
||||
cardCodeRedemptions: many(cardCodeRedemptions),
|
||||
}));
|
||||
|
||||
export const songCommentsRelations = relations(songComments, ({ one }) => ({
|
||||
song: one(songs, {
|
||||
fields: [songComments.songId],
|
||||
references: [songs.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [songComments.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const votesRelations = relations(votes, ({ one }) => ({
|
||||
@@ -569,6 +721,13 @@ export const schedulesRelations = relations(schedules, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const scheduleSongPoolRelations = relations(scheduleSongPool, ({ one }) => ({
|
||||
song: one(songs, {
|
||||
fields: [scheduleSongPool.songId],
|
||||
references: [songs.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [notifications.userId],
|
||||
@@ -578,6 +737,10 @@ export const notificationsRelations = relations(notifications, ({ one }) => ({
|
||||
fields: [notifications.songId],
|
||||
references: [songs.id],
|
||||
}),
|
||||
sender: one(users, {
|
||||
fields: [notifications.senderId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const notificationSettingsRelations = relations(notificationSettings, ({ one }) => ({
|
||||
@@ -592,7 +755,6 @@ export const playTimesRelations = relations(playTimes, ({ many }) => ({
|
||||
schedules: many(schedules),
|
||||
}));
|
||||
|
||||
// API框架关系定义
|
||||
export const apiKeysRelations = relations(apiKeys, ({ one, many }) => ({
|
||||
createdByUser: one(users, {
|
||||
fields: [apiKeys.createdByUserId],
|
||||
@@ -627,61 +789,90 @@ export const userStatusLogsRelations = relations(userStatusLogs, ({ one }) => ({
|
||||
}),
|
||||
}));
|
||||
|
||||
export const songCollaboratorsRelations = relations(songCollaborators, ({one, many}) => ({
|
||||
song: one(songs, {
|
||||
fields: [songCollaborators.songId],
|
||||
references: [songs.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [songCollaborators.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
logs: many(collaborationLogs),
|
||||
export const songCollaboratorsRelations = relations(songCollaborators, ({ one, many }) => ({
|
||||
song: one(songs, {
|
||||
fields: [songCollaborators.songId],
|
||||
references: [songs.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [songCollaborators.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
logs: many(collaborationLogs),
|
||||
}));
|
||||
|
||||
export const collaborationLogsRelations = relations(collaborationLogs, ({one}) => ({
|
||||
collaborator: one(songCollaborators, {
|
||||
fields: [collaborationLogs.collaboratorId],
|
||||
references: [songCollaborators.id],
|
||||
}),
|
||||
operator: one(users, {
|
||||
fields: [collaborationLogs.operatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
export const collaborationLogsRelations = relations(collaborationLogs, ({ one }) => ({
|
||||
collaborator: one(songCollaborators, {
|
||||
fields: [collaborationLogs.collaboratorId],
|
||||
references: [songCollaborators.id],
|
||||
}),
|
||||
operator: one(users, {
|
||||
fields: [collaborationLogs.operatorId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const songReplayRequestsRelations = relations(songReplayRequests, ({one}) => ({
|
||||
song: one(songs, {
|
||||
fields: [songReplayRequests.songId],
|
||||
references: [songs.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [songReplayRequests.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
export const songReplayRequestsRelations = relations(songReplayRequests, ({ one }) => ({
|
||||
song: one(songs, {
|
||||
fields: [songReplayRequests.songId],
|
||||
references: [songs.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [songReplayRequests.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// 邮件模板表(仅存储自定义覆盖,内置模板在代码中定义)
|
||||
export const emailTemplates = pgTable('EmailTemplate', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
key: varchar('key', { length: 100 }).notNull(),
|
||||
name: varchar('name', { length: 200 }).notNull(),
|
||||
subject: varchar('subject', { length: 300 }).notNull(),
|
||||
html: text('html').notNull(),
|
||||
updatedByUserId: integer('updatedByUserId'),
|
||||
});
|
||||
export const cardCodesRelations = relations(cardCodes, ({ many }) => ({
|
||||
songs: many(songs),
|
||||
redemptions: many(cardCodeRedemptions),
|
||||
}));
|
||||
|
||||
export const cardCodeRedemptionsRelations = relations(cardCodeRedemptions, ({ one }) => ({
|
||||
cardCode: one(cardCodes, {
|
||||
fields: [cardCodeRedemptions.cardCodeId],
|
||||
references: [cardCodes.id],
|
||||
}),
|
||||
user: one(users, {
|
||||
fields: [cardCodeRedemptions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
song: one(songs, {
|
||||
fields: [cardCodeRedemptions.songId],
|
||||
references: [songs.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
export const submissionRestrictionExemptionsRelations = relations(submissionRestrictionExemptions, ({ one }) => ({
|
||||
user: one(users, {
|
||||
fields: [submissionRestrictionExemptions.userId],
|
||||
references: [users.id],
|
||||
}),
|
||||
createdByUser: one(users, {
|
||||
fields: [submissionRestrictionExemptions.createdBy],
|
||||
references: [users.id],
|
||||
}),
|
||||
}));
|
||||
|
||||
// 导出所有表的类型
|
||||
export type User = typeof users.$inferSelect;
|
||||
export type NewUser = typeof users.$inferInsert;
|
||||
export type AuthSession = typeof authSessions.$inferSelect;
|
||||
export type NewAuthSession = typeof authSessions.$inferInsert;
|
||||
export type GradeClass = typeof gradeClasses.$inferSelect;
|
||||
export type NewGradeClass = typeof gradeClasses.$inferInsert;
|
||||
export type Song = typeof songs.$inferSelect;
|
||||
export type NewSong = typeof songs.$inferInsert;
|
||||
export type Vote = typeof votes.$inferSelect;
|
||||
export type NewVote = typeof votes.$inferInsert;
|
||||
export type SongComment = typeof songComments.$inferSelect;
|
||||
export type NewSongComment = typeof songComments.$inferInsert;
|
||||
export type Schedule = typeof schedules.$inferSelect;
|
||||
export type NewSchedule = typeof schedules.$inferInsert;
|
||||
export type ScheduleSongPool = typeof scheduleSongPool.$inferSelect;
|
||||
export type NewScheduleSongPool = typeof scheduleSongPool.$inferInsert;
|
||||
export type ExpectedPlayDate = typeof expectedPlayDates.$inferSelect;
|
||||
export type NewExpectedPlayDate = typeof expectedPlayDates.$inferInsert;
|
||||
export type Notification = typeof notifications.$inferSelect;
|
||||
export type NewNotification = typeof notifications.$inferInsert;
|
||||
export type NotificationSettings = typeof notificationSettings.$inferSelect;
|
||||
@@ -694,6 +885,20 @@ export type SystemSettings = typeof systemSettings.$inferSelect;
|
||||
export type NewSystemSettings = typeof systemSettings.$inferInsert;
|
||||
export type SongBlacklist = typeof songBlacklists.$inferSelect;
|
||||
export type NewSongBlacklist = typeof songBlacklists.$inferInsert;
|
||||
export type CardCode = typeof cardCodes.$inferSelect;
|
||||
export type NewCardCode = typeof cardCodes.$inferInsert;
|
||||
export type CardCodeRedemption = typeof cardCodeRedemptions.$inferSelect;
|
||||
export type NewCardCodeRedemption = typeof cardCodeRedemptions.$inferInsert;
|
||||
export type OAuthProvider = typeof oauthProviders.$inferSelect;
|
||||
export type NewOAuthProvider = typeof oauthProviders.$inferInsert;
|
||||
export type UserPasskey = typeof userPasskeys.$inferSelect;
|
||||
export type NewUserPasskey = typeof userPasskeys.$inferInsert;
|
||||
export type PasskeyChallenge = typeof passkeyChallenges.$inferSelect;
|
||||
export type NewPasskeyChallenge = typeof passkeyChallenges.$inferInsert;
|
||||
export type BackupRecord = typeof backupRecords.$inferSelect;
|
||||
export type NewBackupRecord = typeof backupRecords.$inferInsert;
|
||||
export type SubmissionRestrictionExemption = typeof submissionRestrictionExemptions.$inferSelect;
|
||||
export type NewSubmissionRestrictionExemption = typeof submissionRestrictionExemptions.$inferInsert;
|
||||
export type ApiKey = typeof apiKeys.$inferSelect;
|
||||
export type NewApiKey = typeof apiKeys.$inferInsert;
|
||||
export type ApiKeyPermission = typeof apiKeyPermissions.$inferSelect;
|
||||
@@ -708,55 +913,11 @@ export type CollaborationLog = typeof collaborationLogs.$inferSelect;
|
||||
export type NewCollaborationLog = typeof collaborationLogs.$inferInsert;
|
||||
export type SongReplayRequest = typeof songReplayRequests.$inferSelect;
|
||||
export type NewSongReplayRequest = typeof songReplayRequests.$inferInsert;
|
||||
export type EmailTemplate = typeof emailTemplates.$inferSelect;
|
||||
export type NewEmailTemplate = typeof emailTemplates.$inferInsert;export type RequestTime = typeof requestTimes.$inferSelect;
|
||||
export type RequestTime = typeof requestTimes.$inferSelect;
|
||||
export type NewRequestTime = typeof requestTimes.$inferInsert;
|
||||
export type UserIdentity = typeof userIdentities.$inferSelect;
|
||||
export type NewUserIdentity = typeof userIdentities.$inferInsert;
|
||||
// 卡密表
|
||||
export const cardCodes = pgTable('CardCode', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
|
||||
code: text('code').notNull().unique(),
|
||||
status: cardCodeStatusEnum('status').default('AVAILABLE').notNull(),
|
||||
lockedBy: integer('lockedBy').references(() => users.id, { onDelete: 'set null' }),
|
||||
lockedAt: timestamp('lockedAt'),
|
||||
redeemedBy: integer('redeemedBy').references(() => users.id, { onDelete: 'set null' }),
|
||||
redeemedAt: timestamp('redeemedAt'),
|
||||
note: text('note'),
|
||||
});
|
||||
|
||||
// 卡密兑换日志表
|
||||
export const cardCodeRedeemLogs = pgTable('CardCodeRedeemLog', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
cardCodeId: integer('cardCodeId').references(() => cardCodes.id, { onDelete: 'set null' }),
|
||||
codeSnapshot: text('codeSnapshot').notNull(),
|
||||
redeemedBy: integer('redeemedBy').notNull().references(() => users.id, { onDelete: 'restrict' }),
|
||||
redeemedAt: timestamp('redeemedAt').defaultNow().notNull(),
|
||||
source: text('source').default('UNKNOWN').notNull(),
|
||||
songId: integer('songId').references(() => songs.id, { onDelete: 'set null' })
|
||||
}, (table) => [
|
||||
index('card_code_redeem_log_card_code_id_idx').on(table.cardCodeId)
|
||||
]);
|
||||
|
||||
export type CardCode = typeof cardCodes.$inferSelect;
|
||||
export type NewCardCode = typeof cardCodes.$inferInsert;
|
||||
|
||||
// 自动备份历史记录表
|
||||
export const backupHistory = pgTable('BackupHistory', {
|
||||
id: serial('id').primaryKey(),
|
||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||
filename: text('filename').notNull(),
|
||||
totalRecords: integer('totalRecords').default(0).notNull(),
|
||||
backupSize: integer('backupSize').default(0).notNull(),
|
||||
methods: text('methods').notNull(), // JSON: { method: string; success: boolean; error?: string }[]
|
||||
success: boolean('success').default(false).notNull(),
|
||||
triggeredBy: text('triggeredBy'), // 'api' | 'manual'
|
||||
}, (table) => [
|
||||
index('backup_history_created_at_idx').on(table.createdAt)
|
||||
]);
|
||||
|
||||
export type BackupHistory = typeof backupHistory.$inferSelect;
|
||||
export type NewBackupHistory = typeof backupHistory.$inferInsert;
|
||||
export type ChangelogEntry = typeof changelogEntries.$inferSelect;
|
||||
export type NewChangelogEntry = typeof changelogEntries.$inferInsert;
|
||||
export type EmailTemplate = typeof emailTemplates.$inferSelect;
|
||||
export type NewEmailTemplate = typeof emailTemplates.$inferInsert;
|
||||
|
||||
@@ -5,21 +5,29 @@
|
||||
<div class="info-section">
|
||||
<div class="info-content">
|
||||
<div class="logo-section">
|
||||
<img alt="VoiceHub Logo" class="brand-logo" :src="getLogo()" />
|
||||
<h1 v-if="siteTitle" class="brand-title">{{ siteTitle }}</h1>
|
||||
<img
|
||||
alt="VoiceHub Logo"
|
||||
class="brand-logo"
|
||||
:src="logo"
|
||||
width="160"
|
||||
height="160"
|
||||
decoding="async"
|
||||
fetchpriority="high"
|
||||
>
|
||||
<h1 v-if="siteTitle" class="brand-title">{{ siteTitle || 'VoiceHub' }}</h1>
|
||||
</div>
|
||||
|
||||
<div v-if="isFirstLogin" class="welcome-message">
|
||||
<h2>{{ locale.welcomeTitle }}</h2>
|
||||
<p>{{ locale.welcomeDesc }}</p>
|
||||
<h2>欢迎使用VoiceHub</h2>
|
||||
<p>为了保障您的账号安全,请设置一个新的密码</p>
|
||||
</div>
|
||||
<div v-else class="security-message">
|
||||
<h2>{{ locale.securityTitle }}</h2>
|
||||
<p>{{ locale.securityDesc }}</p>
|
||||
<h2>账号安全</h2>
|
||||
<p>定期更新密码有助于保护您的账号安全</p>
|
||||
</div>
|
||||
|
||||
<div class="security-tips">
|
||||
<h3>{{ locale.tipsTitle }}</h3>
|
||||
<h3>密码安全建议</h3>
|
||||
<div class="tip-list">
|
||||
<div class="tip-item">
|
||||
<svg
|
||||
@@ -31,7 +39,7 @@
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
<span>{{ locale.tipMinLength }}</span>
|
||||
<span>至少8个字符</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<svg
|
||||
@@ -43,7 +51,7 @@
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
<span>{{ locale.tipCase }}</span>
|
||||
<span>包含大小写字母</span>
|
||||
</div>
|
||||
<div class="tip-item">
|
||||
<svg
|
||||
@@ -55,7 +63,7 @@
|
||||
>
|
||||
<polyline points="20,6 9,17 4,12" />
|
||||
</svg>
|
||||
<span>{{ locale.tipSpecial }}</span>
|
||||
<span>包含数字和特殊字符</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -66,27 +74,21 @@
|
||||
<div class="form-section">
|
||||
<div class="form-container">
|
||||
<div class="form-header">
|
||||
<h2>{{ isFirstLogin ? locale.setNewPassword : locale.changePasswordTitle }}</h2>
|
||||
<p>{{ isFirstLogin ? locale.setNewPasswordDesc : locale.updatePasswordDesc }}</p>
|
||||
<h2>{{ isFirstLogin ? '设置新密码' : '修改密码' }}</h2>
|
||||
<p>{{ isFirstLogin ? '请设置一个安全的密码' : '更新您的登录密码' }}</p>
|
||||
</div>
|
||||
|
||||
<div class="password-form-shell">
|
||||
<ClientOnly>
|
||||
<ChangePasswordForm :is-first-login="isFirstLogin" />
|
||||
</ClientOnly>
|
||||
</div>
|
||||
<ClientOnly>
|
||||
<ChangePasswordForm :is-first-login="isFirstLogin" />
|
||||
</ClientOnly>
|
||||
|
||||
<div class="form-footer">
|
||||
<NuxtLink v-if="!requirePasswordChange" class="back-link" to="/">
|
||||
<NuxtLink class="back-link" to="/">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<polyline points="15,18 9,12 15,6" />
|
||||
</svg>
|
||||
{{ locale.backToHome }}
|
||||
返回主页
|
||||
</NuxtLink>
|
||||
<button v-else class="back-link logout-link" type="button" @click="auth.logout()">
|
||||
<Icon class="shrink-0" name="logout" :size="16" aria-hidden="true" />
|
||||
{{ locale.logout }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -96,22 +98,15 @@
|
||||
|
||||
<script setup>
|
||||
import ChangePasswordForm from '~/components/Auth/ChangePasswordForm.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useThemeImage } from '~/composables/useThemeImage'
|
||||
import { ref } from 'vue'
|
||||
import logo from '~~/public/images/logo.png'
|
||||
|
||||
// 使用站点配置
|
||||
const { siteTitle, initSiteConfig } = useSiteConfig()
|
||||
const { changePassword: locale } = useLocale()
|
||||
const { getLogo } = useThemeImage()
|
||||
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
const isFirstLogin = computed(() => {
|
||||
const currentUser = auth.user.value
|
||||
return currentUser?.needsInitialPasswordSetup === true
|
||||
})
|
||||
const requirePasswordChange = computed(() => !!auth.user.value?.requirePasswordChange)
|
||||
const isFirstLogin = ref(false)
|
||||
|
||||
// 未登录用户重定向到登录页
|
||||
onMounted(async () => {
|
||||
@@ -122,38 +117,43 @@ onMounted(async () => {
|
||||
router.push('/login')
|
||||
return
|
||||
}
|
||||
|
||||
// 检查是否需要修改密码(用于显示不同的UI提示)
|
||||
if (import.meta.client) {
|
||||
const userJson = localStorage.getItem('user')
|
||||
if (userJson) {
|
||||
const user = JSON.parse(userJson)
|
||||
isFirstLogin.value = user.forcePasswordChange === true || !user.passwordChangedAt
|
||||
}
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.auth-layout {
|
||||
width: 100%;
|
||||
min-height: 100vh;
|
||||
min-height: 100dvh;
|
||||
background: var(--panel-bg-deepest);
|
||||
background: var(--bg-primary, #ffffff);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.auth-container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
background: var(--bg-primary);
|
||||
background: var(--surface-primary, var(--bg-primary, #ffffff));
|
||||
border-radius: 24px;
|
||||
border: 1px solid var(--panel-border-subtle);
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
overflow: hidden;
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
min-height: 600px;
|
||||
margin: auto;
|
||||
box-shadow: 0 10px 40px -10px rgba(15, 23, 42, 0.1);
|
||||
}
|
||||
|
||||
.info-section {
|
||||
background: linear-gradient(135deg, var(--color-indigo-hover) 0%, var(--color-collab-hover) 100%);
|
||||
background: linear-gradient(135deg, #4f46e5 0%, #7c3aed 100%);
|
||||
padding: 60px 40px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -169,7 +169,7 @@ onMounted(async () => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="%23666" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@@ -195,7 +195,10 @@ onMounted(async () => {
|
||||
font-size: 36px;
|
||||
font-weight: 700;
|
||||
margin: 0;
|
||||
color: white;
|
||||
background: linear-gradient(135deg, var(--card-bg, #ffffff) 0%, #f0f0f0 100%);
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.welcome-message,
|
||||
@@ -214,7 +217,7 @@ onMounted(async () => {
|
||||
.welcome-message p,
|
||||
.security-message p {
|
||||
font-size: 16px;
|
||||
color: var(--overlay-90);
|
||||
color: rgba(255, 255, 255, 0.9);
|
||||
margin: 0;
|
||||
line-height: 1.6;
|
||||
}
|
||||
@@ -241,16 +244,16 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 12px 16px;
|
||||
background: var(--overlay-10);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 8px;
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--overlay-20);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.tip-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
color: var(--color-success);
|
||||
color: #10b981;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -264,7 +267,7 @@ onMounted(async () => {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bg-primary);
|
||||
background: var(--surface-primary, var(--bg-primary, #ffffff));
|
||||
}
|
||||
|
||||
.form-container {
|
||||
@@ -275,11 +278,6 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.password-form-shell {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
@@ -288,13 +286,13 @@ onMounted(async () => {
|
||||
.form-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: var(--text-primary);
|
||||
color: var(--text-primary, #1a2332);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary-hover);
|
||||
color: var(--text-secondary, #4a5568);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -308,10 +306,10 @@ onMounted(async () => {
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 20px;
|
||||
background: var(--panel-bg-deep);
|
||||
border: 1px solid var(--panel-border);
|
||||
background: var(--surface-secondary, var(--bg-secondary, #f7fafc));
|
||||
border: 1px solid var(--border-primary, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
color: var(--text-secondary);
|
||||
color: var(--text-secondary, #4a5568);
|
||||
text-decoration: none;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
@@ -319,9 +317,9 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
background: var(--panel-border);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--panel-border-light);
|
||||
background: var(--surface-tertiary, var(--bg-tertiary, #edf2f7));
|
||||
color: var(--text-primary, #1a2332);
|
||||
border-color: var(--border-secondary, #cbd5e1);
|
||||
}
|
||||
|
||||
.back-link svg {
|
||||
|
||||
+218
-88
@@ -17,91 +17,114 @@
|
||||
<!-- 移动端侧边栏遮罩 -->
|
||||
<div
|
||||
v-if="sidebarOpen"
|
||||
class="fixed inset-0 bg-bg-primary-60 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-300"
|
||||
class="fixed inset-0 bg-black/60 backdrop-blur-sm z-40 lg:hidden transition-opacity duration-300"
|
||||
@click="closeSidebar"
|
||||
/>
|
||||
|
||||
<!-- 主内容区域 -->
|
||||
<main
|
||||
class="flex-1 flex flex-col h-screen overflow-hidden lg:ml-64 relative bg-bg-primary text-text-primary"
|
||||
class="flex-1 flex flex-col h-screen overflow-hidden lg:ml-64 relative bg-[#09090b] text-zinc-100"
|
||||
>
|
||||
<header
|
||||
class="h-16 shrink-0 flex items-center justify-between px-4 md:px-8 border-b border-border-secondary bg-bg-primary-60 backdrop-blur-xl z-30"
|
||||
class="admin-main-header h-16 shrink-0 flex items-center justify-between px-4 md:px-8 border-b border-zinc-800 bg-zinc-950/60 backdrop-blur-xl z-30"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="lg:hidden p-2 text-text-tertiary hover:bg-bg-tertiary rounded-lg transition-colors"
|
||||
class="admin-mobile-menu-btn lg:hidden p-2 text-zinc-400 hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
@click="toggleSidebar"
|
||||
>
|
||||
<Menu :size="20" />
|
||||
</button>
|
||||
<h1 class="text-xl font-bold tracking-tight">{{ getPageTitle() }}</h1>
|
||||
</div>
|
||||
<div class="flex items-center gap-4">
|
||||
<!-- 这里可以添加顶部操作按钮 -->
|
||||
<div class="flex items-center gap-2">
|
||||
<button
|
||||
class="px-3 py-1.5 text-xs font-semibold rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-800 transition-colors"
|
||||
@click="openOnboardingTutorial"
|
||||
>
|
||||
新手引导
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
class="flex-1 custom-scrollbar p-4 md:p-8 overflow-y-auto"
|
||||
ref="contentScrollRef"
|
||||
class="admin-content-area flex-1 custom-scrollbar p-4 md:p-8 overflow-y-auto"
|
||||
>
|
||||
<!-- 移动端返回顶部按钮 -->
|
||||
<button
|
||||
v-if="showBackToTop"
|
||||
:aria-label="locale.backToTop"
|
||||
class="fixed bottom-8 right-8 p-3 bg-primary-hover text-text-primary rounded-full shadow-lg hover:bg-primary transition-all z-50"
|
||||
aria-label="返回顶部"
|
||||
class="back-to-top-btn fixed bottom-8 right-8 p-3 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 transition-all z-50"
|
||||
@click="scrollToTop"
|
||||
>
|
||||
<ChevronUp :size="24" />
|
||||
</button>
|
||||
|
||||
<!-- 数据概览 -->
|
||||
<div
|
||||
v-if="activeTab === 'overview' && permissions.canAccessPage('overview')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminOverviewDashboard @navigate="handleNavigate" />
|
||||
</div>
|
||||
<div :key="`${activeTab}-${tabRefreshKey}`">
|
||||
<!-- 数据概览 -->
|
||||
<div
|
||||
v-if="activeTab === 'overview' && permissions.canAccessPage('overview')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminOverviewDashboard @navigate="handleNavigate" />
|
||||
</div>
|
||||
|
||||
<!-- 歌曲管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'songs' && permissions.canAccessPage('songs')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminSongManagement />
|
||||
</div>
|
||||
<!-- 工作流教程 -->
|
||||
<div
|
||||
v-if="activeTab === 'workflow-tutorial' && permissions.canAccessPage('workflow-tutorial')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminWorkflowTutorial />
|
||||
</div>
|
||||
|
||||
<!-- 排期管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'schedule' && permissions.canAccessPage('schedule')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||
>
|
||||
<LazyAdminScheduleManager />
|
||||
</div>
|
||||
<!-- 歌曲管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'songs' && permissions.canAccessPage('songs')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminSongManagement />
|
||||
</div>
|
||||
|
||||
<!-- 打印排期 -->
|
||||
<div
|
||||
v-if="activeTab === 'print' && permissions.canAccessPage('print')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||
>
|
||||
<LazyAdminSchedulePrinter />
|
||||
</div>
|
||||
<!-- 排期管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'schedule' && permissions.canAccessPage('schedule')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||
>
|
||||
<LazyAdminScheduleManager />
|
||||
</div>
|
||||
|
||||
<!-- 数据分析 -->
|
||||
<div
|
||||
v-if="activeTab === 'data-analysis' && permissions.canAccessPage('data-analysis')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminDataAnalysisPanel />
|
||||
</div>
|
||||
<!-- 打印排期 -->
|
||||
<div
|
||||
v-if="activeTab === 'print' && permissions.canAccessPage('print')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||
>
|
||||
<LazyAdminSchedulePrinter />
|
||||
</div>
|
||||
|
||||
<!-- 用户管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'users' && permissions.canAccessPage('users')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminUserManager />
|
||||
</div>
|
||||
<!-- 歌单卡片图片 -->
|
||||
<div
|
||||
v-if="activeTab === 'playlist-card' && permissions.canAccessPage('print')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||
>
|
||||
<LazyAdminPlaylistCardPage />
|
||||
</div>
|
||||
|
||||
<!-- 数据分析 -->
|
||||
<div
|
||||
v-if="activeTab === 'data-analysis' && permissions.canAccessPage('data-analysis')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminDataAnalysisPanel />
|
||||
</div>
|
||||
|
||||
<!-- 用户管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'users' && permissions.canAccessPage('users')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminUserManager />
|
||||
</div>
|
||||
|
||||
<!-- 消息管理 -->
|
||||
<div
|
||||
@@ -151,14 +174,6 @@
|
||||
<LazyAdminBlacklistManager />
|
||||
</div>
|
||||
|
||||
<!-- 年级班级管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'grade-class' && permissions.canAccessPage('grade-class')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminGradeClassManager />
|
||||
</div>
|
||||
|
||||
<!-- 站点配置 -->
|
||||
<div
|
||||
v-if="activeTab === 'site-config' && permissions.canAccessPage('site-config')"
|
||||
@@ -167,12 +182,20 @@
|
||||
<LazyAdminSiteConfigManager />
|
||||
</div>
|
||||
|
||||
<!-- 音源控制 -->
|
||||
<!-- 更新日志 -->
|
||||
<div
|
||||
v-if="activeTab === 'music-source' && permissions.canAccessPage('music-source')"
|
||||
v-if="activeTab === 'changelog' && permissions.canAccessPage('changelog')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminMusicSourceController />
|
||||
<LazyAdminChangelogManager />
|
||||
</div>
|
||||
|
||||
<!-- 选曲日期管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'expected-play-dates' && permissions.canAccessPage('settings')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||
>
|
||||
<LazyAdminExpectedPlayDates />
|
||||
</div>
|
||||
|
||||
<!-- 数据库操作 -->
|
||||
@@ -183,6 +206,14 @@
|
||||
<LazyAdminDatabaseManager />
|
||||
</div>
|
||||
|
||||
<!-- 权限组管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'roles' && permissions.canAccessPage('roles')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminRolesManager />
|
||||
</div>
|
||||
|
||||
<!-- API密钥管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'api-keys' && permissions.canAccessPage('api-keys')"
|
||||
@@ -190,14 +221,15 @@
|
||||
>
|
||||
<LazyAdminApiKeyManager />
|
||||
</div>
|
||||
<!-- 卡密管理 -->
|
||||
<div
|
||||
v-if="activeTab === 'card-codes' && permissions.canAccessPage('card-codes')"
|
||||
class="animate-in fade-in slide-in-from-bottom-4 duration-500"
|
||||
>
|
||||
<LazyAdminCardCodesManager />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<AdminOnboardingTutorial
|
||||
:visible="showOnboardingTutorial"
|
||||
@navigate="handleNavigate"
|
||||
@close="closeOnboardingTutorial"
|
||||
@complete="completeOnboardingTutorial"
|
||||
/>
|
||||
</main>
|
||||
</div>
|
||||
</ClientOnly>
|
||||
@@ -209,14 +241,12 @@ import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue'
|
||||
import { Menu, ChevronUp } from '@lucide/vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { usePermissions } from '~/composables/usePermissions'
|
||||
import { fetchRolesConfig } from '~/composables/usePermissions'
|
||||
import { useSiteConfig } from '~/composables/useSiteConfig'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useScrollMemory } from '~/composables/useScrollMemory'
|
||||
import AdminOnboardingTutorial from '~/components/Admin/AdminOnboardingTutorial.vue'
|
||||
|
||||
// 使用站点配置
|
||||
const { siteTitle, initSiteConfig } = useSiteConfig()
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.dashboard || {})
|
||||
|
||||
// 导入组件
|
||||
|
||||
@@ -227,14 +257,16 @@ definePageMeta({
|
||||
|
||||
// 响应式数据
|
||||
const activeTab = ref('overview')
|
||||
|
||||
// 各面板滚动位置独立记忆与恢复
|
||||
useScrollMemory(() => activeTab.value)
|
||||
|
||||
const currentUser = ref(null)
|
||||
const sidebarOpen = ref(false)
|
||||
const showBackToTop = ref(false)
|
||||
const showOnboardingTutorial = ref(false)
|
||||
const contentScrollRef = ref(null)
|
||||
const beforeNavigateHooks = ref([])
|
||||
const tabRefreshKey = ref(0)
|
||||
|
||||
let revisionInterval = null
|
||||
let lastDataRevision = ''
|
||||
|
||||
// 提供注册导航拦截钩子的方法
|
||||
const registerBeforeNavigate = (hook) => {
|
||||
@@ -252,9 +284,57 @@ provide('registerBeforeNavigate', registerBeforeNavigate)
|
||||
let auth = null
|
||||
const permissions = usePermissions()
|
||||
|
||||
const fetchDataRevision = async () => {
|
||||
try {
|
||||
if (!auth || !auth.isAuthenticated.value) return ''
|
||||
const authConfig = auth.getAuthConfig()
|
||||
const result = await $fetch('/api/songs/revision', {
|
||||
...authConfig,
|
||||
query: { t: Date.now() }
|
||||
})
|
||||
return result?.revision || ''
|
||||
} catch (e) {
|
||||
console.error('获取数据修订号失败:', e)
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
const startRevisionPolling = async () => {
|
||||
if (revisionInterval) clearInterval(revisionInterval)
|
||||
lastDataRevision = await fetchDataRevision()
|
||||
revisionInterval = setInterval(async () => {
|
||||
const next = await fetchDataRevision()
|
||||
if (!next || next === lastDataRevision) return
|
||||
lastDataRevision = next
|
||||
tabRefreshKey.value++
|
||||
}, 5000)
|
||||
}
|
||||
|
||||
// 方法
|
||||
const getPageTitle = () => {
|
||||
return locale.value.tabs[activeTab.value] || locale.value.fallbackTitle
|
||||
const titles = {
|
||||
overview: '数据概览',
|
||||
'workflow-tutorial': '流程与教程',
|
||||
'data-analysis': '数据分析',
|
||||
songs: '歌曲管理',
|
||||
schedule: '排期管理',
|
||||
print: '打印排期',
|
||||
'playlist-card': '图片歌单',
|
||||
users: '用户管理',
|
||||
blacklist: '黑名单管理',
|
||||
'api-keys': 'API密钥管理',
|
||||
notifications: '通知管理',
|
||||
'smtp-config': '邮件配置',
|
||||
playtimes: '播出时段',
|
||||
'request-times': '投稿管理',
|
||||
semesters: '学期管理',
|
||||
'expected-play-dates': '选曲日期',
|
||||
'site-config': '站点配置',
|
||||
changelog: '更新日志',
|
||||
database: '数据库操作',
|
||||
password: '修改密码'
|
||||
}
|
||||
return titles[activeTab.value] || '管理后台'
|
||||
}
|
||||
|
||||
// 动态页面标题
|
||||
@@ -263,7 +343,7 @@ const dynamicTitle = computed(() => {
|
||||
if (siteTitle && siteTitle.value) {
|
||||
return `${currentPageTitle} | ${siteTitle.value}`
|
||||
}
|
||||
return `${currentPageTitle} | ${locale.value.defaultSiteTitle}`
|
||||
return `${currentPageTitle} | 校园广播站点歌系统`
|
||||
})
|
||||
|
||||
// 监听activeTab变化,更新页面标题
|
||||
@@ -288,7 +368,13 @@ watch(
|
||||
)
|
||||
|
||||
const getRoleDisplayName = (role) => {
|
||||
return locale.value.roles[role] || role
|
||||
const roleNames = {
|
||||
USER: '普通用户',
|
||||
SONG_ADMIN: '歌曲管理员',
|
||||
ADMIN: '超级管理员',
|
||||
SUPER_ADMIN: '超级管理员'
|
||||
}
|
||||
return roleNames[role] || role
|
||||
}
|
||||
|
||||
const handleLogout = async () => {
|
||||
@@ -323,6 +409,21 @@ const closeSidebar = () => {
|
||||
sidebarOpen.value = false
|
||||
}
|
||||
|
||||
const openOnboardingTutorial = () => {
|
||||
showOnboardingTutorial.value = true
|
||||
}
|
||||
|
||||
const closeOnboardingTutorial = () => {
|
||||
showOnboardingTutorial.value = false
|
||||
}
|
||||
|
||||
const completeOnboardingTutorial = () => {
|
||||
showOnboardingTutorial.value = false
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('voicehub_admin_tutorial_seen_v1', 'true')
|
||||
}
|
||||
}
|
||||
|
||||
// 监听窗口大小变化,大屏幕时自动关闭移动端侧边栏
|
||||
const handleResize = () => {
|
||||
if (window.innerWidth > 768) {
|
||||
@@ -332,12 +433,14 @@ const handleResize = () => {
|
||||
|
||||
// 返回顶部功能
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
if (contentScrollRef.value) {
|
||||
contentScrollRef.value.scrollTo({ top: 0, behavior: 'smooth' })
|
||||
}
|
||||
}
|
||||
|
||||
// 监听滚动事件
|
||||
const handleScroll = () => {
|
||||
showBackToTop.value = window.scrollY > 300
|
||||
showBackToTop.value = (contentScrollRef.value?.scrollTop || 0) > 300
|
||||
}
|
||||
|
||||
// 触摸手势支持
|
||||
@@ -384,6 +487,7 @@ onMounted(async () => {
|
||||
|
||||
// 初始化服务
|
||||
auth = useAuth()
|
||||
await fetchRolesConfig()
|
||||
|
||||
// 检查认证状态(plugin已经初始化过了)
|
||||
|
||||
@@ -413,7 +517,8 @@ onMounted(async () => {
|
||||
|
||||
// 添加窗口大小监听器
|
||||
window.addEventListener('resize', handleResize)
|
||||
window.addEventListener('scroll', handleScroll)
|
||||
contentScrollRef.value?.addEventListener('scroll', handleScroll, { passive: true })
|
||||
handleScroll()
|
||||
|
||||
// 添加双击关闭侧边栏事件
|
||||
document.addEventListener('dblclick', (e) => {
|
||||
@@ -421,12 +526,21 @@ onMounted(async () => {
|
||||
closeSidebar()
|
||||
}
|
||||
})
|
||||
|
||||
if (!localStorage.getItem('voicehub_admin_tutorial_seen_v1')) {
|
||||
setTimeout(() => {
|
||||
showOnboardingTutorial.value = true
|
||||
}, 800)
|
||||
}
|
||||
|
||||
await startRevisionPolling()
|
||||
})
|
||||
|
||||
// 组件卸载时清理事件监听器
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('resize', handleResize)
|
||||
window.removeEventListener('scroll', handleScroll)
|
||||
contentScrollRef.value?.removeEventListener('scroll', handleScroll)
|
||||
if (revisionInterval) clearInterval(revisionInterval)
|
||||
})
|
||||
</script>
|
||||
|
||||
@@ -434,8 +548,8 @@ onUnmounted(() => {
|
||||
.admin-layout {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
background: var(--panel-bg-deepest);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-primary, #ffffff);
|
||||
color: var(--text-primary, #1a2332);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
@@ -447,10 +561,26 @@ onUnmounted(() => {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--panel-bg-alt);
|
||||
background: var(--border-primary, #cbd5e1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--panel-bg-hover);
|
||||
background: var(--border-secondary, #94a3b8);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.admin-main-header {
|
||||
padding-top: max(0px, env(safe-area-inset-top));
|
||||
}
|
||||
|
||||
.admin-content-area {
|
||||
padding-bottom: calc(1rem + env(safe-area-inset-bottom));
|
||||
}
|
||||
|
||||
.back-to-top-btn {
|
||||
right: 1rem;
|
||||
bottom: calc(1rem + env(safe-area-inset-bottom));
|
||||
padding: 0.65rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
+788
-1553
File diff suppressed because it is too large
Load Diff
+30
-28
@@ -4,14 +4,26 @@
|
||||
<div class="form-section">
|
||||
<div class="form-header">
|
||||
<div class="logo-row">
|
||||
<img :src="brandLogoSrc" alt="Brand Logo" class="brand-logo-center" />
|
||||
<div v-if="schoolLogoHomeDisplayUrl" class="logo-divider" />
|
||||
<img
|
||||
v-if="schoolLogoHomeDisplayUrl"
|
||||
:src="schoolLogoHomeDisplayUrl"
|
||||
:alt="locale.schoolLogoAlt"
|
||||
: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" />
|
||||
@@ -29,25 +41,15 @@
|
||||
<script setup>
|
||||
import { onMounted, computed, ref } from 'vue'
|
||||
import LoginForm from '~/components/Auth/LoginForm.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { useThemeImage } from '~/composables/useThemeImage'
|
||||
import logo from '~~/public/images/logo.png'
|
||||
|
||||
// 使用站点配置
|
||||
const {
|
||||
siteTitle,
|
||||
initSiteConfig,
|
||||
logoUrl,
|
||||
schoolLogoHomeDisplayUrl,
|
||||
icp: icpNumber
|
||||
} = useSiteConfig()
|
||||
const { pages } = useLocale()
|
||||
const locale = computed(() => pages.value?.login || {})
|
||||
const { siteTitle, initSiteConfig, logoUrl, schoolLogoHomeUrl, icp: icpNumber } = useSiteConfig()
|
||||
// 主品牌Logo优先使用SVG,其次使用站点配置中非ICO的地址
|
||||
const { getLogo } = useThemeImage()
|
||||
const brandLogoSrc = computed(() => {
|
||||
const url = logoUrl.value
|
||||
if (url && !url.endsWith('.ico')) return url
|
||||
return getLogo()
|
||||
return logo
|
||||
})
|
||||
|
||||
// 在组件挂载后初始化站点配置
|
||||
@@ -57,7 +59,7 @@ onMounted(async () => {
|
||||
|
||||
// 设置页面标题
|
||||
if (typeof document !== 'undefined' && siteTitle.value) {
|
||||
document.title = `${locale.value.title} | ${siteTitle.value}`
|
||||
document.title = `登录 | ${siteTitle.value}`
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -72,7 +74,7 @@ onMounted(async () => {
|
||||
justify-content: flex-start;
|
||||
padding: 20px;
|
||||
/* 响应式尺寸变量 */
|
||||
--color-brand-logo-size: clamp(48px, 8vw, 96px);
|
||||
--brand-logo-size: clamp(48px, 8vw, 96px);
|
||||
--school-logo-size: clamp(96px, 16vw, 160px);
|
||||
--logo-gap: clamp(12px, 2vw, 24px);
|
||||
--divider-height: clamp(32px, 10vw, 96px);
|
||||
@@ -109,7 +111,7 @@ onMounted(async () => {
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="%23666" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
|
||||
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grid" width="10" height="10" patternUnits="userSpaceOnUse"><path d="M 10 0 L 0 0 0 10" fill="none" stroke="rgba(255,255,255,0.1)" stroke-width="0.5"/></pattern></defs><rect width="100" height="100" fill="url(%23grid)"/></svg>');
|
||||
opacity: 0.3;
|
||||
}
|
||||
|
||||
@@ -136,7 +138,7 @@ onMounted(async () => {
|
||||
|
||||
.brand-logo:hover {
|
||||
transform: translateY(-3px) scale(1.02);
|
||||
filter: drop-shadow(0 10px 20px var(--mask-25));
|
||||
filter: drop-shadow(0 10px 20px rgba(0, 0, 0, 0.25));
|
||||
}
|
||||
|
||||
.brand-title {
|
||||
@@ -224,13 +226,13 @@ onMounted(async () => {
|
||||
}
|
||||
|
||||
.brand-logo-center {
|
||||
width: var(--color-brand-logo-size);
|
||||
height: var(--color-brand-logo-size);
|
||||
width: var(--brand-logo-size);
|
||||
height: var(--brand-logo-size);
|
||||
margin: 0;
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
filter: drop-shadow(0 2px 8px var(--mask-15));
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.school-logo {
|
||||
@@ -240,7 +242,7 @@ onMounted(async () => {
|
||||
object-fit: contain;
|
||||
max-width: 100%;
|
||||
max-height: 100%;
|
||||
filter: drop-shadow(0 2px 8px var(--mask-15));
|
||||
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
|
||||
}
|
||||
|
||||
.form-title {
|
||||
@@ -310,7 +312,7 @@ onMounted(async () => {
|
||||
|
||||
.icp-link:hover,
|
||||
.voicehub-link:hover {
|
||||
color: var(--overlay-80);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
|
||||
@@ -59,7 +59,7 @@ export interface SourceStatus {
|
||||
export interface MusicSearchParams {
|
||||
/** 搜索关键词 */
|
||||
keywords: string
|
||||
/** 音乐平台:tencent=QQ音乐, netease=网易云音乐 */
|
||||
/** 音乐平台:目前仅支持 kugou=酷狗音乐 */
|
||||
platform?: string
|
||||
/** 返回数量限制 */
|
||||
limit?: number
|
||||
@@ -67,8 +67,6 @@ export interface MusicSearchParams {
|
||||
offset?: number
|
||||
/** 搜索类型:1=单曲, 10=专辑, 100=歌手, 1000=歌单, 1009=播客/电台, 2000=声音 */
|
||||
type?: number
|
||||
/** 网易云音乐Cookie */
|
||||
cookie?: string
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -111,88 +109,24 @@ export interface SongDetailResult {
|
||||
|
||||
/**
|
||||
* 默认音源配置
|
||||
* 内置网易云和 QQ 音乐音源只保留已验证可用的请求路径。
|
||||
* 包含主音源 Vkeys 和两个网易云备用音源端点
|
||||
*/
|
||||
export const MUSIC_SOURCE_CONFIG: MusicSourceConfig = {
|
||||
primarySource: 'netease-backup-1',
|
||||
enableFailover: true,
|
||||
primarySource: 'kugou',
|
||||
enableFailover: false,
|
||||
timeout: 10000,
|
||||
retryAttempts: 2,
|
||||
sources: [
|
||||
{
|
||||
id: 'netease-backup-1',
|
||||
name: 'NeteaseCloudMusicApi',
|
||||
baseUrl: '/api/api-enhanced/netease',
|
||||
id: 'kugou',
|
||||
name: '酷狗音乐',
|
||||
baseUrl: '/api/music',
|
||||
priority: 1,
|
||||
enabled: true,
|
||||
timeout: 8000,
|
||||
timeout: 10000,
|
||||
headers: {
|
||||
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'vkeys-v3',
|
||||
name: 'Vkeys QQ 音乐 (v3)',
|
||||
baseUrl: 'https://api.vkeys.cn/music',
|
||||
priority: 2,
|
||||
enabled: true,
|
||||
timeout: 10000
|
||||
},
|
||||
{
|
||||
id: 'vkeys',
|
||||
name: 'Vkeys QQ 音乐 (v2)',
|
||||
baseUrl: 'https://api.vkeys.cn/v2/music',
|
||||
priority: 3,
|
||||
enabled: true,
|
||||
timeout: 10000
|
||||
},
|
||||
{
|
||||
id: 'ygking-qq',
|
||||
name: 'Ygking QQ 音乐备用',
|
||||
baseUrl: 'https://api.ygking.top/api/song/url',
|
||||
priority: 4,
|
||||
enabled: true,
|
||||
timeout: 8000
|
||||
},
|
||||
{
|
||||
id: 'netease-gdstudio',
|
||||
name: 'GD Studio 网易云',
|
||||
baseUrl: 'https://music-api.gdstudio.xyz/api.php',
|
||||
priority: 5,
|
||||
enabled: true,
|
||||
timeout: 10000
|
||||
},
|
||||
{
|
||||
id: 'meting-1',
|
||||
name: 'Meting API 网易云备用源1',
|
||||
baseUrl: 'https://api.qijieya.cn/meting/',
|
||||
priority: 6,
|
||||
enabled: true,
|
||||
timeout: 8000
|
||||
},
|
||||
{
|
||||
id: 'meting-2',
|
||||
name: 'Meting API 网易云备用源2',
|
||||
baseUrl: 'https://api.injahow.cn/meting/',
|
||||
priority: 7,
|
||||
enabled: true,
|
||||
timeout: 8000
|
||||
},
|
||||
{
|
||||
id: 'bilibili',
|
||||
name: '哔哩哔哩',
|
||||
baseUrl: 'https://api.bilibili.com',
|
||||
priority: 8,
|
||||
enabled: true,
|
||||
timeout: 10000
|
||||
},
|
||||
{
|
||||
id: 'migu',
|
||||
name: '咪咕音乐',
|
||||
baseUrl: '/api/native-api/migu/playurl',
|
||||
priority: 9,
|
||||
enabled: true,
|
||||
timeout: 10000
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -226,28 +160,3 @@ export function getPrimarySource(): MusicSource | undefined {
|
||||
export function getBackupSources(): MusicSource[] {
|
||||
return getEnabledSources().filter((source) => source.id !== MUSIC_SOURCE_CONFIG.primarySource)
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为纯数字(旧版 QQ 音乐数字 id)
|
||||
*/
|
||||
function isNumericId(id: string | number): boolean {
|
||||
return /^\d+$/.test(String(id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 构造 vkeys 请求的身份参数
|
||||
* 网易云始终用 id;QQ 音乐旧版数字 id 用 id,新版 mid 用 mid
|
||||
*/
|
||||
export function getVkeysIdParam(
|
||||
platform: 'netease' | 'tencent',
|
||||
musicId: string | number
|
||||
): { key: string; value: string } {
|
||||
if (platform === 'netease') {
|
||||
return { key: 'id', value: String(musicId) }
|
||||
}
|
||||
// tencent
|
||||
if (isNumericId(musicId)) {
|
||||
return { key: 'id', value: String(musicId) }
|
||||
}
|
||||
return { key: 'mid', value: String(musicId) }
|
||||
}
|
||||
|
||||
+196
-679
File diff suppressed because it is too large
Load Diff
@@ -6,7 +6,7 @@ import { desc, eq } from 'drizzle-orm'
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查认证和权限
|
||||
const user = event.context.user
|
||||
if (!user || !['SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '需要管理员权限'
|
||||
|
||||
@@ -6,12 +6,12 @@ import { eq } from 'drizzle-orm'
|
||||
* DELETE /api/admin/api-keys/[id]
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户权限 - 只有超级管理员可以管理 API Key
|
||||
// 检查用户权限 - 只有超级管理员可以管理API Key
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以管理 API Key'
|
||||
statusMessage: '只有超级管理员可以管理API Key'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!apiKeyId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'API Key ID 不能为空'
|
||||
statusMessage: 'API Key ID不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (existingApiKey.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'API Key 不存在'
|
||||
statusMessage: 'API Key不存在'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -73,7 +73,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `删除 API Key 失败:${error.message}`
|
||||
statusMessage: `删除API Key失败: ${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,12 +6,12 @@ import { eq } from 'drizzle-orm'
|
||||
* GET /api/admin/api-keys/[id]
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户权限 - 只有超级管理员可以管理 API Key
|
||||
// 检查用户权限 - 只有超级管理员可以管理API Key
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以管理 API Key'
|
||||
statusMessage: '只有超级管理员可以管理API Key'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!apiKeyId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'API Key ID 不能为空'
|
||||
statusMessage: 'API Key ID不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!apiKey) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'API Key 不存在'
|
||||
statusMessage: 'API Key不存在'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -86,7 +86,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `获取 API Key 详情失败:${error.message}`
|
||||
statusMessage: `获取API Key详情失败: ${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,7 +2,6 @@ import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { z } from 'zod'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { apiPermissionSchema } from './permissions'
|
||||
|
||||
/**
|
||||
* 更新API Key
|
||||
@@ -21,18 +20,18 @@ const updateApiKeySchema = z.object({
|
||||
expiresAt: z.string().optional().nullable(),
|
||||
|
||||
permissions: z
|
||||
.array(apiPermissionSchema)
|
||||
.array(z.enum(['schedules:read', 'songs:read']))
|
||||
.min(1, '至少需要选择一个权限')
|
||||
.optional()
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户权限 - 只有超级管理员可以管理 API Key
|
||||
// 检查用户权限 - 只有超级管理员可以管理API Key
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以管理 API Key'
|
||||
statusMessage: '只有超级管理员可以管理API Key'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -41,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!apiKeyId) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: 'API Key ID 不能为空'
|
||||
statusMessage: 'API Key ID不能为空'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -60,7 +59,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (existingApiKey.length === 0) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: 'API Key 不存在'
|
||||
statusMessage: 'API Key不存在'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,14 +82,14 @@ export default defineEventHandler(async (event) => {
|
||||
if (isNaN(expiresAt.getTime())) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的过期时间格式'
|
||||
statusMessage: '无效的过期时间格式'
|
||||
})
|
||||
}
|
||||
// 验证过期时间不能是过去的时间
|
||||
if (expiresAt <= getBeijingTime()) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '过期时间不能是过去的时间'
|
||||
statusMessage: '过期时间不能是过去的时间'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -100,7 +99,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的过期时间格式'
|
||||
statusMessage: '无效的过期时间格式'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -148,17 +147,17 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 处理 Zod 验证错误
|
||||
// 处理Zod验证错误
|
||||
if (error.name === 'ZodError') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `请求参数验证失败:${error.errors.map((e: any) => e.message).join(', ')}`
|
||||
statusMessage: `请求参数验证失败: ${error.errors.map((e: any) => e.message).join(', ')}`
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `更新 API Key 失败:${error.message}`
|
||||
statusMessage: `更新API Key失败: ${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,12 +6,12 @@ import { and, count, desc, eq, inArray, sql } from 'drizzle-orm'
|
||||
* GET /api/admin/api-keys
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户权限 - 只有超级管理员可以管理 API Key
|
||||
// 检查用户权限 - 只有超级管理员可以管理API Key
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以管理 API Key'
|
||||
statusMessage: '只有超级管理员可以管理API Key'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -167,7 +167,7 @@ export default defineEventHandler(async (event) => {
|
||||
: 'active'
|
||||
}
|
||||
} catch (keyError) {
|
||||
console.error('处理API Key数据失败:', keyError)
|
||||
console.error('处理API Key数据失败:', keyError, key)
|
||||
return null
|
||||
}
|
||||
})
|
||||
@@ -193,7 +193,7 @@ export default defineEventHandler(async (event) => {
|
||||
} catch (error: any) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `获取 API Key 列表失败:${error.message}`
|
||||
statusMessage: `获取API Key列表失败: ${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { apiKeyPermissions, apiKeys, db } from '~/drizzle/db'
|
||||
import crypto from 'crypto'
|
||||
import { z } from 'zod'
|
||||
import { getBeijingTime } from '~/utils/timeUtils'
|
||||
import { apiPermissionSchema } from './permissions'
|
||||
import { generateApiKey, hashApiKey } from '~~/server/utils/apiKeyUtils'
|
||||
|
||||
/**
|
||||
* 创建API Key
|
||||
@@ -17,16 +16,16 @@ const createApiKeySchema = z.object({
|
||||
.optional(),
|
||||
expiresAt: z.union([z.string(), z.null(), z.undefined()]).optional(),
|
||||
|
||||
permissions: z.array(apiPermissionSchema).min(1, '至少需要选择一个权限')
|
||||
permissions: z.array(z.enum(['schedules:read', 'songs:read'])).min(1, '至少需要选择一个权限')
|
||||
})
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户权限 - 只有超级管理员可以管理 API Key
|
||||
// 检查用户权限 - 只有超级管理员可以管理API Key
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以管理 API Key'
|
||||
statusMessage: '只有超级管理员可以管理API Key'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -38,7 +37,7 @@ export default defineEventHandler(async (event) => {
|
||||
// 生成API Key
|
||||
const apiKey = generateApiKey()
|
||||
const keyPrefix = apiKey.substring(0, 10) // vhub_xxxxx
|
||||
const keyHash = await hashApiKey(apiKey)
|
||||
const keyHash = crypto.createHash('sha256').update(apiKey).digest('hex')
|
||||
|
||||
// 处理过期时间
|
||||
let expiresAt: Date | null = null
|
||||
@@ -60,14 +59,14 @@ export default defineEventHandler(async (event) => {
|
||||
if (isNaN(expiresAt.getTime())) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的过期时间格式'
|
||||
statusMessage: '无效的过期时间格式'
|
||||
})
|
||||
}
|
||||
// 验证过期时间不能是过去的时间
|
||||
if (expiresAt <= getBeijingTime()) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '过期时间不能是过去的时间'
|
||||
statusMessage: '过期时间不能是过去的时间'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -77,7 +76,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的过期时间格式'
|
||||
statusMessage: '无效的过期时间格式'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -100,15 +99,7 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
.returning({ id: apiKeys.id })
|
||||
|
||||
const createdApiKey = apiKeyResult[0]
|
||||
if (!createdApiKey) {
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '创建 API Key 失败'
|
||||
})
|
||||
}
|
||||
|
||||
const apiKeyId = createdApiKey.id
|
||||
const apiKeyId = apiKeyResult[0].id
|
||||
|
||||
// 插入权限记录
|
||||
const permissionValues = validatedData.permissions.map((permission) => ({
|
||||
@@ -120,7 +111,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
return {
|
||||
id: apiKeyId,
|
||||
apiKey,
|
||||
apiKey, // 只在创建时返回完整的API Key
|
||||
name: validatedData.name,
|
||||
description: validatedData.description,
|
||||
keyPrefix,
|
||||
@@ -144,18 +135,27 @@ export default defineEventHandler(async (event) => {
|
||||
throw error
|
||||
}
|
||||
|
||||
// 处理 Zod 验证错误
|
||||
// 处理Zod验证错误
|
||||
if (error.name === 'ZodError') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: `请求参数验证失败:${error.errors.map((e: any) => e.message).join(', ')}`
|
||||
statusMessage: `请求参数验证失败: ${error.errors.map((e: any) => e.message).join(', ')}`
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `创建 API Key 失败:${error.message}`
|
||||
statusMessage: `创建API Key失败: ${error.message}`
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 生成API Key
|
||||
* 格式: vhub_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx (37字符)
|
||||
*/
|
||||
function generateApiKey(): string {
|
||||
const prefix = 'vhub_'
|
||||
const randomBytes = crypto.randomBytes(16).toString('hex') // 32字符
|
||||
return prefix + randomBytes
|
||||
}
|
||||
|
||||
@@ -5,12 +5,12 @@ import { ApiLogService } from '~~/server/services/apiLogService'
|
||||
* GET /api/admin/api-keys/logs
|
||||
*/
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查用户权限 - 只有超级管理员可以查看 API 日志
|
||||
// 检查用户权限 - 只有超级管理员可以查看API日志
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以查看 API 日志'
|
||||
statusMessage: '只有超级管理员可以查看API日志'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -62,10 +62,10 @@ export default defineEventHandler(async (event) => {
|
||||
stats: stats.basic
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取 API 访问日志失败:', error)
|
||||
console.error('获取API访问日志失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '获取 API 访问日志失败'
|
||||
statusMessage: '获取API访问日志失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
apiKeyPermissions,
|
||||
apiKeys,
|
||||
apiLogs,
|
||||
cardCodeRedeemLogs,
|
||||
cardCodes,
|
||||
collaborationLogs,
|
||||
emailTemplates,
|
||||
notifications,
|
||||
@@ -29,10 +27,18 @@ import { eq, inArray, isNull, notInArray, or } from 'drizzle-orm'
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以清空数据库'
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
// 只有超级管理员可以清空数据库
|
||||
if (user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
statusMessage: '只有超级管理员可以清空数据库'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -61,7 +67,6 @@ export default defineEventHandler(async (event) => {
|
||||
await db.delete(apiKeys).where(eq(apiKeys.createdByUserId, currentUserId))
|
||||
await db.delete(notifications).where(eq(notifications.userId, currentUserId))
|
||||
await db.delete(notificationSettings).where(eq(notificationSettings.userId, currentUserId))
|
||||
await db.delete(cardCodeRedeemLogs).where(eq(cardCodeRedeemLogs.redeemedBy, currentUserId))
|
||||
await db.delete(userStatusLogs).where(eq(userStatusLogs.userId, currentUserId))
|
||||
await db.delete(userIdentities).where(eq(userIdentities.userId, currentUserId))
|
||||
await db.delete(users).where(eq(users.id, currentUserId))
|
||||
@@ -93,7 +98,6 @@ export default defineEventHandler(async (event) => {
|
||||
await db.delete(apiKeys)
|
||||
await db.delete(notifications)
|
||||
await db.delete(notificationSettings)
|
||||
await db.delete(cardCodeRedeemLogs)
|
||||
await db.delete(collaborationLogs)
|
||||
await db.delete(songCollaborators)
|
||||
await db.delete(songReplayRequests)
|
||||
@@ -104,7 +108,6 @@ export default defineEventHandler(async (event) => {
|
||||
await db.delete(songBlacklists)
|
||||
await db.delete(userIdentities)
|
||||
await db.delete(songs)
|
||||
await db.delete(cardCodes)
|
||||
await db.delete(playTimes)
|
||||
await db.delete(semesters)
|
||||
await db.delete(requestTimes)
|
||||
@@ -137,22 +140,16 @@ export default defineEventHandler(async (event) => {
|
||||
await db
|
||||
.delete(notificationSettings)
|
||||
.where(notInArray(notificationSettings.userId, preservedSuperAdminIds))
|
||||
await db.delete(cardCodeRedeemLogs)
|
||||
await db.delete(collaborationLogs)
|
||||
await db.delete(songCollaborators)
|
||||
await db.delete(songReplayRequests)
|
||||
await db.delete(schedules)
|
||||
await db.delete(votes)
|
||||
await db
|
||||
.delete(userStatusLogs)
|
||||
.where(notInArray(userStatusLogs.userId, preservedSuperAdminIds))
|
||||
await db.delete(userStatusLogs).where(notInArray(userStatusLogs.userId, preservedSuperAdminIds))
|
||||
await db.delete(emailTemplates)
|
||||
await db.delete(songBlacklists)
|
||||
await db
|
||||
.delete(userIdentities)
|
||||
.where(notInArray(userIdentities.userId, preservedSuperAdminIds))
|
||||
await db.delete(userIdentities).where(notInArray(userIdentities.userId, preservedSuperAdminIds))
|
||||
await db.delete(songs)
|
||||
await db.delete(cardCodes)
|
||||
await db.delete(playTimes)
|
||||
await db.delete(semesters)
|
||||
await db.delete(requestTimes)
|
||||
@@ -172,7 +169,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('清空数据失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '清空数据失败:' + error.message
|
||||
statusMessage: '清空数据失败: ' + error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以管理备份文件'
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!filename) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请指定文件名'
|
||||
statusMessage: '请指定文件名'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的文件名'
|
||||
statusMessage: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!filename.endsWith('.json')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '只能删除 JSON 格式的备份文件'
|
||||
statusMessage: '只能删除JSON格式的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,14 +47,14 @@ export default defineEventHandler(async (event) => {
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '备份文件不存在'
|
||||
statusMessage: '备份文件不存在'
|
||||
})
|
||||
}
|
||||
|
||||
// 删除文件
|
||||
await fs.unlink(filepath)
|
||||
|
||||
console.log(`管理员 ${user.username} 删除了备份文件:${filename}`)
|
||||
console.log(`管理员 ${user.username} 删除了备份文件: ${filename}`)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
@@ -65,7 +65,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('删除备份文件失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '删除备份文件失败'
|
||||
statusMessage: error.statusMessage || '删除备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以下载备份文件'
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!filename || typeof filename !== 'string') {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少文件名参数'
|
||||
statusMessage: '缺少文件名参数'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的文件名'
|
||||
statusMessage: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@ export default defineEventHandler(async (event) => {
|
||||
) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的备份文件'
|
||||
statusMessage: '无效的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,19 +66,19 @@ export default defineEventHandler(async (event) => {
|
||||
if (error.code === 'ENOENT') {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '备份文件不存在'
|
||||
statusMessage: '备份文件不存在'
|
||||
})
|
||||
}
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '读取备份文件失败'
|
||||
statusMessage: '读取备份文件失败'
|
||||
})
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('下载备份文件失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '下载备份文件失败'
|
||||
statusMessage: error.statusMessage || '下载备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以下载备份文件'
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!filename) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请指定文件名'
|
||||
statusMessage: '请指定文件名'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的文件名'
|
||||
statusMessage: '无效的文件名'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -34,7 +34,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!filename.endsWith('.json')) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '只能下载 JSON 格式的备份文件'
|
||||
statusMessage: '只能下载JSON格式的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ export default defineEventHandler(async (event) => {
|
||||
} catch {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '备份文件不存在'
|
||||
statusMessage: '备份文件不存在'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('下载备份文件失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '下载备份文件失败'
|
||||
statusMessage: error.statusMessage || '下载备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
apiKeys,
|
||||
apiKeyPermissions,
|
||||
apiLogs,
|
||||
cardCodeRedeemLogs,
|
||||
cardCodes,
|
||||
collaborationLogs,
|
||||
emailTemplates,
|
||||
notifications,
|
||||
@@ -13,7 +11,6 @@ import {
|
||||
playTimes,
|
||||
requestTimes,
|
||||
schedules,
|
||||
scheduleSongPool,
|
||||
semesters,
|
||||
songBlacklists,
|
||||
songCollaborators,
|
||||
@@ -23,10 +20,8 @@ import {
|
||||
users,
|
||||
userIdentities,
|
||||
userStatusLogs,
|
||||
gradeClass,
|
||||
votes
|
||||
} from '~/drizzle/schema'
|
||||
import { inArray } from 'drizzle-orm'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
|
||||
@@ -34,17 +29,15 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以导出备份'
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const { tables = 'all' } = body
|
||||
const includeSystemData =
|
||||
typeof body.includeSystemData === 'boolean' ? body.includeSystemData : tables !== 'users'
|
||||
const { tables = 'all', includeSystemData = true } = body
|
||||
|
||||
console.log('开始创建数据库备份...')
|
||||
|
||||
@@ -107,53 +100,6 @@ export default defineEventHandler(async (event) => {
|
||||
},
|
||||
description: '歌曲数据'
|
||||
},
|
||||
cardCodes: {
|
||||
query: async () => {
|
||||
const codesData = await db.select().from(cardCodes)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
|
||||
return codesData.map((code) => ({
|
||||
...code,
|
||||
lockedByUser: code.lockedBy ? usersData.find((user) => user.id === code.lockedBy) : null,
|
||||
redeemedByUser: code.redeemedBy ? usersData.find((user) => user.id === code.redeemedBy) : null
|
||||
}))
|
||||
},
|
||||
description: '点歌券数据'
|
||||
},
|
||||
cardCodeRedeemLogs: {
|
||||
query: async () => {
|
||||
const logsData = await db.select().from(cardCodeRedeemLogs)
|
||||
const codesData = await db.select({ id: cardCodes.id, code: cardCodes.code }).from(cardCodes)
|
||||
const songsData = await db
|
||||
.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
})
|
||||
.from(songs)
|
||||
const usersData = await db
|
||||
.select({
|
||||
id: users.id,
|
||||
username: users.username,
|
||||
name: users.name
|
||||
})
|
||||
.from(users)
|
||||
|
||||
return logsData.map((log) => ({
|
||||
...log,
|
||||
cardCode: codesData.find((code) => code.id === log.cardCodeId),
|
||||
redeemer: usersData.find((user) => user.id === log.redeemedBy),
|
||||
song: log.songId ? songsData.find((song) => song.id === log.songId) : null
|
||||
}))
|
||||
},
|
||||
description: '点歌券日志'
|
||||
},
|
||||
votes: {
|
||||
query: async () => {
|
||||
const votesData = await db.select().from(votes)
|
||||
@@ -180,24 +126,6 @@ export default defineEventHandler(async (event) => {
|
||||
},
|
||||
description: '投票数据'
|
||||
},
|
||||
scheduleSongPool: {
|
||||
query: async () => {
|
||||
const poolData = await db.select().from(scheduleSongPool)
|
||||
if (poolData.length === 0) return []
|
||||
const poolSongIds = poolData.map((row) => row.songId)
|
||||
const songsData = await db.select({
|
||||
id: songs.id,
|
||||
title: songs.title,
|
||||
artist: songs.artist
|
||||
}).from(songs).where(inArray(songs.id, poolSongIds))
|
||||
const songsMap = new Map(songsData.map((s) => [s.id, s]))
|
||||
return poolData.map((row) => ({
|
||||
...row,
|
||||
song: songsMap.get(row.songId) || null
|
||||
}))
|
||||
},
|
||||
description: '排期备选池'
|
||||
},
|
||||
schedules: {
|
||||
query: async () => {
|
||||
const schedulesData = await db.select().from(schedules)
|
||||
@@ -294,10 +222,6 @@ export default defineEventHandler(async (event) => {
|
||||
},
|
||||
description: '用户状态变更日志'
|
||||
},
|
||||
gradeClass: {
|
||||
query: () => db.select().from(gradeClass),
|
||||
description: '年级班级配置'
|
||||
},
|
||||
userIdentities: {
|
||||
query: async () => {
|
||||
const identitiesData = await db.select().from(userIdentities)
|
||||
@@ -426,10 +350,7 @@ export default defineEventHandler(async (event) => {
|
||||
// 如果包含系统数据,添加系统设置表
|
||||
if (includeSystemData) {
|
||||
tablesToBackup.systemSettings = {
|
||||
query: async () => {
|
||||
const settings = await db.select().from(systemSettings)
|
||||
return settings
|
||||
},
|
||||
query: () => db.select().from(systemSettings),
|
||||
description: '系统设置'
|
||||
}
|
||||
}
|
||||
@@ -519,7 +440,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error(`备份表 ${tableName} 失败:`, error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `备份表 ${tableName} 失败:${error.message}`
|
||||
statusMessage: `备份表 ${tableName} 失败: ${error.message}`
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -612,7 +533,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('创建数据库备份失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '创建数据库备份失败'
|
||||
statusMessage: error.statusMessage || '创建数据库备份失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -6,10 +6,10 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以查看备份文件'
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -109,10 +109,7 @@ export default defineEventHandler(async (event) => {
|
||||
filename.startsWith('users-system-backup-')
|
||||
) {
|
||||
type = 'users'
|
||||
} else if (
|
||||
filename.startsWith('songs-backup-') ||
|
||||
filename.startsWith('songs-system-backup-')
|
||||
) {
|
||||
} else if (filename.startsWith('songs-backup-') || filename.startsWith('songs-system-backup-')) {
|
||||
type = 'songs'
|
||||
} else if (filename.startsWith('system-settings-backup-')) {
|
||||
type = 'system'
|
||||
@@ -152,7 +149,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('获取备份文件列表失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '获取备份文件列表失败'
|
||||
statusMessage: '获取备份文件列表失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -1,10 +1,6 @@
|
||||
import { createError, defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import {
|
||||
cardCodeRedeemLogs,
|
||||
cardCodes,
|
||||
emailTemplates,
|
||||
gradeClass,
|
||||
notificationSettings,
|
||||
notifications,
|
||||
playTimes,
|
||||
@@ -19,21 +15,14 @@ import {
|
||||
votes
|
||||
} from '~/drizzle/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import { restoreScheduleSongPoolRecord } from '~~/server/utils/restoreScheduleSongPool'
|
||||
import { omitMaskedSystemSettingsSecrets } from '~~/server/api/admin/system-settings/secretMask'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { validateThemeConfig } from '~~/server/utils/theme-config'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { normalizeScheduleVisibilitySettings } from '~~/server/utils/system-settings-defaults'
|
||||
import { syncAllSequences } from '~~/server/utils/sequence-sync'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以恢复备份'
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -50,14 +39,14 @@ export default defineEventHandler(async (event) => {
|
||||
if (overwriteSuperAdmin && user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '仅超级管理员可以覆盖超级管理员账号数据'
|
||||
statusMessage: '仅超级管理员可以覆盖超级管理员账号数据'
|
||||
})
|
||||
}
|
||||
|
||||
if (!tableName || !records || !Array.isArray(records)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的请求参数'
|
||||
statusMessage: '无效的请求参数'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -68,9 +57,6 @@ export default defineEventHandler(async (event) => {
|
||||
const songIdMapping = new Map(
|
||||
Object.entries(mappings?.songs || {}).map(([k, v]) => [Number(k), Number(v)])
|
||||
)
|
||||
const cardCodeIdMapping = new Map(
|
||||
Object.entries(mappings?.cardCodes || {}).map(([k, v]) => [Number(k), Number(v)])
|
||||
)
|
||||
const preservedSuperAdminIds = new Set(
|
||||
(mappings?.meta?.preservedSuperAdminIds || []).map((id) => Number(id))
|
||||
)
|
||||
@@ -81,8 +67,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const newMappings = {
|
||||
users: {},
|
||||
songs: {},
|
||||
cardCodes: {}
|
||||
songs: {}
|
||||
}
|
||||
|
||||
const stats = {
|
||||
@@ -114,10 +99,7 @@ export default defineEventHandler(async (event) => {
|
||||
'meowNickname',
|
||||
'forcePasswordChange',
|
||||
'status',
|
||||
'statusChangedBy',
|
||||
'remark',
|
||||
'avatarProvider',
|
||||
'avatarProviderUserId'
|
||||
'statusChangedBy'
|
||||
]
|
||||
const dateFields = [
|
||||
'createdAt',
|
||||
@@ -131,7 +113,7 @@ export default defineEventHandler(async (event) => {
|
||||
userFields.forEach((field) => {
|
||||
if (record.hasOwnProperty(field)) {
|
||||
if (field === 'role') {
|
||||
if (!['USER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(record[field])) {
|
||||
if (!['USER', 'TEACHER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(record[field])) {
|
||||
userData[field] = 'USER'
|
||||
} else {
|
||||
userData[field] = record[field]
|
||||
@@ -285,14 +267,10 @@ export default defineEventHandler(async (event) => {
|
||||
provider: record.provider,
|
||||
providerUserId: record.providerUserId,
|
||||
providerUsername: record.providerUsername,
|
||||
avatar: record.avatar || null,
|
||||
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
|
||||
}
|
||||
|
||||
if (
|
||||
!shouldOverwriteSuperAdmin &&
|
||||
preservedSuperAdminIds.has(Number(validIdentityUserId))
|
||||
) {
|
||||
if (!shouldOverwriteSuperAdmin && preservedSuperAdminIds.has(Number(validIdentityUserId))) {
|
||||
stats.updated++
|
||||
break
|
||||
}
|
||||
@@ -352,12 +330,10 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
const userStatusLogData = {
|
||||
userId: validUserId,
|
||||
username: record.username ?? null,
|
||||
name: record.name ?? null,
|
||||
oldStatus: record.oldStatus || record.previousStatus || null,
|
||||
previousStatus: record.previousStatus || null,
|
||||
newStatus: record.newStatus,
|
||||
reason: record.reason || null,
|
||||
operatorId: record.operatorId || record.changedBy || null,
|
||||
changedBy: record.changedBy || null,
|
||||
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
|
||||
}
|
||||
|
||||
@@ -391,83 +367,6 @@ export default defineEventHandler(async (event) => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'cardCodes': {
|
||||
const cardCodeData: any = {}
|
||||
const cardCodeFields = ['code', 'status', 'note']
|
||||
cardCodeFields.forEach((field) => {
|
||||
if (record.hasOwnProperty(field)) {
|
||||
cardCodeData[field] = record[field]
|
||||
}
|
||||
})
|
||||
|
||||
if (!cardCodeData.code) return
|
||||
|
||||
const mapUserId = async (field: 'lockedBy' | 'redeemedBy') => {
|
||||
if (!record[field]) return null
|
||||
const mappedUserId = userIdMapping.get(record[field])
|
||||
if (mappedUserId) return mappedUserId
|
||||
if (mode === 'merge') return null
|
||||
const userExists = await tx.query.users.findFirst({
|
||||
where: eq(users.id, record[field])
|
||||
})
|
||||
return userExists ? record[field] : null
|
||||
}
|
||||
|
||||
cardCodeData.lockedBy = await mapUserId('lockedBy')
|
||||
cardCodeData.redeemedBy = await mapUserId('redeemedBy')
|
||||
cardCodeData.lockedAt = record.lockedAt ? new Date(record.lockedAt) : null
|
||||
cardCodeData.redeemedAt = record.redeemedAt ? new Date(record.redeemedAt) : null
|
||||
cardCodeData.createdAt = record.createdAt ? new Date(record.createdAt) : new Date()
|
||||
cardCodeData.updatedAt = record.updatedAt ? new Date(record.updatedAt) : new Date()
|
||||
|
||||
let restoredCardCode
|
||||
if (mode === 'merge') {
|
||||
const existing = await tx.query.cardCodes.findFirst({
|
||||
where: eq(cardCodes.code, cardCodeData.code)
|
||||
})
|
||||
if (existing) {
|
||||
restoredCardCode = (
|
||||
await tx
|
||||
.update(cardCodes)
|
||||
.set(cardCodeData)
|
||||
.where(eq(cardCodes.id, existing.id))
|
||||
.returning()
|
||||
)[0]
|
||||
stats.updated++
|
||||
} else {
|
||||
restoredCardCode = (await tx.insert(cardCodes).values(cardCodeData).returning())[0]
|
||||
stats.created++
|
||||
}
|
||||
} else {
|
||||
const existing = await tx.query.cardCodes.findFirst({
|
||||
where: eq(cardCodes.id, record.id)
|
||||
})
|
||||
if (existing) {
|
||||
restoredCardCode = (
|
||||
await tx
|
||||
.update(cardCodes)
|
||||
.set(cardCodeData)
|
||||
.where(eq(cardCodes.id, record.id))
|
||||
.returning()
|
||||
)[0]
|
||||
stats.updated++
|
||||
} else {
|
||||
restoredCardCode = (
|
||||
await tx
|
||||
.insert(cardCodes)
|
||||
.values({ ...cardCodeData, id: record.id })
|
||||
.returning()
|
||||
)[0]
|
||||
stats.created++
|
||||
}
|
||||
}
|
||||
|
||||
if (record.id && restoredCardCode?.id) {
|
||||
newMappings.cardCodes[record.id] = restoredCardCode.id
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'songs': {
|
||||
let validRequesterId = record.requesterId
|
||||
let validPreferredPlayTimeId = record.preferredPlayTimeId
|
||||
@@ -498,25 +397,9 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
let validCardCodeId = record.cardCodeId || null
|
||||
if (record.cardCodeId) {
|
||||
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
|
||||
if (mappedCardCodeId) {
|
||||
validCardCodeId = mappedCardCodeId
|
||||
} else {
|
||||
const cardCodeExists = await tx.query.cardCodes.findFirst({
|
||||
where: eq(cardCodes.id, record.cardCodeId)
|
||||
})
|
||||
if (!cardCodeExists) {
|
||||
validCardCodeId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const songData: any = {
|
||||
requesterId: validRequesterId,
|
||||
preferredPlayTimeId: validPreferredPlayTimeId,
|
||||
cardCodeId: validCardCodeId,
|
||||
played: record.hasOwnProperty('played') ? record.played : false
|
||||
}
|
||||
|
||||
@@ -527,10 +410,8 @@ export default defineEventHandler(async (event) => {
|
||||
'cover',
|
||||
'musicPlatform',
|
||||
'musicId',
|
||||
'durationSeconds',
|
||||
'submissionNote',
|
||||
'submissionNotePublic',
|
||||
'submissionNotePublicStatus'
|
||||
'submissionNotePublic'
|
||||
]
|
||||
songFields.forEach((field) => {
|
||||
if (record.hasOwnProperty(field)) {
|
||||
@@ -700,52 +581,10 @@ export default defineEventHandler(async (event) => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'gradeClass': {
|
||||
const gradeClassData: any = {}
|
||||
const gradeClassFields = ['grade', 'class']
|
||||
gradeClassFields.forEach((field) => {
|
||||
if (record.hasOwnProperty(field)) {
|
||||
gradeClassData[field] =
|
||||
typeof record[field] === 'string' ? record[field].trim() : record[field]
|
||||
}
|
||||
})
|
||||
// 空行直接跳过,避免空值配置行锁死配置优先校验
|
||||
if (!gradeClassData.grade || !gradeClassData.class) {
|
||||
stats.warnings.push(`gradeClass 记录 ${record.id ?? ''} 年级或班级为空,已跳过`)
|
||||
break
|
||||
}
|
||||
|
||||
if (mode === 'merge') {
|
||||
const existingGradeClass = await tx.query.gradeClass.findFirst({
|
||||
where: and(
|
||||
eq(gradeClass.grade, gradeClassData.grade),
|
||||
eq(gradeClass.class, gradeClassData.class)
|
||||
)
|
||||
})
|
||||
if (!existingGradeClass) {
|
||||
await tx.insert(gradeClass).values(gradeClassData)
|
||||
stats.created++
|
||||
}
|
||||
} else {
|
||||
const existing = await tx.query.gradeClass.findFirst({
|
||||
where: eq(gradeClass.id, record.id)
|
||||
})
|
||||
if (!existing) {
|
||||
await tx.insert(gradeClass).values({ ...gradeClassData, id: record.id })
|
||||
stats.created++
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'systemSettings': {
|
||||
let systemSettingsData: any = {}
|
||||
const systemSettingsData: any = {}
|
||||
const fields = [
|
||||
'enablePlayTimeSelection',
|
||||
'instanceId',
|
||||
'defaultTheme',
|
||||
'enabledThemes',
|
||||
'telemetryEnabled',
|
||||
'siteTitle',
|
||||
'siteLogoUrl',
|
||||
'schoolLogoHomeUrl',
|
||||
@@ -758,25 +597,14 @@ export default defineEventHandler(async (event) => {
|
||||
'dailySubmissionLimit',
|
||||
'weeklySubmissionLimit',
|
||||
'monthlySubmissionLimit',
|
||||
'scheduleDaysBeforeEnabled',
|
||||
'scheduleDaysBefore',
|
||||
'scheduleDaysAfterEnabled',
|
||||
'scheduleDaysAfter',
|
||||
'showBlacklistKeywords',
|
||||
'hideStudentInfo',
|
||||
'enableReplayRequests',
|
||||
'enableCollaborativeSubmission',
|
||||
'enableSubmissionRemarks',
|
||||
'enableCardCodeRequests',
|
||||
'requireCardCodeForRequests',
|
||||
'enableCardCodeLimitBypass',
|
||||
'enableSubmissionRestriction',
|
||||
'submissionRestrictionScope',
|
||||
'sameSongRestrictionHours',
|
||||
'sameArtistRestrictionHours',
|
||||
'enableRequestTimeLimitation',
|
||||
'requestTimeLimitation',
|
||||
'forceBlockAllRequests',
|
||||
'forcePasswordChangeOnFirstLogin',
|
||||
'smtpEnabled',
|
||||
'smtpHost',
|
||||
'smtpPort',
|
||||
@@ -784,79 +612,11 @@ export default defineEventHandler(async (event) => {
|
||||
'smtpUsername',
|
||||
'smtpPassword',
|
||||
'smtpFromEmail',
|
||||
'smtpFromName',
|
||||
'allowOAuthRegistration',
|
||||
'allowRegister',
|
||||
'registerRequiresApproval',
|
||||
'registerEmailRequired',
|
||||
'registerRequiresGradeClass',
|
||||
'oauthRegisterRequiresApproval',
|
||||
'submissionNoteRequiresApproval',
|
||||
'oauthRedirectUri',
|
||||
'oauthStateSecret',
|
||||
'oauthProviders',
|
||||
'githubOAuthEnabled',
|
||||
'githubClientId',
|
||||
'githubClientSecret',
|
||||
'casdoorOAuthEnabled',
|
||||
'casdoorServerUrl',
|
||||
'casdoorClientId',
|
||||
'casdoorClientSecret',
|
||||
'casdoorOrganizationName',
|
||||
'googleOAuthEnabled',
|
||||
'googleClientId',
|
||||
'googleClientSecret',
|
||||
'aggregateOAuthEnabled',
|
||||
'aggregateOAuthAppId',
|
||||
'aggregateOAuthAppKey',
|
||||
'aggregateOAuthLoginType',
|
||||
'aggregateOAuthEndpoint',
|
||||
'customOAuthEnabled',
|
||||
'customOAuthDisplayName',
|
||||
'customOAuthAuthorizeUrl',
|
||||
'customOAuthTokenUrl',
|
||||
'customOAuthUserInfoUrl',
|
||||
'customOAuthScope',
|
||||
'customOAuthClientId',
|
||||
'customOAuthClientSecret',
|
||||
'customOAuthUserIdField',
|
||||
'customOAuthUsernameField',
|
||||
'customOAuthNameField',
|
||||
'customOAuthEmailField',
|
||||
'customOAuthAvatarField',
|
||||
'captchaEnabled',
|
||||
'captchaMaxFailures',
|
||||
'captchaProvider',
|
||||
'turnstileSiteKey',
|
||||
'turnstileSecretKey',
|
||||
'autoBackupEnabled',
|
||||
'autoBackupConfig',
|
||||
'statisticsCodeEnabled',
|
||||
'statisticsCode',
|
||||
'enabledPlatforms',
|
||||
'platformOrder'
|
||||
'smtpFromName'
|
||||
]
|
||||
fields.forEach((field) => {
|
||||
if (record.hasOwnProperty(field)) systemSettingsData[field] = record[field]
|
||||
})
|
||||
if (Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
|
||||
if (!Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || !Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_LIST, '主题配置必须同时包含默认主题和启用主题列表')
|
||||
}
|
||||
systemSettingsData.enabledThemes = JSON.stringify(validateThemeConfig(systemSettingsData.defaultTheme, systemSettingsData.enabledThemes))
|
||||
}
|
||||
for (const field of ['scheduleDaysBeforeEnabled', 'scheduleDaysAfterEnabled']) {
|
||||
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && systemSettingsData[field] !== null && typeof systemSettingsData[field] !== 'boolean') {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是布尔值`)
|
||||
}
|
||||
}
|
||||
for (const field of ['scheduleDaysBefore', 'scheduleDaysAfter']) {
|
||||
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && (systemSettingsData[field] !== null && (!Number.isInteger(systemSettingsData[field]) || systemSettingsData[field] < 1 || systemSettingsData[field] > 730))) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是 1-730 的正整数`)
|
||||
}
|
||||
}
|
||||
systemSettingsData = normalizeScheduleVisibilitySettings(systemSettingsData)
|
||||
systemSettingsData = omitMaskedSystemSettingsSecrets(systemSettingsData)
|
||||
|
||||
if (mode === 'merge') {
|
||||
const existing = await tx.query.systemSettings.findFirst()
|
||||
@@ -888,49 +648,6 @@ export default defineEventHandler(async (event) => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'emailTemplates': {
|
||||
// 邮件模板(按 key 去重:merge 模式同名 key 覆盖)
|
||||
if (!record.key || !record.name || !record.subject) return
|
||||
const templateData: any = {
|
||||
key: record.key,
|
||||
name: record.name,
|
||||
subject: record.subject,
|
||||
html: record.html || ''
|
||||
}
|
||||
if (record.contentType) templateData.contentType = record.contentType
|
||||
if (record.headerSubtitle) templateData.headerSubtitle = record.headerSubtitle
|
||||
if (record.actionText) templateData.actionText = record.actionText
|
||||
if (record.actionUrl) templateData.actionUrl = record.actionUrl
|
||||
if (record.updatedByUserId) templateData.updatedByUserId = record.updatedByUserId
|
||||
if (record.createdAt) templateData.createdAt = new Date(record.createdAt)
|
||||
if (record.updatedAt) templateData.updatedAt = new Date(record.updatedAt)
|
||||
|
||||
if (mode === 'merge') {
|
||||
const existing = await tx.query.emailTemplates.findFirst({
|
||||
where: eq(emailTemplates.key, record.key)
|
||||
})
|
||||
if (existing) {
|
||||
await tx.update(emailTemplates).set(templateData).where(eq(emailTemplates.id, existing.id))
|
||||
stats.updated++
|
||||
} else {
|
||||
await tx.insert(emailTemplates).values(templateData)
|
||||
stats.created++
|
||||
}
|
||||
} else {
|
||||
const existing = await tx.query.emailTemplates.findFirst({
|
||||
where: eq(emailTemplates.id, record.id)
|
||||
})
|
||||
if (existing) {
|
||||
await tx.update(emailTemplates).set(templateData).where(eq(emailTemplates.id, record.id))
|
||||
stats.updated++
|
||||
} else {
|
||||
await tx.insert(emailTemplates).values({ ...templateData, id: record.id })
|
||||
stats.created++
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'schedules': {
|
||||
if (!record.songId || !record.playDate) return
|
||||
|
||||
@@ -1085,43 +802,11 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
const notificationData: any = { userId: validUserId }
|
||||
const fields = [
|
||||
'batchId',
|
||||
'source',
|
||||
'senderName',
|
||||
'senderUsername',
|
||||
'title',
|
||||
'message',
|
||||
'type',
|
||||
'userDeleted'
|
||||
]
|
||||
const fields = ['title', 'message', 'type']
|
||||
fields.forEach((field) => {
|
||||
if (record.hasOwnProperty(field)) notificationData[field] = record[field]
|
||||
})
|
||||
// senderId 需要重新映射到目标库的用户 ID;映射未命中时回查同 ID 用户并比对用户名快照,
|
||||
// 不一致则置空,避免跨库恢复时将发送人归属到错误用户,展示仍靠快照字段
|
||||
if (Object.prototype.hasOwnProperty.call(record, 'senderId')) {
|
||||
if (record.senderId) {
|
||||
const mappedSenderId = userIdMapping.get(record.senderId)
|
||||
if (mappedSenderId) {
|
||||
notificationData.senderId = mappedSenderId
|
||||
} else {
|
||||
const senderExists = await tx.query.users.findFirst({
|
||||
where: eq(users.id, record.senderId)
|
||||
})
|
||||
notificationData.senderId =
|
||||
senderExists && senderExists.username === record.senderUsername
|
||||
? record.senderId
|
||||
: null
|
||||
}
|
||||
} else {
|
||||
notificationData.senderId = null
|
||||
}
|
||||
}
|
||||
notificationData.read = record.hasOwnProperty('read') ? record.read : false
|
||||
notificationData.important = Object.prototype.hasOwnProperty.call(record, 'important')
|
||||
? record.important
|
||||
: false
|
||||
notificationData.createdAt = record.createdAt ? new Date(record.createdAt) : new Date()
|
||||
notificationData.updatedAt = record.updatedAt ? new Date(record.updatedAt) : new Date()
|
||||
|
||||
@@ -1219,79 +904,6 @@ export default defineEventHandler(async (event) => {
|
||||
break
|
||||
}
|
||||
|
||||
case 'cardCodeRedeemLogs': {
|
||||
let validCardCodeId = record.cardCodeId || null
|
||||
if (record.cardCodeId) {
|
||||
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
|
||||
if (mappedCardCodeId) {
|
||||
validCardCodeId = mappedCardCodeId
|
||||
} else {
|
||||
const cardCodeExists = await tx.query.cardCodes.findFirst({
|
||||
where: eq(cardCodes.id, record.cardCodeId)
|
||||
})
|
||||
if (!cardCodeExists) validCardCodeId = null
|
||||
}
|
||||
}
|
||||
|
||||
let validRedeemedBy = record.redeemedBy
|
||||
if (record.redeemedBy) {
|
||||
const mappedUserId = userIdMapping.get(record.redeemedBy)
|
||||
if (mappedUserId) {
|
||||
validRedeemedBy = mappedUserId
|
||||
} else {
|
||||
const userExists = await tx.query.users.findFirst({
|
||||
where: eq(users.id, record.redeemedBy)
|
||||
})
|
||||
if (!userExists) return
|
||||
}
|
||||
} else return
|
||||
|
||||
let validSongId = record.songId || null
|
||||
if (record.songId) {
|
||||
const mappedSongId = songIdMapping.get(record.songId)
|
||||
if (mappedSongId) {
|
||||
validSongId = mappedSongId
|
||||
} else {
|
||||
const songExists = await tx.query.songs.findFirst({
|
||||
where: eq(songs.id, record.songId)
|
||||
})
|
||||
if (!songExists) validSongId = null
|
||||
}
|
||||
}
|
||||
|
||||
const logData: any = {
|
||||
cardCodeId: validCardCodeId,
|
||||
codeSnapshot: record.codeSnapshot,
|
||||
redeemedBy: validRedeemedBy,
|
||||
redeemedAt: record.redeemedAt ? new Date(record.redeemedAt) : new Date(),
|
||||
source: record.source || 'UNKNOWN',
|
||||
songId: validSongId,
|
||||
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
|
||||
}
|
||||
|
||||
if (!logData.codeSnapshot) return
|
||||
|
||||
if (mode === 'merge') {
|
||||
await tx.insert(cardCodeRedeemLogs).values(logData)
|
||||
stats.created++
|
||||
} else {
|
||||
const existing = await tx.query.cardCodeRedeemLogs.findFirst({
|
||||
where: eq(cardCodeRedeemLogs.id, record.id)
|
||||
})
|
||||
if (existing) {
|
||||
await tx
|
||||
.update(cardCodeRedeemLogs)
|
||||
.set(logData)
|
||||
.where(eq(cardCodeRedeemLogs.id, record.id))
|
||||
stats.updated++
|
||||
} else {
|
||||
await tx.insert(cardCodeRedeemLogs).values({ ...logData, id: record.id })
|
||||
stats.created++
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'votes': {
|
||||
let validUserId = record.userId
|
||||
if (record.userId) {
|
||||
@@ -1337,11 +949,6 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'scheduleSongPool': {
|
||||
await restoreScheduleSongPoolRecord(tx, record, songIdMapping, userIdMapping, stats, () => { stats.created++ })
|
||||
break
|
||||
}
|
||||
}
|
||||
})
|
||||
stats.processed++
|
||||
@@ -1352,35 +959,6 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
// 防锁死:恢复系统设置块后校验 SMTP 已配置,否则剥离 registerEmailRequired
|
||||
// (避免恢复后注册必填邮箱却无法发码)
|
||||
if (tableName === 'systemSettings') {
|
||||
try {
|
||||
const restoredSettings = await db.select().from(systemSettings).limit(1)
|
||||
if (
|
||||
restoredSettings[0]?.registerEmailRequired &&
|
||||
(!restoredSettings[0]?.smtpEnabled || !restoredSettings[0]?.smtpHost)
|
||||
) {
|
||||
await db
|
||||
.update(systemSettings)
|
||||
.set({ registerEmailRequired: false })
|
||||
.where(eq(systemSettings.id, restoredSettings[0].id))
|
||||
stats.warnings.push('SMTP 未配置,已剥离 registerEmailRequired,避免注册邮箱流程不可用')
|
||||
}
|
||||
} catch (settingsError) {
|
||||
console.error('恢复后设置一致性校验失败:', settingsError)
|
||||
}
|
||||
}
|
||||
|
||||
// 恢复可能携带显式 id,完成后同步所有自增序列,避免后续插入主键冲突
|
||||
try {
|
||||
await syncAllSequences()
|
||||
} catch (seqError) {
|
||||
const seqErrorMsg = seqError instanceof Error ? seqError.message : '未知错误'
|
||||
stats.warnings.push(`自增序列同步失败: ${seqErrorMsg}`)
|
||||
console.error('自增序列同步失败:', seqError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
newMappings,
|
||||
|
||||
@@ -4,17 +4,13 @@ import {
|
||||
apiKeyPermissions,
|
||||
apiKeys,
|
||||
apiLogs,
|
||||
cardCodeRedeemLogs,
|
||||
cardCodes,
|
||||
collaborationLogs,
|
||||
emailTemplates,
|
||||
gradeClass,
|
||||
notificationSettings,
|
||||
notifications,
|
||||
playTimes,
|
||||
requestTimes,
|
||||
schedules,
|
||||
scheduleSongPool,
|
||||
semesters,
|
||||
songBlacklists,
|
||||
songCollaborators,
|
||||
@@ -28,23 +24,18 @@ import {
|
||||
} from '~/drizzle/schema'
|
||||
import { promises as fs } from 'fs'
|
||||
import path from 'path'
|
||||
import { CacheService } from '../../../services/cacheService'
|
||||
import { SmtpService } from '../../../services/smtpService'
|
||||
import { and, eq, inArray, isNull, notInArray, or } from 'drizzle-orm'
|
||||
import { restoreScheduleSongPoolRecord } from '~~/server/utils/restoreScheduleSongPool'
|
||||
import { omitMaskedSystemSettingsSecrets } from '~~/server/api/admin/system-settings/secretMask'
|
||||
import { validateThemeConfig } from '~~/server/utils/theme-config'
|
||||
import { createApiError } from '~~/server/utils/apiError'
|
||||
import { SERVER_ERROR_CODES } from '~~/server/config/constants'
|
||||
import { normalizeScheduleVisibilitySettings } from '~~/server/utils/system-settings-defaults'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以恢复备份'
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -63,7 +54,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!formData) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请上传备份文件'
|
||||
statusMessage: '请上传备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -83,7 +74,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!fileData) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请上传备份文件'
|
||||
statusMessage: '请上传备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -92,7 +83,7 @@ export default defineEventHandler(async (event) => {
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '备份文件格式错误'
|
||||
statusMessage: '备份文件格式错误'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -110,7 +101,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!filename) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请指定备份文件名或上传备份文件'
|
||||
statusMessage: '请指定备份文件名或上传备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,7 +109,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (safeFilename !== filename) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '备份文件名不合法'
|
||||
statusMessage: '备份文件名不合法'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -126,7 +117,7 @@ export default defineEventHandler(async (event) => {
|
||||
clearExisting = bodyClearExisting
|
||||
overwriteSuperAdmin = bodyOverwriteSuperAdmin
|
||||
|
||||
console.log(`开始恢复数据库备份:${safeFilename}`)
|
||||
console.log(`开始恢复数据库备份: ${safeFilename}`)
|
||||
|
||||
// 读取备份文件
|
||||
const backupDir = path.join(process.cwd(), 'backups')
|
||||
@@ -138,7 +129,7 @@ export default defineEventHandler(async (event) => {
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: 404,
|
||||
message: '备份文件不存在或格式错误'
|
||||
statusMessage: '备份文件不存在或格式错误'
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -147,12 +138,12 @@ export default defineEventHandler(async (event) => {
|
||||
if (!backupData.metadata || !backupData.data) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '备份文件格式无效'
|
||||
statusMessage: '备份文件格式无效'
|
||||
})
|
||||
}
|
||||
|
||||
console.log(`备份文件信息:`)
|
||||
console.log(`- 版本:${backupData.metadata.version}`)
|
||||
console.log(`- 版本: ${backupData.metadata.version}`)
|
||||
console.log(`- 创建时间: ${backupData.metadata.timestamp}`)
|
||||
console.log(`- 创建者: ${backupData.metadata.creator}`)
|
||||
console.log(`- 总记录数: ${backupData.metadata.totalRecords}`)
|
||||
@@ -162,7 +153,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (overwriteSuperAdmin && user.role !== 'SUPER_ADMIN') {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '仅超级管理员可以覆盖超级管理员账号数据'
|
||||
statusMessage: '仅超级管理员可以覆盖超级管理员账号数据'
|
||||
})
|
||||
}
|
||||
const shouldOverwriteSuperAdmin =
|
||||
@@ -190,15 +181,12 @@ export default defineEventHandler(async (event) => {
|
||||
await db.delete(apiKeys)
|
||||
await db.delete(notifications)
|
||||
await db.delete(notificationSettings)
|
||||
await db.delete(cardCodeRedeemLogs)
|
||||
await db.delete(collaborationLogs)
|
||||
await db.delete(songCollaborators)
|
||||
await db.delete(songReplayRequests)
|
||||
await db.delete(scheduleSongPool)
|
||||
await db.delete(schedules)
|
||||
await db.delete(votes)
|
||||
await db.delete(songs)
|
||||
await db.delete(cardCodes)
|
||||
await db.delete(songBlacklists)
|
||||
await db.delete(userStatusLogs)
|
||||
await db.delete(emailTemplates)
|
||||
@@ -239,23 +227,15 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
|
||||
if (preservedUserIdList.length > 0) {
|
||||
await db.delete(cardCodeRedeemLogs)
|
||||
await db.delete(apiKeys).where(notInArray(apiKeys.createdByUserId, preservedUserIdList))
|
||||
await db
|
||||
.delete(notifications)
|
||||
.where(notInArray(notifications.userId, preservedUserIdList))
|
||||
await db.delete(notifications).where(notInArray(notifications.userId, preservedUserIdList))
|
||||
await db
|
||||
.delete(notificationSettings)
|
||||
.where(notInArray(notificationSettings.userId, preservedUserIdList))
|
||||
await db
|
||||
.delete(userStatusLogs)
|
||||
.where(notInArray(userStatusLogs.userId, preservedUserIdList))
|
||||
await db
|
||||
.delete(userIdentities)
|
||||
.where(notInArray(userIdentities.userId, preservedUserIdList))
|
||||
await db.delete(userStatusLogs).where(notInArray(userStatusLogs.userId, preservedUserIdList))
|
||||
await db.delete(userIdentities).where(notInArray(userIdentities.userId, preservedUserIdList))
|
||||
await db.delete(users).where(notInArray(users.id, preservedUserIdList))
|
||||
} else {
|
||||
await db.delete(cardCodeRedeemLogs)
|
||||
await db.delete(apiKeys)
|
||||
await db.delete(notifications)
|
||||
await db.delete(notificationSettings)
|
||||
@@ -267,11 +247,9 @@ export default defineEventHandler(async (event) => {
|
||||
await db.delete(collaborationLogs)
|
||||
await db.delete(songCollaborators)
|
||||
await db.delete(songReplayRequests)
|
||||
await db.delete(scheduleSongPool)
|
||||
await db.delete(schedules)
|
||||
await db.delete(votes)
|
||||
await db.delete(songs)
|
||||
await db.delete(cardCodes)
|
||||
await db.delete(songBlacklists)
|
||||
await db.delete(emailTemplates)
|
||||
await db.delete(playTimes)
|
||||
@@ -289,7 +267,6 @@ export default defineEventHandler(async (event) => {
|
||||
// 建立ID映射表
|
||||
const userIdMapping = new Map() // 备份ID -> 当前数据库ID
|
||||
const songIdMapping = new Map() // 备份ID -> 当前数据库ID
|
||||
const cardCodeIdMapping = new Map() // 备份ID -> 当前数据库ID
|
||||
|
||||
// 定义恢复顺序(考虑外键依赖)
|
||||
const restoreOrder = [
|
||||
@@ -297,21 +274,17 @@ export default defineEventHandler(async (event) => {
|
||||
'playTimes',
|
||||
'semesters',
|
||||
'requestTimes',
|
||||
'gradeClass',
|
||||
'users',
|
||||
'userIdentities',
|
||||
'emailTemplates',
|
||||
'userStatusLogs',
|
||||
'songBlacklist',
|
||||
'cardCodes',
|
||||
'songs',
|
||||
'songCollaborators',
|
||||
'collaborationLogs',
|
||||
'songReplayRequests',
|
||||
'votes',
|
||||
'schedules',
|
||||
'scheduleSongPool',
|
||||
'cardCodeRedeemLogs',
|
||||
'notificationSettings',
|
||||
'notifications',
|
||||
'apiKeys',
|
||||
@@ -349,10 +322,6 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
await db.$transaction(
|
||||
async (tx) => {
|
||||
const stats = {
|
||||
created: 0,
|
||||
warnings: restoreResults.details.warnings
|
||||
}
|
||||
// 根据表名选择恢复策略
|
||||
switch (tableName) {
|
||||
case 'users':
|
||||
@@ -371,10 +340,7 @@ export default defineEventHandler(async (event) => {
|
||||
'forcePasswordChange',
|
||||
'meowNickname',
|
||||
'status',
|
||||
'statusChangedBy',
|
||||
'remark',
|
||||
'avatarProvider',
|
||||
'avatarProviderUserId'
|
||||
'statusChangedBy'
|
||||
]
|
||||
|
||||
// 处理日期字段
|
||||
@@ -393,7 +359,7 @@ export default defineEventHandler(async (event) => {
|
||||
// 确保角色为有效的硬编码角色
|
||||
if (field === 'role') {
|
||||
if (
|
||||
!['USER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(
|
||||
!['USER', 'TEACHER', 'SONG_ADMIN', 'ADMIN', 'SUPER_ADMIN'].includes(
|
||||
record[field]
|
||||
)
|
||||
) {
|
||||
@@ -483,8 +449,7 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
} else {
|
||||
const isBackupSuperAdminRecord =
|
||||
record.role === 'SUPER_ADMIN' ||
|
||||
preservedSuperAdminIds.has(Number(record.id))
|
||||
record.role === 'SUPER_ADMIN' || preservedSuperAdminIds.has(Number(record.id))
|
||||
if (!shouldOverwriteSuperAdmin && isBackupSuperAdminRecord) {
|
||||
if (record.id) {
|
||||
const existingProtectedUser = await tx
|
||||
@@ -494,12 +459,12 @@ export default defineEventHandler(async (event) => {
|
||||
.limit(1)
|
||||
if (existingProtectedUser.length > 0) {
|
||||
userIdMapping.set(record.id, existingProtectedUser[0].id)
|
||||
restoreResults.details.warnings.push(
|
||||
`已保留超级管理员账户 ${record.username || `#${record.id}`}`
|
||||
)
|
||||
break
|
||||
}
|
||||
}
|
||||
restoreResults.details.warnings.push(
|
||||
`已保留超级管理员账户 ${record.username || `#${record.id}`}`
|
||||
)
|
||||
break
|
||||
}
|
||||
|
||||
// 完全恢复模式,检查ID是否已存在
|
||||
@@ -600,7 +565,6 @@ export default defineEventHandler(async (event) => {
|
||||
provider: record.provider,
|
||||
providerUserId: record.providerUserId,
|
||||
providerUsername: record.providerUsername,
|
||||
avatar: record.avatar || null,
|
||||
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
|
||||
}
|
||||
|
||||
@@ -671,11 +635,9 @@ export default defineEventHandler(async (event) => {
|
||||
return // 跳过此记录,因为userId是必需的
|
||||
}
|
||||
|
||||
// 构建用户状态日志数据(含快照列,删除用户后审计可追溯)
|
||||
// 构建用户状态日志数据
|
||||
const userStatusLogData = {
|
||||
userId: validUserStatusLogUserId,
|
||||
username: record.username ?? null,
|
||||
name: record.name ?? null,
|
||||
oldStatus: record.oldStatus || record.previousStatus || null,
|
||||
newStatus: record.newStatus,
|
||||
reason: record.reason || null,
|
||||
@@ -716,98 +678,6 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
break
|
||||
|
||||
case 'cardCodes': {
|
||||
const cardCodeData: any = {}
|
||||
const cardCodeFields = ['code', 'status', 'note']
|
||||
cardCodeFields.forEach((field) => {
|
||||
if (record.hasOwnProperty(field)) {
|
||||
cardCodeData[field] = record[field]
|
||||
}
|
||||
})
|
||||
|
||||
if (!cardCodeData.code) {
|
||||
console.warn('点歌券缺少 code,跳过此记录')
|
||||
return
|
||||
}
|
||||
|
||||
const mapUserId = async (field: 'lockedBy' | 'redeemedBy') => {
|
||||
if (!record[field]) return null
|
||||
const mappedUserId = userIdMapping.get(record[field])
|
||||
if (mappedUserId) return mappedUserId
|
||||
if (mode === 'merge') return null
|
||||
const userExists = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, record[field]))
|
||||
.limit(1)
|
||||
return userExists.length > 0 ? record[field] : null
|
||||
}
|
||||
|
||||
cardCodeData.lockedBy = await mapUserId('lockedBy')
|
||||
cardCodeData.redeemedBy = await mapUserId('redeemedBy')
|
||||
cardCodeData.lockedAt = record.lockedAt ? new Date(record.lockedAt) : null
|
||||
cardCodeData.redeemedAt = record.redeemedAt
|
||||
? new Date(record.redeemedAt)
|
||||
: null
|
||||
cardCodeData.createdAt = record.createdAt
|
||||
? new Date(record.createdAt)
|
||||
: new Date()
|
||||
cardCodeData.updatedAt = record.updatedAt
|
||||
? new Date(record.updatedAt)
|
||||
: new Date()
|
||||
|
||||
let restoredCardCode
|
||||
if (mode === 'merge') {
|
||||
const existingCardCode = await tx
|
||||
.select()
|
||||
.from(cardCodes)
|
||||
.where(eq(cardCodes.code, cardCodeData.code))
|
||||
.limit(1)
|
||||
|
||||
if (existingCardCode.length > 0) {
|
||||
restoredCardCode = (
|
||||
await tx
|
||||
.update(cardCodes)
|
||||
.set(cardCodeData)
|
||||
.where(eq(cardCodes.id, existingCardCode[0].id))
|
||||
.returning()
|
||||
)[0]
|
||||
} else {
|
||||
restoredCardCode = (
|
||||
await tx.insert(cardCodes).values(cardCodeData).returning()
|
||||
)[0]
|
||||
}
|
||||
} else {
|
||||
const existingCardCodeWithId = await tx
|
||||
.select()
|
||||
.from(cardCodes)
|
||||
.where(eq(cardCodes.id, record.id))
|
||||
.limit(1)
|
||||
|
||||
if (existingCardCodeWithId.length > 0) {
|
||||
restoredCardCode = (
|
||||
await tx
|
||||
.update(cardCodes)
|
||||
.set(cardCodeData)
|
||||
.where(eq(cardCodes.id, record.id))
|
||||
.returning()
|
||||
)[0]
|
||||
} else {
|
||||
restoredCardCode = (
|
||||
await tx
|
||||
.insert(cardCodes)
|
||||
.values({ ...cardCodeData, id: record.id })
|
||||
.returning()
|
||||
)[0]
|
||||
}
|
||||
}
|
||||
|
||||
if (record.id && restoredCardCode?.id) {
|
||||
cardCodeIdMapping.set(record.id, restoredCardCode.id)
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'songs':
|
||||
// 验证外键约束
|
||||
let validRequesterId = record.requesterId
|
||||
@@ -852,31 +722,10 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
}
|
||||
|
||||
let validCardCodeId = record.cardCodeId || null
|
||||
if (record.cardCodeId) {
|
||||
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
|
||||
if (mappedCardCodeId) {
|
||||
validCardCodeId = mappedCardCodeId
|
||||
} else {
|
||||
const cardCodeExists = await tx
|
||||
.select({ id: cardCodes.id })
|
||||
.from(cardCodes)
|
||||
.where(eq(cardCodes.id, record.cardCodeId))
|
||||
.limit(1)
|
||||
if (cardCodeExists.length === 0) {
|
||||
console.warn(
|
||||
`歌曲 ${record.title} 的点歌券ID ${record.cardCodeId} 不存在,将设为null`
|
||||
)
|
||||
validCardCodeId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 动态构建歌曲数据,自动跳过不存在的字段
|
||||
const songData = {
|
||||
requesterId: validRequesterId, // 必需字段
|
||||
preferredPlayTimeId: validPreferredPlayTimeId, // 已验证的字段
|
||||
cardCodeId: validCardCodeId
|
||||
preferredPlayTimeId: validPreferredPlayTimeId // 已验证的字段
|
||||
}
|
||||
|
||||
// 基本字段
|
||||
@@ -887,10 +736,8 @@ export default defineEventHandler(async (event) => {
|
||||
'cover',
|
||||
'musicPlatform',
|
||||
'musicId',
|
||||
'durationSeconds',
|
||||
'submissionNote',
|
||||
'submissionNotePublic',
|
||||
'submissionNotePublicStatus'
|
||||
'submissionNotePublic'
|
||||
]
|
||||
songFields.forEach((field) => {
|
||||
if (record.hasOwnProperty(field)) {
|
||||
@@ -1134,58 +981,11 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
break
|
||||
|
||||
case 'gradeClass':
|
||||
// 年级班级配置(唯一约束 grade+class)
|
||||
const gradeClassData = {
|
||||
grade: typeof record.grade === 'string' ? record.grade.trim() : '',
|
||||
class: typeof record.class === 'string' ? record.class.trim() : ''
|
||||
}
|
||||
// 空行直接跳过,避免空值配置行锁死配置优先校验
|
||||
if (!gradeClassData.grade || !gradeClassData.class) {
|
||||
stats.warnings.push(`gradeClass 记录 ${record.id ?? ''} 年级或班级为空,已跳过`)
|
||||
break
|
||||
}
|
||||
|
||||
if (mode === 'merge') {
|
||||
const existingGradeClass = await tx
|
||||
.select()
|
||||
.from(gradeClass)
|
||||
.where(
|
||||
and(
|
||||
eq(gradeClass.grade, gradeClassData.grade),
|
||||
eq(gradeClass.class, gradeClassData.class)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
if (existingGradeClass.length === 0) {
|
||||
await tx.insert(gradeClass).values(gradeClassData)
|
||||
}
|
||||
} else {
|
||||
// 完全恢复模式,检查ID是否已存在
|
||||
const existingGradeClassWithId = await tx
|
||||
.select()
|
||||
.from(gradeClass)
|
||||
.where(eq(gradeClass.id, record.id))
|
||||
.limit(1)
|
||||
|
||||
if (existingGradeClassWithId.length === 0) {
|
||||
await tx.insert(gradeClass).values({
|
||||
...gradeClassData,
|
||||
id: record.id
|
||||
})
|
||||
}
|
||||
}
|
||||
break
|
||||
|
||||
case 'systemSettings':
|
||||
// 动态构建系统设置数据,自动跳过不存在的字段
|
||||
let systemSettingsData = {}
|
||||
const systemSettingsData = {}
|
||||
const systemSettingsFields = [
|
||||
'enablePlayTimeSelection',
|
||||
'instanceId',
|
||||
'defaultTheme',
|
||||
'enabledThemes',
|
||||
'telemetryEnabled',
|
||||
'siteTitle',
|
||||
'siteLogoUrl',
|
||||
'schoolLogoHomeUrl',
|
||||
@@ -1198,24 +998,13 @@ export default defineEventHandler(async (event) => {
|
||||
'dailySubmissionLimit',
|
||||
'weeklySubmissionLimit',
|
||||
'monthlySubmissionLimit',
|
||||
'scheduleDaysBeforeEnabled',
|
||||
'scheduleDaysBefore',
|
||||
'scheduleDaysAfterEnabled',
|
||||
'scheduleDaysAfter',
|
||||
'showBlacklistKeywords',
|
||||
'enableRequestTimeLimitation',
|
||||
'requestTimeLimitation',
|
||||
'forceBlockAllRequests',
|
||||
'forcePasswordChangeOnFirstLogin',
|
||||
'enableReplayRequests',
|
||||
'enableCollaborativeSubmission',
|
||||
'enableSubmissionRemarks',
|
||||
'enableCardCodeRequests',
|
||||
'requireCardCodeForRequests',
|
||||
'enableCardCodeLimitBypass',
|
||||
'enableSubmissionRestriction',
|
||||
'submissionRestrictionScope',
|
||||
'sameSongRestrictionHours',
|
||||
'sameArtistRestrictionHours',
|
||||
'hideStudentInfo',
|
||||
'smtpEnabled',
|
||||
'smtpHost',
|
||||
@@ -1224,57 +1013,7 @@ export default defineEventHandler(async (event) => {
|
||||
'smtpUsername',
|
||||
'smtpPassword',
|
||||
'smtpFromEmail',
|
||||
'smtpFromName',
|
||||
'allowOAuthRegistration',
|
||||
'allowRegister',
|
||||
'registerRequiresApproval',
|
||||
'registerEmailRequired',
|
||||
'registerRequiresGradeClass',
|
||||
'oauthRegisterRequiresApproval',
|
||||
'submissionNoteRequiresApproval',
|
||||
'oauthRedirectUri',
|
||||
'oauthStateSecret',
|
||||
'oauthProviders',
|
||||
'githubOAuthEnabled',
|
||||
'githubClientId',
|
||||
'githubClientSecret',
|
||||
'casdoorOAuthEnabled',
|
||||
'casdoorServerUrl',
|
||||
'casdoorClientId',
|
||||
'casdoorClientSecret',
|
||||
'casdoorOrganizationName',
|
||||
'googleOAuthEnabled',
|
||||
'googleClientId',
|
||||
'googleClientSecret',
|
||||
'aggregateOAuthEnabled',
|
||||
'aggregateOAuthAppId',
|
||||
'aggregateOAuthAppKey',
|
||||
'aggregateOAuthLoginType',
|
||||
'aggregateOAuthEndpoint',
|
||||
'customOAuthEnabled',
|
||||
'customOAuthDisplayName',
|
||||
'customOAuthAuthorizeUrl',
|
||||
'customOAuthTokenUrl',
|
||||
'customOAuthUserInfoUrl',
|
||||
'customOAuthScope',
|
||||
'customOAuthClientId',
|
||||
'customOAuthClientSecret',
|
||||
'customOAuthUserIdField',
|
||||
'customOAuthUsernameField',
|
||||
'customOAuthNameField',
|
||||
'customOAuthEmailField',
|
||||
'customOAuthAvatarField',
|
||||
'captchaEnabled',
|
||||
'captchaMaxFailures',
|
||||
'captchaProvider',
|
||||
'turnstileSiteKey',
|
||||
'turnstileSecretKey',
|
||||
'autoBackupEnabled',
|
||||
'autoBackupConfig',
|
||||
'statisticsCodeEnabled',
|
||||
'statisticsCode',
|
||||
'enabledPlatforms',
|
||||
'platformOrder'
|
||||
'smtpFromName'
|
||||
]
|
||||
|
||||
// 只添加备份数据中存在的字段
|
||||
@@ -1283,24 +1022,6 @@ export default defineEventHandler(async (event) => {
|
||||
systemSettingsData[field] = record[field]
|
||||
}
|
||||
})
|
||||
if (Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
|
||||
if (!Object.prototype.hasOwnProperty.call(systemSettingsData, 'defaultTheme') || !Object.prototype.hasOwnProperty.call(systemSettingsData, 'enabledThemes')) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.THEME_INVALID_LIST, '主题配置必须同时包含默认主题和启用主题列表')
|
||||
}
|
||||
systemSettingsData.enabledThemes = JSON.stringify(validateThemeConfig(systemSettingsData.defaultTheme, systemSettingsData.enabledThemes))
|
||||
}
|
||||
for (const field of ['scheduleDaysBeforeEnabled', 'scheduleDaysAfterEnabled']) {
|
||||
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && systemSettingsData[field] !== null && typeof systemSettingsData[field] !== 'boolean') {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是布尔值`)
|
||||
}
|
||||
}
|
||||
for (const field of ['scheduleDaysBefore', 'scheduleDaysAfter']) {
|
||||
if (Object.prototype.hasOwnProperty.call(systemSettingsData, field) && (systemSettingsData[field] !== null && (!Number.isInteger(systemSettingsData[field]) || systemSettingsData[field] < 1 || systemSettingsData[field] > 730))) {
|
||||
throw createApiError(400, SERVER_ERROR_CODES.COMMON_INVALID_PARAMS, `${field} 必须是 1-730 的正整数`)
|
||||
}
|
||||
}
|
||||
systemSettingsData = normalizeScheduleVisibilitySettings(systemSettingsData)
|
||||
systemSettingsData = omitMaskedSystemSettingsSecrets(systemSettingsData)
|
||||
|
||||
if (mode === 'merge') {
|
||||
// 检查是否存在系统设置记录(通常只有一条记录)
|
||||
@@ -1407,26 +1128,6 @@ export default defineEventHandler(async (event) => {
|
||||
? record.sequence
|
||||
: 1
|
||||
|
||||
// 重播申请绑定:申请先于排期恢复,校验绑定存在且属于同一首歌,避免悬空绑定
|
||||
let validScheduleReplayRequestId = null
|
||||
if (record.replayRequestId) {
|
||||
const boundReplayRequest = await tx
|
||||
.select({
|
||||
id: songReplayRequests.id,
|
||||
songId: songReplayRequests.songId
|
||||
})
|
||||
.from(songReplayRequests)
|
||||
.where(eq(songReplayRequests.id, record.replayRequestId))
|
||||
.limit(1)
|
||||
if (
|
||||
boundReplayRequest.length > 0 &&
|
||||
boundReplayRequest[0].songId === validSongId
|
||||
) {
|
||||
validScheduleReplayRequestId = record.replayRequestId
|
||||
}
|
||||
}
|
||||
scheduleData.replayRequestId = validScheduleReplayRequestId
|
||||
|
||||
if (mode === 'merge') {
|
||||
// 检查是否存在相同的排期(按歌曲ID和播放日期)
|
||||
const existingSchedule = await tx
|
||||
@@ -1603,16 +1304,7 @@ export default defineEventHandler(async (event) => {
|
||||
|
||||
// 动态构建通知数据,自动跳过不存在的字段
|
||||
const notificationData = { userId: validNotificationUserId }
|
||||
const notificationDataFields = [
|
||||
'batchId',
|
||||
'source',
|
||||
'senderName',
|
||||
'senderUsername',
|
||||
'title',
|
||||
'message',
|
||||
'type',
|
||||
'userDeleted'
|
||||
]
|
||||
const notificationDataFields = ['title', 'message', 'type']
|
||||
|
||||
notificationDataFields.forEach((field) => {
|
||||
if (record.hasOwnProperty(field)) {
|
||||
@@ -1620,38 +1312,8 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
})
|
||||
|
||||
// senderId 需要重新映射到目标库的用户 ID;映射未命中时回查同 ID 用户并比对用户名快照,
|
||||
// 不一致则置空,避免跨库恢复时将发送人归属到错误用户,展示仍靠快照字段
|
||||
if (Object.prototype.hasOwnProperty.call(record, 'senderId')) {
|
||||
if (record.senderId) {
|
||||
const mappedSenderId = userIdMapping.get(record.senderId)
|
||||
if (mappedSenderId) {
|
||||
notificationData.senderId = mappedSenderId
|
||||
} else {
|
||||
const senderExists = await tx
|
||||
.select({ id: users.id, username: users.username })
|
||||
.from(users)
|
||||
.where(eq(users.id, record.senderId))
|
||||
.limit(1)
|
||||
notificationData.senderId =
|
||||
senderExists.length > 0 &&
|
||||
senderExists[0].username === record.senderUsername
|
||||
? record.senderId
|
||||
: null
|
||||
}
|
||||
} else {
|
||||
notificationData.senderId = null
|
||||
}
|
||||
}
|
||||
|
||||
// 布尔字段,提供默认值
|
||||
notificationData.read = record.hasOwnProperty('read') ? record.read : false
|
||||
notificationData.important = Object.prototype.hasOwnProperty.call(
|
||||
record,
|
||||
'important'
|
||||
)
|
||||
? record.important
|
||||
: false
|
||||
|
||||
// 日期字段
|
||||
notificationData.createdAt = record.createdAt
|
||||
@@ -1864,28 +1526,9 @@ export default defineEventHandler(async (event) => {
|
||||
if (mappedSongId) validReplaySongId = mappedSongId
|
||||
}
|
||||
|
||||
// 校验期望播出时段是否存在(可选字段)
|
||||
let validReplayPlayTimeId = record.preferredPlayTimeId || null
|
||||
if (validReplayPlayTimeId) {
|
||||
const replayPlayTimeExists = await tx
|
||||
.select({ id: playTimes.id })
|
||||
.from(playTimes)
|
||||
.where(eq(playTimes.id, validReplayPlayTimeId))
|
||||
.limit(1)
|
||||
if (replayPlayTimeExists.length === 0) {
|
||||
validReplayPlayTimeId = null
|
||||
}
|
||||
}
|
||||
|
||||
// 保留原始状态与申请元数据,避免历史已履行记录恢复为 PENDING 触发部分唯一索引冲突
|
||||
const replayData = {
|
||||
songId: validReplaySongId,
|
||||
userId: validReplayUserId,
|
||||
status: record.status || 'PENDING',
|
||||
preferredPlayTimeId: validReplayPlayTimeId,
|
||||
submissionNote: record.submissionNote ?? null,
|
||||
submissionNotePublic: record.submissionNotePublic === true,
|
||||
submissionNotePublicStatus: record.submissionNotePublicStatus ?? null,
|
||||
createdAt: record.createdAt ? new Date(record.createdAt) : new Date(),
|
||||
updatedAt: record.updatedAt ? new Date(record.updatedAt) : new Date()
|
||||
}
|
||||
@@ -1920,101 +1563,6 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
break
|
||||
|
||||
case 'cardCodeRedeemLogs': {
|
||||
let validCardCodeId = record.cardCodeId || null
|
||||
if (record.cardCodeId) {
|
||||
const mappedCardCodeId = cardCodeIdMapping.get(record.cardCodeId)
|
||||
if (mappedCardCodeId) {
|
||||
validCardCodeId = mappedCardCodeId
|
||||
} else {
|
||||
const cardCodeExists = await tx
|
||||
.select({ id: cardCodes.id })
|
||||
.from(cardCodes)
|
||||
.where(eq(cardCodes.id, record.cardCodeId))
|
||||
.limit(1)
|
||||
if (cardCodeExists.length === 0) {
|
||||
validCardCodeId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let validRedeemedBy = record.redeemedBy
|
||||
if (record.redeemedBy) {
|
||||
const mappedUserId = userIdMapping.get(record.redeemedBy)
|
||||
if (mappedUserId) {
|
||||
validRedeemedBy = mappedUserId
|
||||
} else {
|
||||
const userExists = await tx
|
||||
.select({ id: users.id })
|
||||
.from(users)
|
||||
.where(eq(users.id, record.redeemedBy))
|
||||
.limit(1)
|
||||
if (userExists.length === 0) {
|
||||
console.warn(
|
||||
`点歌券日志的操作用户ID ${record.redeemedBy} 不存在,跳过此记录`
|
||||
)
|
||||
return
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.warn('点歌券日志缺少redeemedBy,跳过此记录')
|
||||
return
|
||||
}
|
||||
|
||||
let validLogSongId = record.songId || null
|
||||
if (record.songId) {
|
||||
const mappedSongId = songIdMapping.get(record.songId)
|
||||
if (mappedSongId) {
|
||||
validLogSongId = mappedSongId
|
||||
} else {
|
||||
const songExists = await tx
|
||||
.select({ id: songs.id })
|
||||
.from(songs)
|
||||
.where(eq(songs.id, record.songId))
|
||||
.limit(1)
|
||||
if (songExists.length === 0) {
|
||||
validLogSongId = null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const logData = {
|
||||
cardCodeId: validCardCodeId,
|
||||
codeSnapshot: record.codeSnapshot,
|
||||
redeemedBy: validRedeemedBy,
|
||||
redeemedAt: record.redeemedAt ? new Date(record.redeemedAt) : new Date(),
|
||||
source: record.source || 'UNKNOWN',
|
||||
songId: validLogSongId,
|
||||
createdAt: record.createdAt ? new Date(record.createdAt) : new Date()
|
||||
}
|
||||
|
||||
if (!logData.codeSnapshot) {
|
||||
console.warn('点歌券日志缺少codeSnapshot,跳过此记录')
|
||||
return
|
||||
}
|
||||
|
||||
if (mode === 'merge') {
|
||||
await tx.insert(cardCodeRedeemLogs).values(logData)
|
||||
} else {
|
||||
const existingLogWithId = await tx
|
||||
.select()
|
||||
.from(cardCodeRedeemLogs)
|
||||
.where(eq(cardCodeRedeemLogs.id, record.id))
|
||||
.limit(1)
|
||||
if (existingLogWithId.length > 0) {
|
||||
await tx
|
||||
.update(cardCodeRedeemLogs)
|
||||
.set(logData)
|
||||
.where(eq(cardCodeRedeemLogs.id, record.id))
|
||||
} else {
|
||||
await tx
|
||||
.insert(cardCodeRedeemLogs)
|
||||
.values({ ...logData, id: record.id })
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
|
||||
case 'votes':
|
||||
// 验证外键约束
|
||||
let validVoteUserId = record.userId
|
||||
@@ -2120,11 +1668,6 @@ export default defineEventHandler(async (event) => {
|
||||
}
|
||||
break
|
||||
|
||||
case 'scheduleSongPool': {
|
||||
await restoreScheduleSongPoolRecord(tx, record, songIdMapping, userIdMapping, stats)
|
||||
break
|
||||
}
|
||||
|
||||
case 'requestTimes':
|
||||
// requestTimes表没有外键依赖
|
||||
const requestTimeData = {
|
||||
@@ -2572,23 +2115,6 @@ export default defineEventHandler(async (event) => {
|
||||
console.log(`📊 处理了 ${restoreResults.details.tablesProcessed} 个表`)
|
||||
console.log(`📊 恢复了 ${restoreResults.details.recordsRestored} 条记录`)
|
||||
|
||||
// 防锁死:恢复开关前校验 SMTP 已配置,否则剥离 registerEmailRequired(避免恢复后注册必填邮箱却无法发码)
|
||||
try {
|
||||
const restoredSettings = await db.select().from(systemSettings).limit(1)
|
||||
if (
|
||||
restoredSettings[0]?.registerEmailRequired &&
|
||||
(!restoredSettings[0]?.smtpEnabled || !restoredSettings[0]?.smtpHost)
|
||||
) {
|
||||
await db
|
||||
.update(systemSettings)
|
||||
.set({ registerEmailRequired: false })
|
||||
.where(eq(systemSettings.id, restoredSettings[0].id))
|
||||
console.warn('⚠️ SMTP 未配置,已剥离 registerEmailRequired,避免注册邮箱流程不可用')
|
||||
}
|
||||
} catch (settingsError) {
|
||||
console.error('恢复后设置一致性校验失败:', settingsError)
|
||||
}
|
||||
|
||||
// 重置所有自增序列
|
||||
console.log(`🔄 开始重置自增序列...`)
|
||||
const sequenceResetResults = []
|
||||
@@ -2647,6 +2173,17 @@ export default defineEventHandler(async (event) => {
|
||||
restoreResults.message = '数据恢复完成,但存在错误'
|
||||
}
|
||||
|
||||
// 清除所有缓存
|
||||
try {
|
||||
const cacheService = CacheService.getInstance()
|
||||
await cacheService.clearAllCaches()
|
||||
console.log('数据恢复后缓存已清除')
|
||||
} catch (cacheError) {
|
||||
console.warn('清除缓存失败:', cacheError)
|
||||
restoreResults.details.warnings = restoreResults.details.warnings || []
|
||||
restoreResults.details.warnings.push('清除缓存失败')
|
||||
}
|
||||
|
||||
try {
|
||||
const smtpService = SmtpService.getInstance()
|
||||
const initialized = await smtpService.initializeSmtpConfig()
|
||||
@@ -2666,7 +2203,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('恢复数据库备份失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '恢复数据库备份失败'
|
||||
statusMessage: error.statusMessage || '恢复数据库备份失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -7,10 +7,10 @@ export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
// 验证管理员权限
|
||||
const user = event.context.user
|
||||
if (!user || user.role !== 'SUPER_ADMIN') {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '只有超级管理员可以上传备份文件'
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
||||
keepExtensions: true,
|
||||
maxFileSize: 100 * 1024 * 1024, // 100MB
|
||||
filter: ({ mimetype, originalFilename }) => {
|
||||
// 只允许 JSON 文件
|
||||
// 只允许JSON文件
|
||||
return (
|
||||
mimetype === 'application/json' ||
|
||||
(originalFilename && originalFilename.endsWith('.json'))
|
||||
@@ -41,7 +41,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!files.file || !files.file[0]) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '请选择要上传的备份文件'
|
||||
statusMessage: '请选择要上传的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ export default defineEventHandler(async (event) => {
|
||||
await fs.unlink(uploadedFile.filepath)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '备份文件格式无效'
|
||||
statusMessage: '备份文件格式无效'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -66,7 +66,7 @@ export default defineEventHandler(async (event) => {
|
||||
await fs.unlink(uploadedFile.filepath)
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '不是有效的备份文件'
|
||||
statusMessage: '不是有效的备份文件'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -106,7 +106,7 @@ export default defineEventHandler(async (event) => {
|
||||
// 重新保存文件
|
||||
await fs.writeFile(newFilepath, JSON.stringify(backupData, null, 2), 'utf8')
|
||||
|
||||
console.log(`管理员 ${user.username} 上传了备份文件:${newFilename}`)
|
||||
console.log(`管理员 ${user.username} 上传了备份文件: ${newFilename}`)
|
||||
|
||||
// 获取文件信息
|
||||
const stats = await fs.stat(newFilepath)
|
||||
@@ -127,7 +127,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('上传备份文件失败:', error)
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '上传备份文件失败'
|
||||
statusMessage: error.statusMessage || '上传备份文件失败'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createError, defineEventHandler, getRouterParam } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songBlacklists } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { CacheService } from '../../../services/cacheService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查认证和权限
|
||||
@@ -40,6 +41,15 @@ export default defineEventHandler(async (event) => {
|
||||
// 删除黑名单项
|
||||
await db.delete(songBlacklists).where(eq(songBlacklists.id, id))
|
||||
|
||||
// 清除歌曲缓存(黑名单变更可能影响歌曲提交验证)
|
||||
try {
|
||||
const cacheService = CacheService.getInstance()
|
||||
await cacheService.clearSongsCache()
|
||||
console.log('黑名单删除后歌曲缓存已清除')
|
||||
} catch (cacheError) {
|
||||
console.warn('清除歌曲缓存失败:', cacheError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '黑名单项删除成功'
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createError, defineEventHandler, getRouterParam, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songBlacklists } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { CacheService } from '../../../services/cacheService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查认证和权限
|
||||
@@ -54,6 +55,15 @@ export default defineEventHandler(async (event) => {
|
||||
.where(eq(songBlacklists.id, id))
|
||||
.returning()
|
||||
|
||||
// 清除歌曲缓存(黑名单变更可能影响歌曲提交验证)
|
||||
try {
|
||||
const cacheService = CacheService.getInstance()
|
||||
await cacheService.clearSongsCache()
|
||||
console.log('黑名单更新后歌曲缓存已清除')
|
||||
} catch (cacheError) {
|
||||
console.warn('清除歌曲缓存失败:', cacheError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '黑名单项更新成功',
|
||||
|
||||
@@ -2,11 +2,7 @@ import { createError, defineEventHandler, readBody } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { songBlacklists } from '~/drizzle/schema'
|
||||
import { and, eq } from 'drizzle-orm'
|
||||
import {
|
||||
BLACKLIST_TYPES,
|
||||
BLACKLIST_LANGUAGE_VALUES,
|
||||
BLACKLIST_GENRE_VALUES
|
||||
} from '~~/server/config/constants'
|
||||
import { CacheService } from '../../../services/cacheService'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
// 检查认证和权限
|
||||
@@ -29,27 +25,13 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
}
|
||||
|
||||
if (!Object.values(BLACKLIST_TYPES).includes(type)) {
|
||||
if (!['SONG', 'KEYWORD', 'LANGUAGE', 'GENRE'].includes(type)) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的黑名单类型'
|
||||
})
|
||||
}
|
||||
|
||||
// 语种/曲风为固定候选值,防止录入无法匹配的自由文本
|
||||
if (type === BLACKLIST_TYPES.LANGUAGE && !BLACKLIST_LANGUAGE_VALUES.includes(value.trim())) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的语种候选值'
|
||||
})
|
||||
}
|
||||
if (type === BLACKLIST_TYPES.GENRE && !BLACKLIST_GENRE_VALUES.includes(value.trim())) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '无效的曲风候选值'
|
||||
})
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查是否已存在
|
||||
const existing = await db
|
||||
@@ -77,6 +59,15 @@ export default defineEventHandler(async (event) => {
|
||||
})
|
||||
.returning()
|
||||
|
||||
// 清除歌曲缓存(黑名单变更可能影响歌曲提交验证)
|
||||
try {
|
||||
const cacheService = CacheService.getInstance()
|
||||
await cacheService.clearSongsCache()
|
||||
console.log('黑名单添加后歌曲缓存已清除')
|
||||
} catch (cacheError) {
|
||||
console.warn('清除歌曲缓存失败:', cacheError)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '黑名单项添加成功',
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { changelogEntries } from '~/drizzle/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({ statusCode: 403, message: '只有管理员才能删除更新日志' })
|
||||
}
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
throw createError({ statusCode: 400, message: '无效的 id' })
|
||||
}
|
||||
|
||||
const rows = await db.delete(changelogEntries).where(eq(changelogEntries.id, id)).returning()
|
||||
if (!rows[0]) {
|
||||
throw createError({ statusCode: 404, message: '更新日志不存在' })
|
||||
}
|
||||
|
||||
return { success: true }
|
||||
})
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
import { eq } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { changelogEntries } from '~/drizzle/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({ statusCode: 403, message: '只有管理员才能更新更新日志' })
|
||||
}
|
||||
|
||||
const id = Number(getRouterParam(event, 'id'))
|
||||
if (!Number.isFinite(id) || id <= 0) {
|
||||
throw createError({ statusCode: 400, message: '无效的 id' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const updateData: any = { updatedAt: new Date() }
|
||||
|
||||
if (body?.version !== undefined) {
|
||||
if (body.version !== null && typeof body.version !== 'string') {
|
||||
throw createError({ statusCode: 400, message: 'version 必须是字符串' })
|
||||
}
|
||||
updateData.version = (body.version || '').trim()
|
||||
}
|
||||
|
||||
if (body?.title !== undefined) {
|
||||
if (body.title !== null && typeof body.title !== 'string') {
|
||||
throw createError({ statusCode: 400, message: 'title 必须是字符串' })
|
||||
}
|
||||
updateData.title = (body.title || '').trim()
|
||||
}
|
||||
|
||||
if (body?.content !== undefined) {
|
||||
if (body.content !== null && typeof body.content !== 'string') {
|
||||
throw createError({ statusCode: 400, message: 'content 必须是字符串' })
|
||||
}
|
||||
updateData.content = (body.content || '').trim()
|
||||
}
|
||||
|
||||
if (body?.published !== undefined) {
|
||||
if (typeof body.published !== 'boolean') {
|
||||
throw createError({ statusCode: 400, message: 'published 必须是布尔值' })
|
||||
}
|
||||
updateData.published = body.published
|
||||
updateData.publishedAt = body.published ? new Date() : null
|
||||
}
|
||||
|
||||
if (Object.keys(updateData).length <= 1) {
|
||||
throw createError({ statusCode: 400, message: '没有可更新的字段' })
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.update(changelogEntries)
|
||||
.set(updateData)
|
||||
.where(eq(changelogEntries.id, id))
|
||||
.returning()
|
||||
|
||||
if (!rows[0]) {
|
||||
throw createError({ statusCode: 404, message: '更新日志不存在' })
|
||||
}
|
||||
|
||||
return rows[0]
|
||||
})
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { desc } from 'drizzle-orm'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { changelogEntries } from '~/drizzle/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({ statusCode: 403, message: '只有管理员才能查看更新日志' })
|
||||
}
|
||||
|
||||
return db.select().from(changelogEntries).orderBy(desc(changelogEntries.createdAt))
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { changelogEntries } from '~/drizzle/schema'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user) {
|
||||
throw createError({ statusCode: 401, message: '未授权访问' })
|
||||
}
|
||||
|
||||
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({ statusCode: 403, message: '只有管理员才能创建更新日志' })
|
||||
}
|
||||
|
||||
const body = await readBody(event)
|
||||
const version = typeof body?.version === 'string' ? body.version.trim() : ''
|
||||
const title = typeof body?.title === 'string' ? body.title.trim() : ''
|
||||
const content = typeof body?.content === 'string' ? body.content.trim() : ''
|
||||
const published = body?.published === true
|
||||
|
||||
if (!version) {
|
||||
throw createError({ statusCode: 400, message: 'version 不能为空' })
|
||||
}
|
||||
if (!title) {
|
||||
throw createError({ statusCode: 400, message: 'title 不能为空' })
|
||||
}
|
||||
if (!content) {
|
||||
throw createError({ statusCode: 400, message: 'content 不能为空' })
|
||||
}
|
||||
|
||||
const rows = await db
|
||||
.insert(changelogEntries)
|
||||
.values({
|
||||
version,
|
||||
title,
|
||||
content,
|
||||
published,
|
||||
publishedAt: published ? new Date() : null,
|
||||
createdBy: user.id
|
||||
})
|
||||
.returning()
|
||||
|
||||
return rows[0]
|
||||
})
|
||||
@@ -3,17 +3,13 @@ import { databaseManager } from '~~/server/utils/database-manager'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
try {
|
||||
const authResult = await verifyAdminAuth(event)
|
||||
|
||||
if (!authResult.success) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: authResult.message
|
||||
})
|
||||
}
|
||||
// 验证管理员权限
|
||||
await verifyAdminAuth(event)
|
||||
|
||||
// 清理过期会话
|
||||
const cleanedCount = await databaseManager.cleanupExpiredSessions()
|
||||
|
||||
// 清除健康检查缓存以确保下次检查获取最新状态
|
||||
databaseManager.clearHealthCheckCache()
|
||||
|
||||
return {
|
||||
@@ -22,14 +18,17 @@ export default defineEventHandler(async (event) => {
|
||||
cleanedCount,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
} catch (error: any) {
|
||||
if (error.statusCode === 401) {
|
||||
throw error
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes('认证')) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
statusMessage: 'Unauthorized'
|
||||
})
|
||||
}
|
||||
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: error instanceof Error ? error.message : 'Cleanup failed'
|
||||
statusMessage: error instanceof Error ? error.message : 'Cleanup failed'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!authResult.success) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: authResult.message
|
||||
statusMessage: authResult.message
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('Database performance API error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Internal Server Error'
|
||||
statusMessage: 'Internal Server Error'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!authResult.success) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: authResult.message
|
||||
statusMessage: authResult.message
|
||||
})
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('Database pool status API error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Internal Server Error'
|
||||
statusMessage: 'Internal Server Error'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { createError, defineEventHandler } from 'h3'
|
||||
import { db } from '~/drizzle/db'
|
||||
import { CacheService } from '../../../services/cacheService'
|
||||
import {
|
||||
apiKeyPermissions,
|
||||
apiKeys,
|
||||
@@ -24,29 +25,6 @@ import {
|
||||
} from '~/drizzle/schema'
|
||||
import { ne, sql } from 'drizzle-orm'
|
||||
|
||||
function getFirstRow<T>(result: any): T | undefined {
|
||||
return result?.rows?.[0] ?? result?.[0]
|
||||
}
|
||||
|
||||
function quoteIdentifierPart(value: string) {
|
||||
const normalized =
|
||||
value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1).replace(/""/g, '"') : value
|
||||
|
||||
return `"${normalized.replace(/"/g, '""')}"`
|
||||
}
|
||||
|
||||
function toQualifiedIdentifier(value: string) {
|
||||
return sql.raw(value.split('.').map(quoteIdentifierPart).join('.'))
|
||||
}
|
||||
|
||||
function toPgRelationName(value: string) {
|
||||
return value.split('.').map(quoteIdentifierPart).join('.')
|
||||
}
|
||||
|
||||
function toRegclass(value: string) {
|
||||
return sql.raw(`'${value.replace(/'/g, "''")}'::regclass`)
|
||||
}
|
||||
|
||||
// 重置所有表的自增序列
|
||||
async function resetAutoIncrementSequences() {
|
||||
const tables = [
|
||||
@@ -71,31 +49,15 @@ async function resetAutoIncrementSequences() {
|
||||
|
||||
for (const table of tables) {
|
||||
try {
|
||||
// 获取表的最大ID
|
||||
const maxIdResult = await db.execute(
|
||||
sql`SELECT MAX(id) as max_id FROM ${toQualifiedIdentifier(table.dbName)}`
|
||||
)
|
||||
const maxId = Number(
|
||||
getFirstRow<{ max_id: number | string | null }>(maxIdResult)?.max_id || 0
|
||||
)
|
||||
|
||||
// 获取序列名称
|
||||
const sequenceNameResult = await db.execute(
|
||||
sql`SELECT pg_get_serial_sequence(${toPgRelationName(table.dbName)}, 'id') as sequence_name`
|
||||
sql.raw(`SELECT pg_get_serial_sequence('"${table.dbName}"', 'id') as sequence_name`)
|
||||
)
|
||||
const sequenceName = getFirstRow<{ sequence_name: string | null }>(
|
||||
sequenceNameResult
|
||||
)?.sequence_name
|
||||
const sequenceName = (sequenceNameResult as any)[0]?.sequence_name
|
||||
|
||||
if (sequenceName) {
|
||||
if (maxId === 0) {
|
||||
await db.execute(
|
||||
sql`ALTER SEQUENCE ${toQualifiedIdentifier(sequenceName)} RESTART WITH 1`
|
||||
)
|
||||
} else {
|
||||
const newSequenceValue = maxId
|
||||
await db.execute(sql`SELECT setval(${toRegclass(sequenceName)}, ${newSequenceValue})`)
|
||||
}
|
||||
// 重置序列值到 1
|
||||
await db.execute(sql.raw(`ALTER SEQUENCE ${sequenceName} RESTART WITH 1`))
|
||||
results.push({ table: table.name, success: true })
|
||||
} else {
|
||||
results.push({ table: table.name, success: false, message: 'Sequence not found' })
|
||||
@@ -115,7 +77,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!user || !['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
|
||||
throw createError({
|
||||
statusCode: 403,
|
||||
message: '权限不足'
|
||||
statusMessage: '权限不足'
|
||||
})
|
||||
}
|
||||
|
||||
@@ -264,11 +226,21 @@ export default defineEventHandler(async (event) => {
|
||||
// 重置自增序列
|
||||
const sequenceResults = await resetAutoIncrementSequences()
|
||||
resetResults.details.sequencesReset = sequenceResults.filter((r) => r.success).length
|
||||
|
||||
// 清除缓存
|
||||
try {
|
||||
const cacheService = CacheService.getInstance()
|
||||
await cacheService.clearAllCache()
|
||||
} catch (error) {
|
||||
console.warn('清除缓存失败:', error)
|
||||
resetResults.details.warnings = resetResults.details.warnings || []
|
||||
resetResults.details.warnings.push('清除缓存失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置数据库失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: `重置数据库失败:${error.message}`
|
||||
statusMessage: `重置数据库失败: ${error.message}`
|
||||
})
|
||||
}
|
||||
|
||||
@@ -276,7 +248,7 @@ export default defineEventHandler(async (event) => {
|
||||
} catch (error) {
|
||||
throw createError({
|
||||
statusCode: error.statusCode || 500,
|
||||
message: error.message || '服务器内部错误'
|
||||
statusMessage: error.statusMessage || '服务器内部错误'
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -9,7 +9,7 @@ export default defineEventHandler(async (event) => {
|
||||
if (!authResult.success) {
|
||||
throw createError({
|
||||
statusCode: 401,
|
||||
message: authResult.message
|
||||
statusMessage: authResult.message
|
||||
})
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('Database status API error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: 'Internal Server Error'
|
||||
statusMessage: 'Internal Server Error'
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
@@ -63,7 +63,7 @@ export default defineEventHandler(async (event) => {
|
||||
console.error('检查数据库状态失败:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '检查数据库状态失败:' + error.message
|
||||
statusMessage: '检查数据库状态失败: ' + error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { expectedPlayDates } from '~/drizzle/schema'
|
||||
import { eq } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const user = event.context.user
|
||||
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'SUPER_ADMIN')) {
|
||||
throw createError({ statusCode: 403, message: '权限不足' })
|
||||
}
|
||||
|
||||
const idParam = event.context.params?.id
|
||||
if (!idParam) {
|
||||
throw createError({ statusCode: 400, message: '无效的ID' })
|
||||
}
|
||||
const id = Number(idParam)
|
||||
if (isNaN(id)) {
|
||||
throw createError({ statusCode: 400, message: '无效的ID' })
|
||||
}
|
||||
|
||||
try {
|
||||
await db.delete(expectedPlayDates).where(eq(expectedPlayDates.id, id))
|
||||
return { success: true }
|
||||
} catch (e: any) {
|
||||
throw createError({ statusCode: 500, message: e.message || '删除失败' })
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,95 @@
|
||||
import { db } from '~/drizzle/db'
|
||||
import { expectedPlayDates, songs } from '~/drizzle/schema'
|
||||
import { desc, eq, and } from 'drizzle-orm'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const method = event.node.req.method
|
||||
const user = event.context.user
|
||||
|
||||
if (!user || (user.role !== 'ADMIN' && user.role !== 'SUPER_ADMIN')) {
|
||||
throw createError({ statusCode: 403, message: '权限不足' })
|
||||
}
|
||||
|
||||
if (method === 'GET') {
|
||||
const dates = await db.select().from(expectedPlayDates).orderBy(desc(expectedPlayDates.date))
|
||||
const result = []
|
||||
|
||||
for (const d of dates) {
|
||||
const dateObj = new Date(d.date)
|
||||
const weekdays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
|
||||
const weekday = weekdays[dateObj.getDay()]
|
||||
|
||||
let availableSeqs: number[] = []
|
||||
if (d.availableSequences) {
|
||||
availableSeqs = d.availableSequences.split(',').map(s => parseInt(s.trim(), 10)).filter(n => !isNaN(n))
|
||||
}
|
||||
|
||||
const takenSongs = await db.select({
|
||||
sequence: songs.expectedPlaySequence,
|
||||
title: songs.title,
|
||||
artist: songs.artist,
|
||||
requesterId: songs.requesterId
|
||||
})
|
||||
.from(songs)
|
||||
.where(
|
||||
and(
|
||||
eq(songs.expectedPlayDate, d.date),
|
||||
eq(songs.played, false)
|
||||
)
|
||||
)
|
||||
|
||||
const takenSeqs = new Set(takenSongs.map(s => s.sequence).filter(seq => seq !== null))
|
||||
const takenDetails: Record<number, any> = {}
|
||||
takenSongs.forEach(s => {
|
||||
if (s.sequence !== null) {
|
||||
takenDetails[s.sequence] = { title: s.title, artist: s.artist }
|
||||
}
|
||||
})
|
||||
|
||||
const freeSeqs = availableSeqs.filter(seq => !takenSeqs.has(seq))
|
||||
|
||||
result.push({
|
||||
...d,
|
||||
weekday,
|
||||
allSequences: availableSeqs.sort((a, b) => a - b),
|
||||
takenSequences: Array.from(takenSeqs).sort((a, b) => a - b),
|
||||
takenDetails,
|
||||
freeSequences: freeSeqs.sort((a, b) => a - b),
|
||||
isFull: freeSeqs.length === 0
|
||||
})
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
if (method === 'POST') {
|
||||
const body = await readBody(event)
|
||||
const { date, availableSequences } = body
|
||||
|
||||
if (!date || !availableSequences) {
|
||||
throw createError({ statusCode: 400, message: '日期和序号不能为空' })
|
||||
}
|
||||
|
||||
try {
|
||||
// 检查是否存在
|
||||
const existing = await db.select().from(expectedPlayDates).where(eq(expectedPlayDates.date, date))
|
||||
|
||||
if (existing.length > 0) {
|
||||
// 更新
|
||||
const result = await db.update(expectedPlayDates)
|
||||
.set({ availableSequences })
|
||||
.where(eq(expectedPlayDates.date, date))
|
||||
.returning()
|
||||
return result[0]
|
||||
} else {
|
||||
// 新增
|
||||
const result = await db.insert(expectedPlayDates)
|
||||
.values({ date, availableSequences })
|
||||
.returning()
|
||||
return result[0]
|
||||
}
|
||||
} catch (e: any) {
|
||||
throw createError({ statusCode: 500, message: e.message || '保存失败' })
|
||||
}
|
||||
}
|
||||
})
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user