Compare commits
142
Commits
1106aeea6c
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe807eb39 | ||
|
|
f5a43b37cc | ||
|
|
dc2565da34 | ||
|
|
c51b7c660a | ||
|
|
6f9def0ffd | ||
|
|
d1b643bd93 | ||
|
|
09e7f53a1a | ||
|
|
308a2103ac | ||
|
|
81fd05b39a | ||
|
|
b64ee5cbc6 | ||
|
|
28238ab00f | ||
|
|
5962df638d | ||
|
|
3eff710d5d | ||
|
|
ebf1c3a127 | ||
|
|
752a2eae87 | ||
|
|
cd6e235848 | ||
|
|
deb71875c1 | ||
|
|
dfb4891022 | ||
|
|
792b3d4ef6 | ||
|
|
5039ab3f79 | ||
|
|
1a334bbf5c | ||
|
|
cccaf36990 | ||
|
|
9d3edc2c5e | ||
|
|
af50ff1d95 | ||
|
|
17f2aa7fcd | ||
|
|
64a81393b5 | ||
|
|
575e8b9f57 | ||
|
|
08a37e4e2f | ||
|
|
d26996350e | ||
|
|
a2ba15c3ca | ||
|
|
a080144a86 | ||
|
|
565d6e0fcb | ||
|
|
9db42861e1 | ||
|
|
3614ac6350 | ||
|
|
89cc80911e | ||
|
|
39375c23f2 | ||
|
|
12dac95e1b | ||
|
|
a140ce8621 | ||
|
|
6f5aa625e6 | ||
|
|
381f3debf1 | ||
|
|
6b3a83ebeb | ||
|
|
a0184fdeb0 | ||
|
|
573e168702 | ||
|
|
357876a716 | ||
|
|
25c7517405 | ||
|
|
87aa12d573 | ||
|
|
8a82cb77a7 | ||
|
|
43f05a6e34 | ||
|
|
90e32ad086 | ||
|
|
db685f26ba | ||
|
|
de52e4124c | ||
|
|
92704d6637 | ||
|
|
f80abe659f | ||
|
|
1a2031325b | ||
|
|
e8eada885a | ||
|
|
c2114f0d82 | ||
|
|
37fd18aaea | ||
|
|
c4c30de416 | ||
|
|
ccb39203b8 | ||
|
|
d3d2673e5c | ||
|
|
9a025f5bd5 | ||
|
|
456f676929 | ||
|
|
7034bbaedb | ||
|
|
16f0b1f6b5 | ||
|
|
bdefc105bf | ||
|
|
8ff5bec4f0 | ||
|
|
a55fa96952 | ||
|
|
ac4727551c | ||
|
|
27d718bf01 | ||
|
|
456bc39e3e | ||
|
|
28d06dfdb2 | ||
|
|
604749d08a | ||
|
|
ec39f6ab56 | ||
|
|
71bbd88bb8 | ||
|
|
19ab33b102 | ||
|
|
a481e0ae4c | ||
|
|
9fd8a9ccbd | ||
|
|
f5b7bef285 | ||
|
|
a64fa6bc72 | ||
|
|
7f3e570d8a | ||
|
|
7b605351f4 | ||
|
|
467dd38d5f | ||
|
|
a937f32e6e | ||
|
|
a0f11ff64a | ||
|
|
78c3d878ea | ||
|
|
d8060147ed | ||
|
|
9fc5e3e607 | ||
|
|
8b0934f6f4 | ||
|
|
b1c3af84de | ||
|
|
bf7c40c4fe | ||
|
|
7ee5e8b9e9 | ||
|
|
2ee8056ad1 | ||
|
|
80b7c256b5 | ||
|
|
94832e2c82 | ||
|
|
a82ff6747e | ||
|
|
0c71328efd | ||
|
|
ca190a445a | ||
|
|
d818819fff | ||
|
|
d09d12828d | ||
|
|
153ee41ef0 | ||
|
|
4d39afd23f | ||
|
|
e4fe591b82 | ||
|
|
87276f5add | ||
|
|
0e60f15cfe | ||
|
|
6462b1b8a1 | ||
|
|
cc142a58bc | ||
|
|
285dd24463 | ||
|
|
5c44fca146 | ||
|
|
0373c19b54 | ||
|
|
afb4cd3669 | ||
|
|
124280a92f | ||
|
|
13b9b72170 | ||
|
|
daa86ef50f | ||
|
|
bd8df1f026 | ||
|
|
43e2a05666 | ||
|
|
732c9c6725 | ||
|
|
496edf9f71 | ||
|
|
2097608f51 | ||
|
|
455438aa64 | ||
|
|
d98fe53658 | ||
|
|
7b8dabdb49 | ||
|
|
e65c65d8e3 | ||
|
|
d247cfbda3 | ||
|
|
167b60a368 | ||
|
|
10117fc7e2 | ||
|
|
cfd90865c8 | ||
|
|
f4aa9884e2 | ||
|
|
cfe3d5bfc5 | ||
|
|
10d363ca2b | ||
|
|
97af401d89 | ||
|
|
7b3294a380 | ||
|
|
082b5c854c | ||
|
|
f999d58eef | ||
|
|
3ff6c9dd3e | ||
|
|
47d63a48b0 | ||
|
|
7421906fb9 | ||
|
|
a027816831 | ||
|
|
ed66d2d79f | ||
|
|
08a6a66977 | ||
|
|
5d015a3529 | ||
|
|
1718542228 | ||
|
|
4ce4649aaf |
+11
-8
@@ -1,17 +1,20 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.git
|
||||
node_modules/
|
||||
.nuxt/
|
||||
.output/
|
||||
.git/
|
||||
.gitignore
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.development
|
||||
dist
|
||||
coverage
|
||||
.vscode
|
||||
.idea
|
||||
dist/
|
||||
coverage/
|
||||
.vscode/
|
||||
.idea/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
app/node_modules/
|
||||
VoiceHub-main/
|
||||
app/pages/app/
|
||||
|
||||
+72
-18
@@ -15,7 +15,7 @@ JWT_SECRET="your-very-secure-jwt-secret-key"
|
||||
# WebAuthn / Passkey 配置(可选)
|
||||
# 依赖方 ID (RP ID),必须是你域名的一个有效域名
|
||||
# 规则:必须是请求域名的精确匹配或父域名
|
||||
#
|
||||
#
|
||||
# 示例:
|
||||
# - 请求域名为 voicehub.example.com,RP ID 可设为 voicehub.example.com 或 example.com
|
||||
# - 不配置时,默认使用当前请求的 hostname(适合开发/预览环境)
|
||||
@@ -41,28 +41,50 @@ WEBAUTHN_ORIGIN=
|
||||
NODE_ENV=development
|
||||
|
||||
# Sentry 配置(可选)
|
||||
# 是否启用错误追踪与遥测请在管理员后台的站点配置中控制。
|
||||
# 默认仅在后台开启后发送错误事件和实例在线事件;性能追踪默认 10%,会话回放默认关闭。
|
||||
# 上报的硬性前提:NODE_ENV=production 且非平台预览部署(Vercel preview / Netlify deploy-preview 与 branch-deploy 自动关闭)。
|
||||
# 如需强制覆盖(例如自托管测试环境或临时调试),可设置 SENTRY_ENABLED=true/false,设置后以该值为准。
|
||||
# 如需覆盖采样率,可取消注释并设置为 [0,1] 范围内的数字。
|
||||
# SENTRY_ENABLED=false
|
||||
# SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
# NUXT_PUBLIC_SENTRY_TRACES_SAMPLE_RATE=0.1
|
||||
# NUXT_PUBLIC_SENTRY_REPLAYS_SESSION_SAMPLE_RATE=0
|
||||
# NUXT_PUBLIC_SENTRY_REPLAYS_ON_ERROR_SAMPLE_RATE=0
|
||||
# 错误监控与性能追踪,配置后系统将自动上报运行时异常
|
||||
#
|
||||
# SENTRY_DSN 客户端(浏览器)使用的 DSN(公开可暴露)
|
||||
# 示例:https://publicKey@o0.ingest.sentry.io/0
|
||||
# SENTRY_DSN=
|
||||
#
|
||||
# SENTRY_SERVER_DSN 服务端(Node)使用的 DSN(保密,不应暴露给浏览器)
|
||||
# 如未单独配置,服务端会自动降级使用 SENTRY_DSN
|
||||
# SENTRY_SERVER_DSN=
|
||||
#
|
||||
# 采样率配置(0~1 的小数,默认 1.0 表示 100%)
|
||||
# 错误采样率
|
||||
# SENTRY_ERROR_SAMPLE_RATE=1.0
|
||||
# 性能追踪(Traces)采样率
|
||||
# SENTRY_TRACES_SAMPLE_RATE=1.0
|
||||
# 浏览器会话回放(Replay)采样率,需同时设置下面两项才会生效
|
||||
# 生产环境建议分别设为 0.1 和 1.0,即 10% 正常用户 + 100% 错误用户进行回放
|
||||
# SENTRY_REPLAY_SESSION_SAMPLE_RATE=
|
||||
# SENTRY_REPLAY_ON_ERROR_SAMPLE_RATE=
|
||||
#
|
||||
# 项目版本号,用于区分不同版本的错误;未填时自动使用 package.json 的 version
|
||||
# SENTRY_RELEASE=
|
||||
#
|
||||
# 环境标签,默认继承 NODE_ENV
|
||||
# SENTRY_ENVIRONMENT=
|
||||
|
||||
# Redis 分布式短期状态服务(可选)
|
||||
# 单实例可不配置;多实例/Serverless 建议配置,用于验证码、限流和临时锁定。
|
||||
# Redis 缓存服务(可选,暂不推荐)
|
||||
# 如果设置了 REDIS_URL,系统将自动启用 Redis 缓存功能
|
||||
# 格式:redis://用户名:密码@主机:端口
|
||||
# REDIS_URL="redis://default:password@host:port"
|
||||
|
||||
# Docker Compose 自带的 Redis 容器(compose 自带 redis 服务,仅在启用自带 redis 时用)
|
||||
# - 留空(或空字符串):不设置 Redis 密码,内网部署常用(默认)
|
||||
# - 填写强密码:Redis 会启用 requirepass,连接时 REDIS_URL 里也要带上相同密码
|
||||
REDIS_PASSWORD=
|
||||
|
||||
# Redis 键命名空间,多个 VoiceHub 环境共用 Redis 时应分别设置
|
||||
# REDIS_KEY_PREFIX="voicehub:v2:"
|
||||
|
||||
# OAuth 配置(可选)
|
||||
# 启用相应的 OAuth 提供商需要填写对应的 Client ID 和 Client Secret
|
||||
# OAuth 运行时配置已经迁移到管理员后台;以下变量仅用于兼容旧部署和后台一键导入。
|
||||
# 导入完成后,系统以数据库中保存的配置为准,不会在每次启动时直接读取这些变量。
|
||||
#
|
||||
# 注意:OAuth 运行时配置已经迁移到管理员后台;以下变量仅用于兼容旧部署和后台一键导入。
|
||||
# 导入完成后,系统以数据库中保存的配置为准。
|
||||
|
||||
# OAuth 回调地址(必填,如果启用 OAuth)
|
||||
# 注意:[provider] 是占位符,实际使用时需要根据具体情况填写
|
||||
@@ -84,10 +106,42 @@ CASDOOR_ENDPOINT=
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
|
||||
# 聚合登陆 OAuth
|
||||
# 聚合登陆 OAuth(如 auxx.cn 等聚合登录服务,支持微信/QQ 等通道
|
||||
AGGREGATE_OAUTH_APP_ID=
|
||||
AGGREGATE_OAUTH_APP_KEY=
|
||||
# 支持单值、逗号分隔或 JSON 数组,例如:qq,wx,sina,baidu
|
||||
# 可用值:qq,wx,alipay,sina,baidu,douyin,huawei,xiaomi,gitee,gitea,bilibili,kuaishou
|
||||
AGGREGATE_OAUTH_LOGIN_TYPE=qq
|
||||
AGGREGATE_OAUTH_ENDPOINT=https://example.com/connect.php
|
||||
AGGREGATE_OAUTH_LOGIN_TYPE=wx
|
||||
# 聚合登录服务基地址(https,不带 /oauth/... 路径),例如:https://www.auxx.cn
|
||||
AGGREGATE_OAUTH_ENDPOINT=
|
||||
|
||||
# 微信小程序登录(可选)
|
||||
# 用于小程序端 wx.login() 的 code 换取 openid,对应接口 POST /api/auth/wechat/login
|
||||
# AppID / AppSecret 在微信公众平台「开发管理 → 开发设置 → 开发者ID」中获取
|
||||
# 注意:这与上方聚合登录的 wx 通道不是一回事——小程序 openid 按 AppID 维度生成,两者不通用
|
||||
WECHAT_MINI_APPID=
|
||||
WECHAT_MINI_SECRET=
|
||||
|
||||
# 微信「订阅消息」模板(可选:**留空也能用**,代码里内置了站长选好的模板)
|
||||
# 内置模板(唯一来源:server/utils/wechatSubscribeTemplates.ts 的 BUILTIN_TEMPLATES):
|
||||
# SONG_SELECTED「歌曲排期通知」—— 管理员把歌排进排期/发布排期时发
|
||||
# thing1→songTitle, phrase2→status, time3→playTime, character_string4→sequence
|
||||
# (微信侧真实关键词,模板原文:歌曲名称/排期状态/播放时间/排期序号;**没有日期字段**,
|
||||
# 日期含在 playTime 的 'YYYY-MM-DD HH:mm' 里)
|
||||
# SONG_REJECTED「审核驳回通知」—— 投稿被驳回时发
|
||||
# thing6→songTitle, thing4→reason
|
||||
# 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的模板或字段名)。
|
||||
# ⚠️ 必须是合法 JSON;写了坏值(例如只填一个光秃秃的模板 ID)服务端会**忽略它**并在启动日志里
|
||||
# 打一行「不是合法 JSON,已忽略(仍用内置默认模板)」,属于可恢复状态,但请在体检接口里确认。
|
||||
# {"SONG_SELECTED":{"id":"模板ID"}} ← 只换 ID,沿用内置字段映射
|
||||
# {"SONG_REJECTED":{"id":"模板ID","fields":{"thing6":"songTitle"}}} ← 同时换字段映射
|
||||
# {"SONG_PLAYED":{"id":"模板ID","fields":{"thing1":"songTitle"}}} ← 也可以补一张内置表里没有的模板
|
||||
# fields 的键是模板里的字段名(thing1/phrase2/time3/character_string4,公众平台选用模板时能看到;
|
||||
# **编号是微信自动分配的,不能靠命名规律猜** —— 猜错一位就是整条 47003。核不准就别写,
|
||||
# 服务端会自动调 /wxaapi/newtmpl/gettemplate 读模板原文来校准),
|
||||
# 值是变量名(可用:songTitle / message / time / playDate / playTime / sequence / status / reason)。
|
||||
# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;只有绑定了微信的账号才有 openid;
|
||||
# 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度(旧授权按模板 ID 记账,直接作废)。
|
||||
# 发不出去时先看 GET /api/admin/wechat/diagnose(超管,只读不消耗额度):
|
||||
# 它会把「拿不拿到 token / 有没有绑微信 / 通知开关开没开 / 还剩几条授权」逐项说清。
|
||||
WECHAT_MINI_SUBSCRIBE_TEMPLATES=
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
# 强制 shell 脚本与工作流用 LF:runner 是 Linux,CRLF 会让 `run: |` 里的 heredoc / 多行脚本出问题
|
||||
*.sh text eol=lf
|
||||
*.yml text eol=lf
|
||||
*.yaml text eol=lf
|
||||
*.mjs text eol=lf
|
||||
*.js text eol=lf
|
||||
*.ts text eol=lf
|
||||
*.json text eol=lf
|
||||
# 迁移 SQL 也用 LF:这些文件会在容器里被 drizzle-kit 读取执行,不要混进 CRLF
|
||||
*.sql text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
+271
-439
@@ -1,5 +1,14 @@
|
||||
# 精简版部署流水线(2 个 job)
|
||||
#
|
||||
# 精简前是 4 个 job:preflight(npm ci + nuxt build)、meta-warmup(系统环境准备 + 装 buildx)、
|
||||
# build-image(又装一次 buildx + docker build)、deploy。其中 preflight 和 meta-warmup 的产物
|
||||
# 从来没被 docker build 用到(Dockerfile:24-59 自己在容器里跑 npm ci + node scripts/memory-build.js,
|
||||
# 用的是镜像自己的分层缓存),两个 job 各跑 3-6 分钟纯属白烧;meta-warmup 还在 build-image 之前
|
||||
# 用同一个 runner 抢内存。
|
||||
#
|
||||
# 现在:build-image(构建+推送) → deploy(SSH 滚动部署)。构建缓存交给 BuildKit 的
|
||||
# registry 缓存(type=registry,缓存层存在 Gitea registry 的 :buildcache tag 里,跨 run 复用)。
|
||||
name: 构建并部署 BYW VoiceHub
|
||||
run-name: ${{ github.actor }} 触发部署 · Run ${{ github.run_number }}
|
||||
|
||||
on:
|
||||
push:
|
||||
@@ -7,305 +16,98 @@ on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
deploy_target:
|
||||
description: 构建完成后是否自动部署到服务器
|
||||
description: "只构建不部署时不要勾选"
|
||||
type: boolean
|
||||
default: true
|
||||
required: false
|
||||
skip_build_cache:
|
||||
description: 跳过 BuildKit 分层缓存,强制重新构建(排错用)
|
||||
description: "跳过构建缓存(强制重新构建)"
|
||||
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 限速
|
||||
IMAGE_NAME: byw-voicehub
|
||||
DOCKER_MIRROR_PREFIX: docker.m.daocloud.io/library/
|
||||
NODE_BUILDER_TAG: "22-alpine"
|
||||
NODE_RUNTIME_TAG: "22-alpine"
|
||||
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
|
||||
DEPLOY_PATH: /data/opt/byw-voicehub
|
||||
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
|
||||
# 构建缓存 tag(存在 Gitea registry 里,跨 run 复用)
|
||||
CACHE_TAG: buildcache
|
||||
|
||||
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
|
||||
name: 构建并推送镜像
|
||||
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 }}
|
||||
image_tag: ${{ steps.meta.outputs.image_tag }}
|
||||
image_full: ${{ steps.meta.outputs.image_full }}
|
||||
image_latest: ${{ steps.meta.outputs.image_latest }}
|
||||
steps:
|
||||
- name: 系统环境准备(Ubuntu 22.04 默认镜像源换清华 + 全局 GitHub gh-proxy)
|
||||
- name: 准备 Docker(registry-mirrors)
|
||||
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 tee /etc/docker/daemon.json >/dev/null <<JSON
|
||||
{
|
||||
"registry-mirrors": [
|
||||
"${DOCKER_REGISTRY_MIRROR_1}",
|
||||
"${DOCKER_REGISTRY_MIRROR_2}",
|
||||
"${DOCKER_REGISTRY_MIRROR_3}"
|
||||
]
|
||||
}
|
||||
JSON
|
||||
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
|
||||
|| echo "[Docker] 重启失败,继续(下面用显式 mirror 前缀拉镜像兜底)"
|
||||
sleep 3 || true
|
||||
fi
|
||||
|
||||
echo "环境准备完成"
|
||||
cat /etc/os-release 2>/dev/null | head -4 || true
|
||||
docker --version
|
||||
docker buildx version || echo "buildx 未安装"
|
||||
docker info 2>/dev/null | sed -n '/Registry Mirrors/,/^$/p' || true
|
||||
|
||||
- name: 检出代码
|
||||
run: |
|
||||
set -euxo pipefail
|
||||
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
|
||||
BRANCH="${GITHUB_REF_NAME:-main}"
|
||||
echo "Clone from: ${REPO_URL} (${BRANCH})"
|
||||
rm -rf * .[^.]* 2>/dev/null || true
|
||||
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
|
||||
git rev-parse --short HEAD
|
||||
docker version
|
||||
docker buildx version || echo "buildx 未安装,将使用 docker build --push"
|
||||
|
||||
- name: 注入 OAuth secrets(构建期 build-arg)
|
||||
env:
|
||||
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
|
||||
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
|
||||
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
|
||||
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
|
||||
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
|
||||
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for pair in \
|
||||
"OAUTH_REDIRECT_URI:${OAUTH_REDIRECT_URI:-}" \
|
||||
"OAUTH_STATE_SECRET:${OAUTH_STATE_SECRET:-}" \
|
||||
"AGGREGATE_OAUTH_APP_ID:${AGGREGATE_OAUTH_APP_ID:-}" \
|
||||
"AGGREGATE_OAUTH_APP_KEY:${AGGREGATE_OAUTH_APP_KEY:-}" \
|
||||
"AGGREGATE_OAUTH_LOGIN_TYPE:${AGGREGATE_OAUTH_LOGIN_TYPE:-}" \
|
||||
"AGGREGATE_OAUTH_ENDPOINT:${AGGREGATE_OAUTH_ENDPOINT:-}"; do
|
||||
K="${pair%%:*}"; V="${pair#*:}"
|
||||
if [ -n "${V}" ]; then
|
||||
printf '%s="%s"\n' "$K" "$V" >> "$GITHUB_ENV"
|
||||
echo "[OAuth-secrets] $K=已注入 (len=${#V})"
|
||||
else
|
||||
echo "[OAuth-secrets] $K=未配置"
|
||||
fi
|
||||
done
|
||||
|
||||
- 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)}"
|
||||
@@ -313,114 +115,115 @@ jobs:
|
||||
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 "======================================"
|
||||
CACHE_REF="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:${CACHE_TAG}"
|
||||
{
|
||||
echo "image_tag=${DATE_TAG}"
|
||||
echo "image_full=${IMG_FULL}"
|
||||
echo "image_latest=${IMG_LATEST}"
|
||||
echo "cache_ref=${CACHE_REF}"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
echo "Image tag : ${DATE_TAG}"
|
||||
echo "Full image : ${IMG_FULL}"
|
||||
echo "Latest image : ${IMG_LATEST}"
|
||||
echo "Cache ref : ${CACHE_REF_INNER}"
|
||||
echo "======================================"
|
||||
echo "Cache ref : ${CACHE_REF}"
|
||||
|
||||
- name: 初始化 Docker Buildx builder(带国内 registry-mirrors + 内置 Gitea Basic Auth)
|
||||
- name: 安装 BuildKit builder(可选)+ 登录 registry
|
||||
id: buildx
|
||||
env:
|
||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
|
||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
|
||||
REGISTRY_PULL_USER: ${{ secrets.REGISTRY_PULL_USER }}
|
||||
REGISTRY_PULL_TOKEN: ${{ secrets.REGISTRY_PULL_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)"
|
||||
REGISTRY_USER="${REGISTRY_USER,,}"
|
||||
|
||||
# 兜底登录凭证:REGISTRY_* 优先,缺失则用 REGISTRY_PULL_*
|
||||
LOGIN_USER="${REGISTRY_USER}"
|
||||
LOGIN_TOKEN="${REGISTRY_TOKEN}"
|
||||
if [ -z "${LOGIN_TOKEN}" ] || [ "${LOGIN_TOKEN}" = "null" ]; then
|
||||
LOGIN_USER="${REGISTRY_PULL_USER:-}"
|
||||
LOGIN_TOKEN="${REGISTRY_PULL_TOKEN:-}"
|
||||
fi
|
||||
if [ -z "${LOGIN_TOKEN}" ]; then
|
||||
echo "ERROR: REGISTRY_TOKEN / REGISTRY_PULL_TOKEN 均为空,无法登录 ${GITEA_HOST}"
|
||||
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}" \
|
||||
|
||||
# 先把 buildkitd 基础镜像拉到本地(多 mirror 轮询),装完插件后直接复用
|
||||
for M in \
|
||||
"${DOCKER_REGISTRY_MIRROR_1#https://}/${BUILDKIT_IMAGE}" \
|
||||
"${DOCKER_REGISTRY_MIRROR_2#https://}/${BUILDKIT_IMAGE}" \
|
||||
"${DOCKER_REGISTRY_MIRROR_3#https://}/${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
|
||||
if docker pull "${M}" 2>/dev/null; then
|
||||
[ "${M}" = "${BUILDKIT_IMAGE}" ] || docker tag "${M}" "${BUILDKIT_IMAGE}" 2>/dev/null || true
|
||||
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 }}
|
||||
printf '%s' "${LOGIN_TOKEN}" | docker login "${GITEA_HOST}" -u "${LOGIN_USER}" --password-stdin
|
||||
|
||||
# buildx 插件不是必须的:没有就走普通 docker build(仍是同一份 Dockerfile,
|
||||
# 只是没有跨 run 的 registry 构建缓存)。绝不因为装不上而让部署失败。
|
||||
if docker buildx version >/dev/null 2>&1 \
|
||||
&& docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
|
||||
echo "[buildx] 插件已可用:$(docker buildx version 2>&1 | head -1)"
|
||||
else
|
||||
echo "[buildx] 未发现可用插件,尝试安装官方 buildx"
|
||||
ARCH="$(uname -m)"
|
||||
case "${ARCH}" in
|
||||
x86_64|amd64) BX_ARCH=amd64 ;;
|
||||
aarch64|arm64) BX_ARCH=arm64 ;;
|
||||
*) BX_ARCH="" ;;
|
||||
esac
|
||||
if [ -n "${BX_ARCH}" ]; then
|
||||
BX_VER="v0.19.3"
|
||||
BX_DIR="${HOME}/.docker/cli-plugins"
|
||||
mkdir -p "${BX_DIR}"
|
||||
curl -fsSL --retry 3 --connect-timeout 20 -o "${BX_DIR}/docker-buildx" \
|
||||
"${GH_PROXY_PREFIX}https://github.com/docker/buildx/releases/download/${BX_VER}/buildx-${BX_VER}.linux-${BX_ARCH}" \
|
||||
|| curl -fsSL --retry 3 --connect-timeout 20 -o "${BX_DIR}/docker-buildx" \
|
||||
"https://github.com/docker/buildx/releases/download/${BX_VER}/buildx-${BX_VER}.linux-${BX_ARCH}" \
|
||||
|| rm -f "${BX_DIR}/docker-buildx"
|
||||
[ -f "${BX_DIR}/docker-buildx" ] && chmod +x "${BX_DIR}/docker-buildx" || true
|
||||
fi
|
||||
fi
|
||||
|
||||
if docker buildx version >/dev/null 2>&1 \
|
||||
&& docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
|
||||
docker buildx rm multi 2>/dev/null || true
|
||||
if docker buildx create --name multi --driver docker-container --use \
|
||||
--driver-opt network=host \
|
||||
--driver-opt "image=${BUILDKIT_IMAGE}" \
|
||||
&& docker buildx inspect --bootstrap >/dev/null 2>&1; then
|
||||
echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV"
|
||||
echo "[buildx] builder multi 就绪(启用 registry 构建缓存)"
|
||||
else
|
||||
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
|
||||
echo "[buildx] builder 创建失败,转 docker build 兜底"
|
||||
fi
|
||||
else
|
||||
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
|
||||
echo "[buildx] 插件不可用,转 docker build 兜底(无跨 run 缓存)"
|
||||
fi
|
||||
|
||||
- name: 构建并推送镜像
|
||||
env:
|
||||
SKIP_CACHE: ${{ inputs.skip_build_cache }}
|
||||
IMG_FULL: ${{ steps.meta.outputs.full }}
|
||||
IMG_LATEST: ${{ steps.meta.outputs.latest }}
|
||||
IMG_FULL: ${{ steps.meta.outputs.image_full }}
|
||||
IMG_LATEST: ${{ steps.meta.outputs.image_latest }}
|
||||
CACHE_REF: ${{ steps.meta.outputs.cache_ref }}
|
||||
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,,}"
|
||||
CACHE_REF="${CACHE_REF,,}"
|
||||
|
||||
BUILD_ARGS=(
|
||||
--build-arg BUILDKIT_INLINE_CACHE=1
|
||||
@@ -429,55 +232,67 @@ jobs:
|
||||
--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 认证失败)"
|
||||
for V in OAUTH_REDIRECT_URI OAUTH_STATE_SECRET AGGREGATE_OAUTH_APP_ID \
|
||||
AGGREGATE_OAUTH_APP_KEY AGGREGATE_OAUTH_LOGIN_TYPE AGGREGATE_OAUTH_ENDPOINT; do
|
||||
eval "VAL=\${${V}:-}"
|
||||
if [ -n "${VAL}" ]; then
|
||||
BUILD_ARGS+=( --build-arg "${V}=${VAL}" )
|
||||
echo "[build-arg] ${V}=已注入"
|
||||
else
|
||||
echo "[build-arg] ${V}=空"
|
||||
fi
|
||||
done
|
||||
|
||||
# ========== 阶段 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}" \
|
||||
.
|
||||
TAGS=( --tag "${IMG_FULL}" --tag "${IMG_LATEST}" )
|
||||
LABELS=(
|
||||
--label "org.opencontainers.image.source=${OCS_SOURCE}"
|
||||
--label "org.opencontainers.image.revision=${OCS_REVISION}"
|
||||
--label "org.opencontainers.image.created=${OCS_CREATED}"
|
||||
)
|
||||
|
||||
if [ "${BUILDX_USABLE:-no}" = "yes" ]; then
|
||||
echo "[build] docker buildx build(含 registry 缓存)"
|
||||
CACHE_ARGS=()
|
||||
if [ "${SKIP_CACHE}" = "true" ]; then
|
||||
echo "[build] skip_build_cache=true → 不使用缓存,仅更新缓存"
|
||||
else
|
||||
CACHE_ARGS+=( --cache-from "type=registry,ref=${CACHE_REF}" )
|
||||
fi
|
||||
CACHE_ARGS+=( --cache-to "type=registry,ref=${CACHE_REF},mode=max" )
|
||||
if ! docker buildx build \
|
||||
--file ./Dockerfile \
|
||||
--platform linux/amd64 \
|
||||
--load \
|
||||
--provenance=false \
|
||||
"${BUILD_ARGS[@]}" "${CACHE_ARGS[@]}" "${TAGS[@]}" "${LABELS[@]}" . ; then
|
||||
echo "[build] 带 registry 缓存的构建失败(可能是本机 docker 版本不认 registry 缓存导出),去掉缓存重试一次"
|
||||
docker buildx build \
|
||||
--file ./Dockerfile \
|
||||
--platform linux/amd64 \
|
||||
--load \
|
||||
--provenance=false \
|
||||
"${BUILD_ARGS[@]}" "${TAGS[@]}" "${LABELS[@]}" .
|
||||
fi
|
||||
else
|
||||
echo "[build] docker build 兜底(无构建缓存)"
|
||||
docker build \
|
||||
--file ./Dockerfile \
|
||||
--platform linux/amd64 \
|
||||
"${BUILD_ARGS[@]}" "${TAGS[@]}" "${LABELS[@]}" .
|
||||
fi
|
||||
|
||||
# 断言:两个 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 image inspect "${IMG_FULL}" --format='{{.Size}}' \
|
||||
| awk '{printf "镜像体积:%.2f MB\n", $1/1024/1024}'
|
||||
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
|
||||
echo "推送完成:${IMG_FULL} + ${IMG_LATEST}"
|
||||
|
||||
deploy:
|
||||
name: 部署到生产服务器
|
||||
needs: build-image
|
||||
if: ${{ !cancelled() && !failure() && (github.event_name == 'push' || inputs.deploy_target == true) }}
|
||||
needs: [build-image]
|
||||
if: needs.build-image.result == 'success' && (github.event_name == 'push' || inputs.deploy_target == true)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 25
|
||||
environment:
|
||||
@@ -497,7 +312,7 @@ jobs:
|
||||
[ -n "${DEPLOY_SSH_KEY}" ] || MISSING+=("DEPLOY_SSH_KEY")
|
||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||
echo "缺少以下 Repository Secrets:${MISSING[*]}"
|
||||
echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加(注意是 Repository Secrets,不是 Deploy Keys)"
|
||||
echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加"
|
||||
exit 1
|
||||
fi
|
||||
echo "部署密钥配置完整(DEPLOY_HOST=${DEPLOY_HOST},DEPLOY_USER=${DEPLOY_USER})"
|
||||
@@ -516,9 +331,19 @@ jobs:
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
||||
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
|
||||
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
|
||||
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
|
||||
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
|
||||
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
|
||||
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
|
||||
WECHAT_MINI_APPID: ${{ secrets.WECHAT_MINI_APPID }}
|
||||
WECHAT_MINI_SECRET: ${{ secrets.WECHAT_MINI_SECRET }}
|
||||
WECHAT_MINI_SUBSCRIBE_TEMPLATES: ${{ secrets.WECHAT_MINI_SUBSCRIBE_TEMPLATES }}
|
||||
KUGOU_ADMIN_COOKIE: ${{ secrets.KUGOU_ADMIN_COOKIE }}
|
||||
KUGOU_ADMIN_TOKEN: ${{ secrets.KUGOU_ADMIN_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# 全小写规范(Docker Registry 硬性要求 + Gitea raw URL 路径大小写敏感)
|
||||
IMAGE_NEW="${IMAGE_NEW,,}"
|
||||
IMAGE_LATEST="${IMAGE_LATEST,,}"
|
||||
GITHUB_REPOSITORY="${GITHUB_REPOSITORY,,}"
|
||||
@@ -526,7 +351,6 @@ jobs:
|
||||
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}"
|
||||
@@ -541,34 +365,31 @@ jobs:
|
||||
-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' \
|
||||
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\nexport OAUTH_REDIRECT_URI=%q\nexport OAUTH_STATE_SECRET=%q\nexport AGGREGATE_OAUTH_APP_ID=%q\nexport AGGREGATE_OAUTH_APP_KEY=%q\nexport AGGREGATE_OAUTH_LOGIN_TYPE=%q\nexport AGGREGATE_OAUTH_ENDPOINT=%q\nexport WECHAT_MINI_APPID=%q\nexport WECHAT_MINI_SECRET=%q\nexport WECHAT_MINI_SUBSCRIBE_TEMPLATES=%q\nexport KUGOU_ADMIN_COOKIE=%q\nexport KUGOU_ADMIN_TOKEN=%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}")"
|
||||
"${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_REF_NAME}" \
|
||||
"${OAUTH_REDIRECT_URI}" "${OAUTH_STATE_SECRET}" \
|
||||
"${AGGREGATE_OAUTH_APP_ID}" "${AGGREGATE_OAUTH_APP_KEY}" \
|
||||
"${AGGREGATE_OAUTH_LOGIN_TYPE}" "${AGGREGATE_OAUTH_ENDPOINT}" \
|
||||
"${WECHAT_MINI_APPID}" "${WECHAT_MINI_SECRET}" \
|
||||
"${WECHAT_MINI_SUBSCRIBE_TEMPLATES:-}" \
|
||||
"${KUGOU_ADMIN_COOKIE:-}" "${KUGOU_ADMIN_TOKEN:-}")"
|
||||
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
|
||||
@@ -576,80 +397,104 @@ jobs:
|
||||
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 "${AGGREGATE_OAUTH_APP_ID}" ] || [ -n "${WECHAT_MINI_APPID}" ]; then
|
||||
ENV_FILE="${DEPLOY_DIR}/.env"
|
||||
touch "${ENV_FILE}"
|
||||
MERGE_OK=1
|
||||
upsert_env_key() {
|
||||
_K="$1"; _V="$2"
|
||||
if [ -z "${_V}" ]; then return 0; fi
|
||||
# 值的引号要自适应:JSON(含双引号)必须用单引号包,否则 .env 会变成坏行。
|
||||
_Q='"'
|
||||
case "${_V}" in
|
||||
*'"'*) _Q="'" ;;
|
||||
esac
|
||||
_LINE="${_K}=${_Q}${_V}${_Q}"
|
||||
if grep -q "^${_K}=" "${ENV_FILE}" 2>/dev/null; then
|
||||
# 用 grep -v 重写而不是 awk 改字段:awk 的 FS="=" 会吃掉值里 "=" 之后的内容
|
||||
# (base64 密钥常带 "=" 填充)
|
||||
if grep -v "^${_K}=" "${ENV_FILE}" > "${ENV_FILE}.tmp.$$" 2>/dev/null; then
|
||||
printf '%s\n' "${_LINE}" >> "${ENV_FILE}.tmp.$$"
|
||||
mv "${ENV_FILE}.tmp.$$" "${ENV_FILE}" || { rm -f "${ENV_FILE}.tmp.$$"; MERGE_OK=0; }
|
||||
else
|
||||
rm -f "${ENV_FILE}.tmp.$$"
|
||||
MERGE_OK=0
|
||||
fi
|
||||
else
|
||||
printf '%s\n' "${_LINE}" >> "${ENV_FILE}"
|
||||
fi
|
||||
}
|
||||
upsert_env_key OAUTH_REDIRECT_URI "${OAUTH_REDIRECT_URI}" || MERGE_OK=0
|
||||
upsert_env_key OAUTH_STATE_SECRET "${OAUTH_STATE_SECRET}" || MERGE_OK=0
|
||||
upsert_env_key AGGREGATE_OAUTH_APP_ID "${AGGREGATE_OAUTH_APP_ID}" || MERGE_OK=0
|
||||
upsert_env_key AGGREGATE_OAUTH_APP_KEY "${AGGREGATE_OAUTH_APP_KEY}" || MERGE_OK=0
|
||||
upsert_env_key AGGREGATE_OAUTH_LOGIN_TYPE "${AGGREGATE_OAUTH_LOGIN_TYPE}" || MERGE_OK=0
|
||||
upsert_env_key AGGREGATE_OAUTH_ENDPOINT "${AGGREGATE_OAUTH_ENDPOINT}" || MERGE_OK=0
|
||||
upsert_env_key WECHAT_MINI_APPID "${WECHAT_MINI_APPID}" || MERGE_OK=0
|
||||
upsert_env_key WECHAT_MINI_SECRET "${WECHAT_MINI_SECRET}" || MERGE_OK=0
|
||||
# 变量必须同时在【本 step 的 env:】【ENV_PAYLOAD 的 export 列表】两处出现:
|
||||
# 远端脚本是 set -euxo pipefail,只在一处声明 → 另一端就是 unbound variable。
|
||||
upsert_env_key WECHAT_MINI_SUBSCRIBE_TEMPLATES "${WECHAT_MINI_SUBSCRIBE_TEMPLATES:-}" || MERGE_OK=0
|
||||
upsert_env_key KUGOU_ADMIN_COOKIE "${KUGOU_ADMIN_COOKIE:-}" || MERGE_OK=0
|
||||
upsert_env_key KUGOU_ADMIN_TOKEN "${KUGOU_ADMIN_TOKEN:-}" || MERGE_OK=0
|
||||
rm -f "${ENV_FILE}.tmp.$$"
|
||||
if [ "${MERGE_OK}" = "1" ]; then
|
||||
echo "[Runtime-env] 运行期配置已合并写入 ${ENV_FILE}"
|
||||
else
|
||||
echo "[Runtime-env] WARNING:合并写入失败,请手动检查 ${ENV_FILE};仍尝试继续部署"
|
||||
fi
|
||||
else
|
||||
echo "[Runtime-env] AGGREGATE_OAUTH_APP_ID 与 WECHAT_MINI_APPID 均未设置——跳过写入远端 .env"
|
||||
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
|
||||
| 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 前缀)"
|
||||
echo "docker-compose.yml 不存在,自动从仓库模板下载"
|
||||
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)"
|
||||
|| echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env"
|
||||
fi
|
||||
|
||||
if [ ! -f .env ]; then
|
||||
echo "================================================================="
|
||||
echo "WARNING:${DEPLOY_DIR}/.env 不存在"
|
||||
echo "首次部署请在服务器上创建 .env(至少填写 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL + 5 个 cpus 数字兜底:APP_CPUS/APP_CPUS_LIMIT/APP_CPUS_RESERVATION/PG_CPUS/REDIS_CPUS)"
|
||||
echo "参考仓库根目录的 .env.example"
|
||||
echo "================================================================="
|
||||
echo "WARNING:${DEPLOY_DIR}/.env 不存在(首次部署请参考仓库根目录 .env.example 创建)"
|
||||
fi
|
||||
|
||||
echo "Executing docker compose up -d ..."
|
||||
@@ -664,12 +509,7 @@ jobs:
|
||||
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
|
||||
echo "${STATUS}" | grep -q '"State":"running"' || ${COMPOSE} ps || true
|
||||
sleep 5
|
||||
WAITED=$((WAITED + 1))
|
||||
done
|
||||
@@ -683,16 +523,8 @@ jobs:
|
||||
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 "================================================================="
|
||||
echo "Rollback: cd ${DEPLOY_DIR} && printf 'services:\\n voicehub:\\n image: <OLD_TAG>\\n' > docker-compose.override.yml && docker compose up -d"
|
||||
REMOTE_SCRIPT_MAIN
|
||||
|
||||
# 清理私钥临时文件
|
||||
rm -f "${SSH_KEY_FILE}"
|
||||
|
||||
@@ -110,15 +110,6 @@ body:
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: instance-id
|
||||
attributes:
|
||||
label: 实例 ID(可选)
|
||||
description: 如果方便,可以提供系统实例 ID(可在管理员后台「系统状态」或浏览器控制台中查看),这能帮助开发者关联到具体的部署环境
|
||||
placeholder: 例如:a807234c-cc23-45ea-ab3b-dcb604e1b7d9
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for npm
|
||||
- package-ecosystem: 'npm'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'saturday'
|
||||
time: '09:00'
|
||||
timezone: 'Asia/Shanghai'
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: 'chore'
|
||||
prefix-development: 'chore'
|
||||
include: 'scope'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'automated'
|
||||
- 'chore'
|
||||
allow:
|
||||
- dependency-type: 'direct'
|
||||
- dependency-type: 'indirect'
|
||||
ignore:
|
||||
# 忽略主要版本更新,避免破坏性变更
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
groups:
|
||||
# 将所有非重大(minor/patch)更新合并到一个 PR
|
||||
npm-minor-patch:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- '*'
|
||||
update-types:
|
||||
- 'minor'
|
||||
- 'patch'
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'saturday'
|
||||
time: '09:00'
|
||||
timezone: 'Asia/Shanghai'
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- 'laoshuikaixue'
|
||||
assignees:
|
||||
- 'laoshuikaixue'
|
||||
commit-message:
|
||||
prefix: 'chore'
|
||||
include: 'scope'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'automated'
|
||||
- 'chore'
|
||||
ignore:
|
||||
- dependency-name: '*node*'
|
||||
groups:
|
||||
docker-all:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- '*'
|
||||
|
||||
# Enable version updates for GitHub Actions
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'saturday'
|
||||
time: '09:00'
|
||||
timezone: 'Asia/Shanghai'
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: 'chore'
|
||||
include: 'scope'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'automated'
|
||||
- 'chore'
|
||||
groups:
|
||||
gha-all:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- '*'
|
||||
@@ -15,7 +15,6 @@ permissions:
|
||||
|
||||
jobs:
|
||||
build-fpk:
|
||||
if: github.repository == 'laoshuikaixue/VoiceHub' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -23,26 +22,9 @@ jobs:
|
||||
|
||||
- name: 读取版本号 & 安装 fnpack
|
||||
run: |
|
||||
# 读取原始版本号
|
||||
ORIGINAL_VER=$(node -p "require('./package.json').version")
|
||||
echo "📦 原始版本: $ORIGINAL_VER"
|
||||
|
||||
# 转换为三段式版本号 (x.y.zw 格式,将第四段拼接到第三段)
|
||||
FNOS_VER=$(echo "$ORIGINAL_VER" | awk -F. '{
|
||||
if (NF >= 4) {
|
||||
# 四段式版本号,拼接最后两段
|
||||
printf "%s.%s.%s%s", $1, $2, $3, $4
|
||||
} else if (NF == 3) {
|
||||
# 已经是三段式,直接使用
|
||||
printf "%s.%s.%s", $1, $2, $3
|
||||
} else {
|
||||
# 其他情况,保持原样
|
||||
print $0
|
||||
}
|
||||
}')
|
||||
|
||||
echo "APP_VER=$FNOS_VER" >> "$GITHUB_ENV"
|
||||
echo "📦 转换后版本: $FNOS_VER"
|
||||
VER=$(node -p "require('./package.json').version")
|
||||
echo "APP_VER=$VER" >> "$GITHUB_ENV"
|
||||
echo "📦 版本: $VER"
|
||||
|
||||
wget -q https://static2.fnnas.com/fnpack/fnpack-1.2.1-linux-amd64 -O /usr/local/bin/fnpack
|
||||
chmod +x /usr/local/bin/fnpack
|
||||
@@ -60,10 +42,10 @@ jobs:
|
||||
|
||||
# 从 public 目录复制图标到构建目录
|
||||
mkdir -p voicehub/app/ui/images
|
||||
cp public/assets/logo.png voicehub/app/ui/images/icon-64.png
|
||||
cp public/assets/logo.png voicehub/app/ui/images/icon-256.png
|
||||
cp public/assets/logo.png voicehub/ICON.PNG
|
||||
cp public/assets/logo.png voicehub/ICON_256.PNG
|
||||
cp public/images/logo.png voicehub/app/ui/images/icon-64.png
|
||||
cp public/images/logo.png voicehub/app/ui/images/icon-256.png
|
||||
cp public/images/logo.png voicehub/ICON.PNG
|
||||
cp public/images/logo.png voicehub/ICON_256.PNG
|
||||
|
||||
# 更新版本号
|
||||
sed -i "s/^version=.*/version=${APP_VER}/" voicehub/manifest
|
||||
@@ -122,4 +104,4 @@ jobs:
|
||||
with:
|
||||
name: voicehub-fpk-${{ env.APP_VER }}
|
||||
path: "*.fpk"
|
||||
retention-days: 30
|
||||
retention-days: 30
|
||||
|
||||
@@ -14,209 +14,71 @@ on:
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
# 阶段1:优先构建并推送 amd64 架构,立即可用
|
||||
build-amd64:
|
||||
if: github.repository == 'laoshuikaixue/VoiceHub' || github.event_name == 'workflow_dispatch'
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: '0'
|
||||
|
||||
- name: Prepare lowercase names
|
||||
id: names
|
||||
run: |
|
||||
echo "image-name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
echo "actor-name=$(echo '${{ github.actor }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
|
||||
# 提取版本号并处理成不同格式
|
||||
- name: Extract version from package.json
|
||||
id: extract-version
|
||||
run: |
|
||||
# 提取完整版本号
|
||||
FULL_VERSION=$(node -p "require('./package.json').version")
|
||||
# 提取主版本.次版本 (如 1.2 从 1.2.3)
|
||||
MAJOR_MINOR_VERSION=$(echo "$FULL_VERSION" | sed -E 's/^([0-9]+\.[0-9]+)\.[0-9]+$/\1/')
|
||||
|
||||
echo "full-version=$FULL_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "major-minor-version=$MAJOR_MINOR_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted full version: $FULL_VERSION"
|
||||
echo "Extracted major.minor version: $MAJOR_MINOR_VERSION"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm,arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ steps.names.outputs.actor-name }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.names.outputs.image-name }}
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
# 正式标签 - amd64 构建完成后立即可用
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
# 使用从package.json提取的完整版本号
|
||||
type=raw,value=${{ steps.extract-version.outputs.full-version }}
|
||||
# 使用主版本.次版本格式
|
||||
type=raw,value=${{ steps.extract-version.outputs.major-minor-version }}
|
||||
# 架构后缀标签 - 用于后续多架构合并
|
||||
type=raw,value=latest-amd64,enable={{is_default_branch}}
|
||||
type=raw,value=${{ steps.extract-version.outputs.full-version }}-amd64
|
||||
type=raw,value=${{ steps.extract-version.outputs.major-minor-version }}-amd64
|
||||
|
||||
- name: Build and push amd64 image
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64
|
||||
cache-from: type=gha,scope=amd64
|
||||
cache-to: type=gha,mode=max,scope=amd64
|
||||
|
||||
# 阶段2:并行构建其余架构,各自推送带后缀的独立标签
|
||||
build-other-arches:
|
||||
if: github.repository == 'laoshuikaixue/VoiceHub' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
strategy:
|
||||
matrix:
|
||||
arch:
|
||||
- { platform: linux/arm64, suffix: -arm64 }
|
||||
- { platform: linux/arm/v7, suffix: -armv7 }
|
||||
- { platform: linux/arm/v6, suffix: -armv6 }
|
||||
- { platform: linux/ppc64le, suffix: -ppc64le }
|
||||
- { platform: linux/s390x, suffix: -s390x }
|
||||
fail-fast: false
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: '0'
|
||||
|
||||
- name: Prepare lowercase names
|
||||
id: names
|
||||
run: |
|
||||
echo "image-name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
echo "actor-name=$(echo '${{ github.actor }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: extract-version
|
||||
run: |
|
||||
FULL_VERSION=$(node -p "require('./package.json').version")
|
||||
MAJOR_MINOR_VERSION=$(echo "$FULL_VERSION" | sed -E 's/^([0-9]+\.[0-9]+)\.[0-9]+$/\1/')
|
||||
echo "full-version=$FULL_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "major-minor-version=$MAJOR_MINOR_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ steps.names.outputs.actor-name }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.names.outputs.image-name }}
|
||||
tags: |
|
||||
type=raw,value=latest${{ matrix.arch.suffix }},enable={{is_default_branch}}
|
||||
type=raw,value=${{ steps.extract-version.outputs.full-version }}${{ matrix.arch.suffix }}
|
||||
type=raw,value=${{ steps.extract-version.outputs.major-minor-version }}${{ matrix.arch.suffix }}
|
||||
|
||||
- name: Build and push ${{ matrix.arch.platform }} image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: ${{ matrix.arch.platform }}
|
||||
cache-from: type=gha,scope=${{ matrix.arch.suffix }}
|
||||
cache-to: type=gha,mode=max,scope=${{ matrix.arch.suffix }}
|
||||
|
||||
# 阶段3:所有架构构建完成后,合并为多架构 manifest 清单并推送正式标签
|
||||
merge-manifests:
|
||||
if: (github.repository == 'laoshuikaixue/VoiceHub' || github.event_name == 'workflow_dispatch') && github.event_name != 'pull_request'
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build-amd64, build-other-arches]
|
||||
permissions:
|
||||
packages: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Prepare lowercase names
|
||||
id: names
|
||||
run: |
|
||||
echo "image-name=$(echo '${{ github.repository }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
echo "actor-name=$(echo '${{ github.actor }}' | tr '[:upper:]' '[:lower:]')" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Extract version from package.json
|
||||
id: extract-version
|
||||
run: |
|
||||
FULL_VERSION=$(node -p "require('./package.json').version")
|
||||
MAJOR_MINOR_VERSION=$(echo "$FULL_VERSION" | sed -E 's/^([0-9]+\.[0-9]+)\.[0-9]+$/\1/')
|
||||
echo "full-version=$FULL_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "major-minor-version=$MAJOR_MINOR_VERSION" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ steps.names.outputs.actor-name }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Create and push multi-architecture manifest lists
|
||||
env:
|
||||
IMAGE_BASE: ${{ env.REGISTRY }}/${{ steps.names.outputs.image-name }}
|
||||
FULL_VER: ${{ steps.extract-version.outputs.full-version }}
|
||||
MAJOR_MINOR_VER: ${{ steps.extract-version.outputs.major-minor-version }}
|
||||
run: |
|
||||
# 所有架构后缀列表
|
||||
ARCH_SUFFIXES=("-amd64" "-arm64" "-armv7" "-armv6" "-ppc64le" "-s390x")
|
||||
|
||||
# 生成指定标签的所有架构镜像参数
|
||||
build_arch_args() {
|
||||
local tag=$1
|
||||
local args=""
|
||||
for suffix in "${ARCH_SUFFIXES[@]}"; do
|
||||
args="$args ${IMAGE_BASE}:${tag}${suffix}"
|
||||
done
|
||||
echo "$args"
|
||||
}
|
||||
|
||||
# 合并 latest 标签(仅默认分支)
|
||||
if [ "${{ github.ref == 'refs/heads/main' }}" = "true" ]; then
|
||||
echo "Merging multi-arch manifest for latest tag"
|
||||
docker buildx imagetools create -t "${IMAGE_BASE}:latest" $(build_arch_args "latest")
|
||||
fi
|
||||
|
||||
# 合并完整版本号标签
|
||||
echo "Merging multi-arch manifest for ${FULL_VER} tag"
|
||||
docker buildx imagetools create -t "${IMAGE_BASE}:${FULL_VER}" $(build_arch_args "${FULL_VER}")
|
||||
|
||||
# 合并主次版本号标签
|
||||
echo "Merging multi-arch manifest for ${MAJOR_MINOR_VER} tag"
|
||||
docker buildx imagetools create -t "${IMAGE_BASE}:${MAJOR_MINOR_VER}" $(build_arch_args "${MAJOR_MINOR_VER}")
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
name: Build and Push PostgreSQL Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- 'Dockerfile-postgres'
|
||||
- '.github/workflows/docker-postgres.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: 'Reason for manual trigger'
|
||||
required: false
|
||||
default: 'Manual trigger'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
if: github.repository == 'laoshuikaixue/VoiceHub' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: '0'
|
||||
|
||||
# 转换仓库名、用户名全小写,解决GHCR大写报错
|
||||
- name: Generate lowercase image & actor name
|
||||
id: lowercase
|
||||
run: |
|
||||
REPO_LOWER=$(echo "${{ github.repository }}" | tr '[:upper:]' '[:lower:]')
|
||||
ACTOR_LOWER=$(echo "${{ github.actor }}" | tr '[:upper:]' '[:lower:]')
|
||||
IMAGE_FULL="${REPO_LOWER}-postgres"
|
||||
echo "repo_lower=$REPO_LOWER" >> $GITHUB_OUTPUT
|
||||
echo "actor_lower=$ACTOR_LOWER" >> $GITHUB_OUTPUT
|
||||
echo "image_name=$IMAGE_FULL" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ steps.lowercase.outputs.actor_lower }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ steps.lowercase.outputs.image_name }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=raw,value=15-alpine,enable={{is_default_branch}}
|
||||
|
||||
- name: Build and push PostgreSQL Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile-postgres
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7,linux/arm/v6,linux/ppc64le,linux/s390x,linux/386,linux/riscv64
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
provenance: false
|
||||
sbom: false
|
||||
@@ -1,32 +0,0 @@
|
||||
name: Nix CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main, master]
|
||||
pull_request:
|
||||
branches: [main, master]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
nix-check:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install Nix
|
||||
uses: DeterminateSystems/determinate-nix-action@v3
|
||||
|
||||
- name: Setup Cachix binary cache
|
||||
uses: cachix/cachix-action@v14
|
||||
with:
|
||||
name: voicehub
|
||||
authToken: '${{ secrets.CACHIX_AUTH_TOKEN }}'
|
||||
|
||||
- name: Run nix flake check
|
||||
run: nix flake check
|
||||
|
||||
- name: Build voicehub (validate hashes)
|
||||
run: nix build .#voicehub
|
||||
@@ -1,110 +0,0 @@
|
||||
name: 更新 Nix pnpm 依赖哈希
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- pnpm-lock.yaml
|
||||
- flake.nix
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened, ready_for_review]
|
||||
paths:
|
||||
- pnpm-lock.yaml
|
||||
- flake.nix
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: write
|
||||
|
||||
concurrency:
|
||||
group: update-nix-pnpm-hash-${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
update-hash:
|
||||
name: 同步 pnpmDeps 哈希
|
||||
if: github.actor != 'github-actions[bot]' && (github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository)
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
TARGET_BRANCH: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.ref || github.ref_name }}
|
||||
CHECKOUT_REPOSITORY: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name || github.repository }}
|
||||
|
||||
steps:
|
||||
- name: 检出触发分支
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: ${{ env.CHECKOUT_REPOSITORY }}
|
||||
ref: ${{ env.TARGET_BRANCH }}
|
||||
|
||||
- name: 安装 Nix
|
||||
uses: DeterminateSystems/determinate-nix-action@v3
|
||||
|
||||
- name: 配置 Git 提交身份
|
||||
run: |
|
||||
git config user.name "github-actions[bot]"
|
||||
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||
|
||||
- name: 计算 pnpmDeps 哈希
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
python3 - <<'PY'
|
||||
from pathlib import Path
|
||||
|
||||
path = Path("flake.nix")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
marker = "pnpmDeps = pkgs.fetchPnpmDeps {"
|
||||
start = text.index(marker)
|
||||
key = 'hash = "'
|
||||
hash_start = text.index(key, start) + len(key)
|
||||
hash_end = text.index('"', hash_start)
|
||||
path.write_text(text[:hash_start] + text[hash_end:], encoding="utf-8")
|
||||
PY
|
||||
|
||||
set +e
|
||||
nix build .#voicehub --no-link 2>&1 | tee nix-build.log
|
||||
status="${PIPESTATUS[0]}"
|
||||
set -e
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "Nix 构建在空哈希下意外成功,无法提取新的 pnpmDeps 哈希。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
new_hash="$(grep -Eo 'got:[[:space:]]+sha256-[A-Za-z0-9+/=_-]+' nix-build.log | tail -n 1 | sed -E 's/got:[[:space:]]+//')"
|
||||
|
||||
if [ -z "$new_hash" ]; then
|
||||
echo "没有在 Nix 输出中找到 got: sha256-...,请查看上方日志。"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
HASH="$new_hash" python3 - <<'PY'
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
new_hash = os.environ["HASH"]
|
||||
path = Path("flake.nix")
|
||||
text = path.read_text(encoding="utf-8")
|
||||
marker = "pnpmDeps = pkgs.fetchPnpmDeps {"
|
||||
start = text.index(marker)
|
||||
key = 'hash = "'
|
||||
hash_start = text.index(key, start) + len(key)
|
||||
hash_end = text.index('"', hash_start)
|
||||
path.write_text(text[:hash_start] + new_hash + text[hash_end:], encoding="utf-8")
|
||||
PY
|
||||
|
||||
echo "新的 pnpmDeps 哈希: $new_hash"
|
||||
|
||||
- name: 提交哈希更新
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
if git diff --quiet -- flake.nix; then
|
||||
echo "pnpmDeps 哈希已经是最新,无需提交。"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
git add flake.nix
|
||||
git commit -m "ci: 同步 pnpmDeps 哈希"
|
||||
git push origin "HEAD:${TARGET_BRANCH}"
|
||||
@@ -6,8 +6,6 @@
|
||||
.cache
|
||||
dist
|
||||
docs
|
||||
.pnpm-store
|
||||
.mimosa
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
@@ -32,13 +30,8 @@ backups/
|
||||
.trae
|
||||
.qoder
|
||||
.figma
|
||||
.codex
|
||||
.agents
|
||||
scripts/package-lock.json
|
||||
|
||||
# Nix build output
|
||||
result
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
|
||||
@@ -1,110 +0,0 @@
|
||||
# VoiceHub Agent 行为规范
|
||||
|
||||
## 1. 项目概览
|
||||
|
||||
VoiceHub — Nuxt 4 校园广播站点歌管理系统。
|
||||
|
||||
- **前端**: Nuxt 4 (Vue 3), Tailwind CSS
|
||||
- **图标**: 自定义 `Icon.vue` 组件(内联 SVG),辅以 `@lucide/vue`
|
||||
- **后端**: Nuxt Server API (Nitro)
|
||||
- **数据库**: PostgreSQL, Drizzle ORM
|
||||
- **语言**: 后端 TypeScript, Vue SFC 用纯 JavaScript(无 `lang="ts"`)
|
||||
|
||||
## 2. 规范
|
||||
|
||||
### 2.1. 语言
|
||||
注释、文档、Git 信息均用简体中文。注释中禁止写思考过程等无用内容,只需关键部分。
|
||||
- CSS 注释只保留关键信息(变量用途、值来源、防误删警告),禁止写设计意图、过程或对比说明(如“取值与现状一致”“视觉零变化”“浅色模式下需清晰可见”“对齐某分支观感”等)
|
||||
|
||||
### 2.2. Vue 组件
|
||||
- 统一 `<script setup>`(纯 JS,不加 `lang="ts"`,禁止类型注解)
|
||||
- API 调用用 `useFetch` 或 `$fetch`,需错误处理
|
||||
- 模态框用 `<Teleport to="body">`
|
||||
- 图标用 `<Icon name="..." />`,name 需在 `Icon.vue` 中有定义;图标语义必须符合实际功能(如音源控制用 `ListMusic`),禁止使用语义不符的图标
|
||||
- 项目未开启组件自动导入,模板中使用的自定义组件(含 `Icon`)必须在 `<script setup>` 中显式导入;漏导入不会报错,会被渲染成无内容的原生未知标签(如 `<icon>`),表现为占位但不显示
|
||||
- 下拉选择统一复用 `~/components/UI/Common/CustomSelect.vue`;多选使用其 `multiple` 模式,禁止为普通业务配置新增原生 `<select>`
|
||||
- 状态管理用 Composables,不用 Pinia
|
||||
|
||||
### 2.3. 后端
|
||||
- 导入: 项目根目录(如 `server/`)用 `~~/`,app 目录用 `~/`(`~/` 即 `app/`);drizzle 位于 `app/drizzle/`,服务端导入必须写 `~/drizzle/db`、`~/drizzle/schema`,禁止 `~~/drizzle/...`(静态检查不报错,部署构建解析失败)
|
||||
- 错误: 用户可见的业务错误统一用 `createApiError(statusCode, code, message, data?)`(`~~/server/utils/apiError.ts`),code 取自 `SERVER_ERROR_CODES`;认证错误 401
|
||||
- 时间戳: 服务端取当前时间统一用 `getServerTimestamp()` / `getServerDate()`(`~~/server/utils/serverTime.ts`),禁止直接写 `Date.now()` / `new Date()`
|
||||
|
||||
### 2.4. 第三方库
|
||||
- otplib: `import otplib from 'otplib'` 然后 `const { authenticator } = otplib`
|
||||
|
||||
### 2.5. 国际化 (i18n)
|
||||
- 支持 `zh-CN`(基底,静态内置)与 `en-US`(动态按需加载);词典 `app/utils/locale/{zh-CN,en-US}.ts` 结构必须完全一致,新增文案键须两文件同步添加
|
||||
- 组件取文案用 `useLocale()` 分区 + `useLocaleText`/`useSafeLocale`,禁止自行实现 `callLocale`/`getNestedMessage` 等取值函数
|
||||
- 服务端错误码本地化:服务端 `createApiError` 抛码 → 客户端 `useServerErrors().localize(err)` 展示;动态值用第四参 `{ params: [...] }`,词典值用 `{0}`/`{1}` 占位符
|
||||
- 新增错误码须三处同步:`SERVER_ERROR_CODES` + zh/en 的 `serverErrors`(键完全对齐)
|
||||
- 英文长文本须考虑页面布局排布:按钮/Tab/徽章/菜单/开关标签等空间受限位置优先使用缩写(如 Previous → Prev),避免溢出或换行错位;描述性文本(Desc/Placeholder/Hint/Message 等)可自然换行不受限;缩写仅调整 en-US 词典值,键名与词典结构保持不变
|
||||
|
||||
## 3. 项目关键模式
|
||||
|
||||
### 3.1. 音频播放器
|
||||
- `useAudioPlayer.ts` — 全局状态
|
||||
- `useAudioPlayerControl.ts` — `<audio>` 元素控制、进度拖拽
|
||||
- `useAudioPlayerSync.ts` — 状态同步
|
||||
- 连续失败保护: `consecutiveSkipCount`,上限 3 次
|
||||
|
||||
### 3.2. 音源
|
||||
- 多音源搜索(netease、tencent、bilibili)
|
||||
- QQ 搜索失败自动降级到网易云,选项卡同步切换
|
||||
- 搜索结果含 `actualMusicPlatform` 字段
|
||||
|
||||
### 3.3. 字符串匹配
|
||||
- 归一化唯一权威实现: `app/utils/song-name-normalize.ts` 的 `normalizeForMatch`(`server/utils` 下同名文件仅转发导出;前端组件禁止内联重复实现)
|
||||
- 口径: 先繁→简(`toSimplifiedChinese`,映射表 `app/utils/data/cjkT2sMap.ts` 由 OpenCC 数据自动生成、禁止手工编辑),再按单词边界移除 `feat.`/`ft.`,然后移除标点和空格,最后 `&`/`&` → `and`
|
||||
|
||||
### 3.4. 专辑详情
|
||||
- `AlbumDetailsModal.vue`:仅网易云支持,使用 `AbortController` 防止竞态
|
||||
- QQ 音乐专辑链接不可点击
|
||||
|
||||
### 3.5. 加载动画
|
||||
- 加载转圈统一用 `~/components/UI/Common/AppSpinner.vue`(scoped CSS 实现,不依赖 UnoCSS utility),支持 `size`(直径 px,默认 32)、`borderWidth`、`label` 属性;复杂加载状态(标题/进度/步骤)用 `LoadingState.vue`(其 circle 类型内部复用 AppSpinner)
|
||||
- 禁止手写 `border-*-20/30 border-t-primary rounded-full animate-spin` 或自建 `.loading-spinner` 圆环类;此写法依赖 UnoCSS 生成的 border 工具类,preflight 未设置 `border-style: solid` 时整个圆环不可见
|
||||
- 按钮内加载态可用 Lucide 图标(`Loader2`/`RefreshCw` 等)+ `animate-spin`;错误语义色(如网易云红色转圈)保留专用类
|
||||
- 全局 border 重置:`main.css` 的 `*` 规则含 `border-style: solid; border-width: 0`,勿删除
|
||||
|
||||
## 4. 配置与扩展功能开发规范
|
||||
|
||||
### 4.1. 常量与白名单收敛
|
||||
- 业务白名单/枚举只允许一个权威定义:服务端放 `server/config/constants.ts`(如 `MUSIC_SOURCE_PLATFORMS`),前端放共享模块(如 `app/utils/platforms.ts`);其他文件一律 import 引用,禁止重新定义同名数组或映射
|
||||
- 新增业务实体(平台等)需同步:constants + 前端共享模块 + `app/drizzle/schema.ts` 默认值 + 迁移文件,缺一不可
|
||||
|
||||
### 4.2. 数据库迁移
|
||||
- 迁移文件(SQL + snapshot + journal 条目)必须且只能通过 `pnpm db:generate` 自动生成,禁止手工编写 SQL、手工创建 snapshot、手工编辑 `_journal.json`;手工迁移会导致 snapshot 格式与 drizzle-kit 版本不兼容,后续 `db:generate`/`db:check`/`db:migrate` 均会失败
|
||||
- 迁移时间戳使用真实生成时刻,禁止随意编造
|
||||
- 迁移文件命名保持 drizzle-kit 自动生成的时间戳格式,禁止手动改名(改名会导致数据库中已执行的迁移记录无法匹配,产生重复迁移)
|
||||
|
||||
### 4.3. 新增 SystemSettings 字段同步清单
|
||||
新增字段必须全部同步,遗漏会导致备份能进不能出、初始化缺字段:
|
||||
1. `app/drizzle/schema.ts`(含默认值)
|
||||
2. `server/utils/system-settings-defaults.ts` 的 `SYSTEM_SETTINGS_DEFAULTS`
|
||||
3. `server/api/admin/system-settings/index.ts` 建行分支
|
||||
4. `server/api/admin/backup/restore.post.ts` 与 `restore-chunk.post.ts` 的字段白名单(最易遗漏)
|
||||
5. 需公开时加入 `PUBLIC_SETTINGS_FIELDS`
|
||||
6. 迁移文件
|
||||
|
||||
### 4.4. 组合字段交叉校验
|
||||
- 多个配置字段存在相互约束(如启用列表与排序列表交集非空)时,保存接口必须做交叉一致性校验:提交字段与当前持久化值合并后校验,禁止只独立校验单字段;失败用 `createApiError` + `COMMON_INVALID_PARAMS`
|
||||
|
||||
### 4.5. 开关/禁用类功能
|
||||
- 实现平台/功能开关时,必须梳理该功能的全部调用路径(含既有降级、兜底、跨平台升级路径),在每条路径的源头按配置过滤,禁止只拦截主入口
|
||||
- 后台任务(如歌词跨平台升级)遇到被禁用的目标应显式跳过并记录日志,不能静默失败
|
||||
|
||||
### 4.6. 配置读取容错
|
||||
- 读取 DB/外部配置(JSON 数组等)必须容错:非法 JSON、空数组、未知值回退默认值(参考 `usePlatformConfig` 的 `parsePlatformArray`),禁止直接采用解析结果
|
||||
|
||||
### 4.7. 模块级缓存与异步配置 UI
|
||||
- 模块级共享缓存加载必须防并发(in-flight promise 去重);`refresh` 类方法加 `import.meta.server` 守卫,防止 SSR 污染共享状态
|
||||
- 依赖异步配置的 UI 在配置加载完成前不渲染(如 `v-if="loaded"`),避免 SSR 首屏闪烁或禁用项短暂可见
|
||||
|
||||
### 4.8. 抛错与错误展示
|
||||
- 抛错必须用 `Error` 实例(可附加 `data`),禁止 `throw { data: ... }` 字面量
|
||||
- 客户端用户可见错误统一 `useServerErrors().localize(err)` 展示,禁止直接取 `error.data.message`/`error.message`(中文消息在英文界面会泄漏)
|
||||
|
||||
## 5. 文件变更提醒
|
||||
|
||||
**每次完成任务后,如果新增或删除了文件/目录,必须同步更新 `README.md` 的"项目结构"部分,保持与实际文件系统一致。(注意:数据库迁移文件除外)**
|
||||
+60
-47
@@ -1,76 +1,89 @@
|
||||
# 支持: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
||||
# ========== 支持在 CI 注入国内镜像源前缀拉 node 基础镜像 ==========
|
||||
# 用法:docker buildx build \
|
||||
# --build-arg DOCKER_MIRROR=docker.m.daocloud.io/library/ \
|
||||
# --build-arg NODE_BUILDER_TAG=22-alpine \
|
||||
# --build-arg NODE_RUNTIME_TAG=22-alpine \
|
||||
# ...
|
||||
# 缺省(DOCKER_MIRROR 为空)等价于原来的 node:22-alpine,本地开发不变
|
||||
ARG DOCKER_MIRROR=""
|
||||
ARG NODE_BUILDER_TAG="22-alpine"
|
||||
ARG NODE_RUNTIME_TAG="22-alpine"
|
||||
|
||||
# ==========================================
|
||||
# 第一阶段:构建阶段
|
||||
# 第一阶段:依赖安装与构建阶段
|
||||
# ==========================================
|
||||
# Nuxt/Vite 使用原生构建器。始终在 CI runner 的原生平台编译,避免 QEMU
|
||||
# 执行 Rolldown 时崩溃;生成的 Nitro 输出不包含目标架构原生二进制。
|
||||
FROM --platform=$BUILDPLATFORM node:24-alpine AS builder
|
||||
FROM ${DOCKER_MIRROR}node:${NODE_BUILDER_TAG} AS builder
|
||||
|
||||
WORKDIR /app
|
||||
ENV NODE_OPTIONS="--max-old-space-size=1024"
|
||||
ENV VITE_BUILD_THREADS=1
|
||||
ENV NITRO_PRESET=node-server
|
||||
# 1. 仅复制依赖相关文件和 scripts 目录(因为 postinstall 需要),利用 Docker 缓存层
|
||||
COPY package.json package-lock.json ./
|
||||
COPY scripts ./scripts
|
||||
|
||||
# 依赖安装阶段只复制 postinstall 所需脚本,避免其他脚本变更使依赖缓存失效
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY scripts/postinstall.js ./scripts/postinstall.js
|
||||
|
||||
# 安装所有依赖
|
||||
# 配置 npm 镜像加速并安装依赖,严格内存优化
|
||||
RUN set -eux; \
|
||||
export CI=true; \
|
||||
npm install -g pnpm@10.29.3; \
|
||||
pnpm config set fetch-retries 5; \
|
||||
pnpm config set fetch-retry-mintimeout 20000; \
|
||||
pnpm config set fetch-retry-maxtimeout 120000; \
|
||||
pnpm install --frozen-lockfile || ( \
|
||||
rm -rf node_modules; \
|
||||
pnpm install --no-frozen-lockfile || ( \
|
||||
pnpm config set registry https://registry.npmmirror.com; \
|
||||
pnpm install --no-frozen-lockfile \
|
||||
) \
|
||||
)
|
||||
npm config set registry https://registry.npmmirror.com; \
|
||||
npm config set maxsockets 3; \
|
||||
npm config set progress false; \
|
||||
npm config set cache /tmp/npm-cache; \
|
||||
npm install --package-lock-only --ignore-scripts; \
|
||||
npm ci --prefer-offline --no-audit --no-fund --maxsockets=3
|
||||
|
||||
# 复制所有源代码
|
||||
# 2. 复制源代码并进行构建
|
||||
# 只有在 package.json 没变而源代码改变时,才会从这一步开始重新执行,跳过上面的耗时依赖安装
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN pnpm run build
|
||||
# ========== OAuth / 第三方登录构建期配置(process.env.AGGREGATE_OAUTH_APP_ID 控制 nuxt.config.ts public.oauth.aggregate 开关) ==========
|
||||
# 说明:
|
||||
# - 这些是 **构建期** 变量,只在 RUN npm run build 时用到(runtimeConfig.public 只会在 build 时固化到 .output/public 包)
|
||||
# - 部署时运行期变量(同一套值)会通过 docker-compose.yml env_file: [.env] 注入到容器 process.env 供 server/utils/oauth-strategies.ts loadAggregateSettings 使用
|
||||
ARG OAUTH_REDIRECT_URI=""
|
||||
ARG OAUTH_STATE_SECRET=""
|
||||
ARG AGGREGATE_OAUTH_APP_ID=""
|
||||
ARG AGGREGATE_OAUTH_APP_KEY=""
|
||||
ARG AGGREGATE_OAUTH_LOGIN_TYPE=""
|
||||
ARG AGGREGATE_OAUTH_ENDPOINT=""
|
||||
ENV OAUTH_REDIRECT_URI="${OAUTH_REDIRECT_URI}" \
|
||||
OAUTH_STATE_SECRET="${OAUTH_STATE_SECRET}" \
|
||||
AGGREGATE_OAUTH_APP_ID="${AGGREGATE_OAUTH_APP_ID}" \
|
||||
AGGREGATE_OAUTH_APP_KEY="${AGGREGATE_OAUTH_APP_KEY}" \
|
||||
AGGREGATE_OAUTH_LOGIN_TYPE="${AGGREGATE_OAUTH_LOGIN_TYPE}" \
|
||||
AGGREGATE_OAUTH_ENDPOINT="${AGGREGATE_OAUTH_ENDPOINT}"
|
||||
|
||||
# 构建应用,使用内存优化脚本适应4GB总内存
|
||||
RUN chmod +x scripts/memory-build.js && \
|
||||
node scripts/memory-build.js
|
||||
RUN npm prune --omit=dev
|
||||
|
||||
# ==========================================
|
||||
# 第二阶段:运行阶段
|
||||
# ==========================================
|
||||
# 预定义各架构的运行时镜像
|
||||
FROM node:24-alpine AS runtime-amd64
|
||||
FROM node:24-alpine AS runtime-arm64
|
||||
# Node 24 镜像已移除 arm/v7,32 位 ARM 使用 Node 22
|
||||
FROM node:22-alpine AS runtime-arm
|
||||
FROM node:24-trixie-slim AS runtime-s390x
|
||||
FROM node:24-trixie-slim AS runtime-ppc64le
|
||||
|
||||
# 根据 TARGETARCH 选择对应的运行时镜像
|
||||
FROM runtime-${TARGETARCH} AS runtime
|
||||
FROM ${DOCKER_MIRROR}node:${NODE_RUNTIME_TAG} AS runtime
|
||||
|
||||
USER root
|
||||
WORKDIR /app
|
||||
|
||||
# 运行依赖必须在目标平台安装,不能从构建平台复制 node_modules。
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
||||
COPY scripts/postinstall.js ./scripts/postinstall.js
|
||||
RUN set -eux; \
|
||||
export CI=true; \
|
||||
npm install -g pnpm@10.29.3; \
|
||||
pnpm config set fetch-retries 5; \
|
||||
pnpm config set fetch-retry-mintimeout 20000; \
|
||||
pnpm config set fetch-retry-maxtimeout 120000; \
|
||||
pnpm install --prod --frozen-lockfile
|
||||
|
||||
# 从构建阶段复制必要文件
|
||||
# 从构建阶段复制必要文件,剔除无关的开发依赖
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/package-lock.json ./
|
||||
COPY --from=builder /app/drizzle.config.ts ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.output ./.output
|
||||
COPY --from=builder /app/app/drizzle ./app/drizzle
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
# 如果有 vendor 目录再取消注释
|
||||
# COPY --from=builder /app/vendor ./vendor
|
||||
|
||||
# 环境变量配置
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false \
|
||||
PREBUILT=true
|
||||
PREBUILT=true \
|
||||
NODE_OPTIONS="--max-old-space-size=512"
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE $PORT
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
FROM postgres:15-alpine AS runtime-amd64
|
||||
FROM postgres:15-alpine AS runtime-arm64
|
||||
FROM postgres:15-alpine AS runtime-arm
|
||||
FROM postgres:15-alpine AS runtime-s390x
|
||||
FROM postgres:15-alpine AS runtime-ppc64le
|
||||
FROM postgres:15-alpine AS runtime-riscv64
|
||||
FROM postgres:15-alpine AS runtime-386
|
||||
|
||||
# 根据 TARGETARCH 选择对应的运行时镜像
|
||||
FROM runtime-${TARGETARCH} AS runtime
|
||||
@@ -0,0 +1,29 @@
|
||||
# 校园广播站点歌系统重构计划
|
||||
|
||||
## 1. 数据库重构计划 (PostgreSQL -> MySQL)
|
||||
- **冲突说明**:当前项目使用 PostgreSQL (基于 `drizzle-orm/pg-core`),包含众多表如 `User`, `PlayTime`, `Song`, `Vote`, `Schedule`, `Notification` 等;而改造技术方案中明确要求使用 MySQL,并且 `users` 和 `songs` 表结构更精简。
|
||||
- **重构步骤**:
|
||||
1. 将 `drizzle-orm/pg-core` 替换为 `drizzle-orm/mysql-core`。
|
||||
2. 修改 `app/drizzle/schema.ts`,将所有 `pgTable` 替换为 `mysqlTable`,`pgEnum` 替换为 MySQL 枚举或字符串,修复类型映射(如 `text` -> `varchar` 或 `text`)。
|
||||
3. 精简 `User` 表,确保包含 `id`, `username`, `password_hash`, `role`。
|
||||
4. 修改 `Song` 表,加入 `kg_hash`, `title`, `singer`, `cover_url`,并平滑迁移现有数据。
|
||||
5. 重新生成 Drizzle 迁移文件 (`drizzle-kit generate`) 并应用到 MySQL 数据库。
|
||||
|
||||
## 2. 音源集成重构计划 (多源聚合 -> 仅酷狗音乐)
|
||||
- **冲突说明**:当前项目在 `app/utils/musicSources.ts` 及 `useMusicSources.ts` 中深度集成了网易云、QQ音乐 (vkeys)、Bilibili 等多平台逻辑;改造方案要求仅保留酷狗音乐。
|
||||
- **重构步骤**:
|
||||
1. 移除 `neteaseApi.ts`, `bilibiliSource.ts`, `musicSources.ts` 中的网易云、QQ音乐等配置。
|
||||
2. 完善 `server/api/kugou/` 下的代理接口(包括 `/search`, `/url`, `/lyric`, `/detail`)。
|
||||
3. 简化 `useMusicSources.ts` 逻辑,移除平台回退机制,所有请求直接指向酷狗代理接口。
|
||||
4. 修复前端点歌页面 (`SongList.vue`, `RequestForm.vue`) 中的平台选择器和遗留的网易云/QQ音乐图标。
|
||||
|
||||
## 3. UI 现代化与扁平化设计
|
||||
- **冲突说明**:当前项目已使用 Nuxt UI + Tailwind CSS,且页面中硬编码了大量的深色主题类名(如 `bg-zinc-900`, `text-zinc-300` 等),而改造方案要求采用浅色现代风格(`Background #F8FAFC`, `Neutral Dark #0F172A`)。全面替换将涉及上百个组件的重写。
|
||||
- **重构步骤**:
|
||||
1. 修改 `tailwind.config.ts`,将主色调 (`primary`) 映射为 `#2563EB`。
|
||||
2. 逐步将所有组件中硬编码的 `zinc` 深色类名替换为语义化的 Tailwind 浅色类名(如 `bg-slate-50`, `text-slate-900`)。
|
||||
3. 在后台管理仪表盘 (`OverviewDashboard.vue`) 和点歌前端引入 Bento Grid (便当盒) 布局。
|
||||
4. 在全局样式 `components.css` 中为卡片和按钮统一添加微弱的悬停上浮效果(`hover:-translate-y-1 hover:shadow-md`)。
|
||||
|
||||
## 4. 后台管理:歌单图片生成功能
|
||||
- **落地情况**:将在 `ScheduleManager.vue` 或新建的 `SchedulePrinter.vue` 中引入 HTML5 Canvas API,将排期歌单绘制为 400x600 的图片并提供下载。
|
||||
-19
@@ -204,22 +204,3 @@ pnpm run start
|
||||
# 或使用 nohup 后台运行
|
||||
nohup pnpm run start > voicehub.log 2>&1 &
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. 旧 Redis 缓存键清理(可选)
|
||||
|
||||
Redis 仅用于验证码、限流和临时安全状态,不参与歌曲、排期、点赞或用户资料缓存。从旧版本升级且曾使用 Redis 业务缓存的部署,可先执行 dry-run 检查残留键:
|
||||
|
||||
```bash
|
||||
pnpm run redis:scan-legacy
|
||||
```
|
||||
|
||||
确认 Redis 数据库为 VoiceHub 独占后,才可显式执行清理。PowerShell 示例:
|
||||
|
||||
```powershell
|
||||
$env:REDIS_LEGACY_CLEANUP_CONFIRM = 'VOICEHUB'
|
||||
pnpm run redis:scan-legacy -- --apply
|
||||
```
|
||||
|
||||
共享 Redis 默认不会自动删除;禁止使用 `FLUSHDB`。
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.development
|
||||
dist
|
||||
coverage
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,64 @@
|
||||
# 数据库连接地址(必填)
|
||||
# 格式:postgresql://用户名:密码@主机:端口/数据库名?sslmode=require
|
||||
# 示例:postgresql://myuser:mypassword@localhost:5432/voicehub?sslmode=require
|
||||
DATABASE_URL="postgresql://username:password@host:port/database?sslmode=require"
|
||||
|
||||
# JWT 认证密钥(必填)
|
||||
# 请使用强随机字符串,建议至少 32 位
|
||||
JWT_SECRET="your-very-secure-jwt-secret-key"
|
||||
|
||||
# WebAuthn / Passkey 配置(可选)
|
||||
# 依赖方 ID (RP ID),必须是你域名的一个有效域名
|
||||
# 规则:必须是请求域名的精确匹配或父域名
|
||||
#
|
||||
# 示例:
|
||||
# - 请求域名为 voicehub.example.com,RP ID 可设为 voicehub.example.com 或 example.com
|
||||
# - 不配置时,默认使用当前请求的 hostname(适合开发/预览环境)
|
||||
# - 生产环境建议配置为你的主域名,如:voicehub.example.com
|
||||
WEBAUTHN_RP_ID=
|
||||
|
||||
# 允许的 Origin 列表(可选)
|
||||
# WebAuthn 要求精确匹配 Origin,此配置用于支持多域名部署
|
||||
# 多个域名用逗号分隔,必须包含 https:// 前缀
|
||||
#
|
||||
# 示例:
|
||||
# - 单域名:https://voicehub.example.com
|
||||
# - 多域名(Vercel 预览 + 生产):https://voicehub.example.com,https://voicehub-xxx.vercel.app
|
||||
# - 二级域名:https://voicehub.example.com,https://sub.voicehub.example.com
|
||||
#
|
||||
# 不配置时:默认信任请求的 Origin(适合开发环境)
|
||||
# 生产环境:强烈建议配置,以防止恶意站点伪造 Origin 攻击
|
||||
WEBAUTHN_ORIGIN=
|
||||
|
||||
# 应用运行环境(可选)
|
||||
# development: 开发环境
|
||||
# production: 生产环境
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis 缓存服务(可选,暂不推荐)
|
||||
# 如果设置了 REDIS_URL,系统将自动启用 Redis 缓存功能
|
||||
# 格式:redis://用户名:密码@主机:端口
|
||||
# REDIS_URL="redis://default:password@host:port"
|
||||
|
||||
# OAuth 配置(可选)
|
||||
# 启用相应的 OAuth 提供商需要填写对应的 Client ID 和 Client Secret
|
||||
|
||||
# OAuth 回调地址(必填,如果启用 OAuth)
|
||||
# 注意:[provider] 是占位符,实际使用时需要根据具体情况填写
|
||||
OAUTH_REDIRECT_URI="http://localhost:3000/api/auth/[provider]/callback"
|
||||
|
||||
# 用于加密 OAuth state 的密钥(必填,如果启用 OAuth)
|
||||
OAUTH_STATE_SECRET=your-secure-random-string-for-oauth-state
|
||||
|
||||
# GitHub OAuth
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Casdoor OAuth
|
||||
CASDOOR_CLIENT_ID=
|
||||
CASDOOR_CLIENT_SECRET=
|
||||
CASDOOR_ENDPOINT=
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
name: 🐛 问题反馈
|
||||
description: 报告VoiceHub系统中的问题或错误
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', '需要确认']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您报告问题!请详细描述遇到的问题,这将帮助我们更快地定位和解决问题。
|
||||
|
||||
- type: dropdown
|
||||
id: bug-type
|
||||
attributes:
|
||||
label: 问题类型
|
||||
description: 请选择最符合的问题类型
|
||||
options:
|
||||
- 音乐播放相关
|
||||
- 用户登录/注册
|
||||
- 点歌功能
|
||||
- 通知系统
|
||||
- 管理员功能
|
||||
- 界面显示
|
||||
- 性能问题
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 请详细描述遇到的问题
|
||||
placeholder: 请清楚地描述问题的具体表现...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 请提供详细的复现步骤
|
||||
placeholder: |
|
||||
1. 进入...
|
||||
2. 点击...
|
||||
3. 看到错误...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 期望行为
|
||||
description: 描述您期望发生的正确行为
|
||||
placeholder: 应该显示/执行...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: 实际行为
|
||||
description: 描述实际发生的错误行为
|
||||
placeholder: 实际显示/执行了...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: browser
|
||||
attributes:
|
||||
label: 浏览器
|
||||
description: 使用的浏览器类型
|
||||
options:
|
||||
- Chrome
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
- 其他
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser-version
|
||||
attributes:
|
||||
label: 浏览器版本
|
||||
description: 浏览器的具体版本号
|
||||
placeholder: 例如:Chrome 120.0.0.0
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: device-type
|
||||
attributes:
|
||||
label: 设备类型
|
||||
description: 使用的设备类型
|
||||
options:
|
||||
- 桌面电脑
|
||||
- 笔记本电脑
|
||||
- 平板电脑
|
||||
- 手机
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 任何其他有助于理解问题的信息
|
||||
placeholder: 截图、错误日志、相关配置等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: 确认事项
|
||||
description: 请确认以下事项
|
||||
options:
|
||||
- label: 我已经搜索了现有的Issues,确认这不是重复问题
|
||||
required: true
|
||||
- label: 我已经尝试刷新页面或重新登录
|
||||
required: false
|
||||
- label: 问题可以稳定复现
|
||||
required: false
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 📚 使用文档
|
||||
url: https://docs.voicehub.lao-shui.top
|
||||
about: 查看VoiceHub的使用文档和常见问题解答
|
||||
- name: 💬 讨论区
|
||||
url: https://github.com/laoshuikaixue/VoiceHub/discussions
|
||||
about: 在讨论区提问、分享经验或参与社区讨论
|
||||
- name: 🔒 安全问题私密报告
|
||||
url: mailto:contact@lao-shui.top
|
||||
about: 对于严重的安全漏洞,请通过邮件私密报告
|
||||
- name: 📧 联系我们
|
||||
url: mailto:contact@lao-shui.top
|
||||
about: 如果您需要技术支持或有其他问题,可以直接联系我们
|
||||
@@ -0,0 +1,139 @@
|
||||
name: ✨ 功能建议
|
||||
description: 为VoiceHub系统提出新功能或改进建议
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement', '功能建议']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您为VoiceHub提出功能建议!您的想法将帮助我们改进系统,为用户提供更好的体验。
|
||||
|
||||
- type: dropdown
|
||||
id: feature-category
|
||||
attributes:
|
||||
label: 功能类别
|
||||
description: 请选择建议功能所属的类别
|
||||
options:
|
||||
- 音乐播放功能
|
||||
- 点歌系统
|
||||
- 用户管理
|
||||
- 通知系统
|
||||
- 管理员工具
|
||||
- 用户界面
|
||||
- 数据统计
|
||||
- 安全功能
|
||||
- API接口
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: feature-summary
|
||||
attributes:
|
||||
label: 功能概述
|
||||
description: 简要描述您建议的功能
|
||||
placeholder: 用一两句话概括您的功能建议...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-statement
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 描述当前存在的问题或不便之处
|
||||
placeholder: 目前在使用VoiceHub时遇到了什么问题或不便?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: 建议方案
|
||||
description: 详细描述您建议的解决方案
|
||||
placeholder: |
|
||||
请详细描述您的功能建议:
|
||||
- 功能如何工作
|
||||
- 用户如何使用
|
||||
- 预期效果
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: user-stories
|
||||
attributes:
|
||||
label: 用户场景
|
||||
description: 描述用户在什么情况下会使用这个功能
|
||||
placeholder: |
|
||||
作为一个[用户角色],我希望[功能描述],这样我就可以[预期收益]。
|
||||
|
||||
例如:
|
||||
- 作为学生,我希望能够预约点歌时间,这样我就可以在特定时间播放我的歌曲
|
||||
- 作为管理员,我希望能够批量管理歌曲,这样我就可以更高效地维护歌曲库
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 替代方案
|
||||
description: 是否考虑过其他解决方案?
|
||||
placeholder: 描述您考虑过的其他可能的解决方案...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级
|
||||
description: 您认为这个功能的重要程度如何?
|
||||
options:
|
||||
- 高 - 非常重要,急需实现
|
||||
- 中 - 重要,希望尽快实现
|
||||
- 低 - 有了更好,不急
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-type
|
||||
attributes:
|
||||
label: 目标用户
|
||||
description: 这个功能主要面向哪类用户?
|
||||
options:
|
||||
- 所有用户
|
||||
- 普通学生用户
|
||||
- 管理员
|
||||
- 特定用户群体
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: technical-considerations
|
||||
attributes:
|
||||
label: 技术考虑
|
||||
description: 如果您有技术背景,可以提供一些技术实现的想法
|
||||
placeholder: 实现思路、技术栈建议、性能考虑等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 任何其他有助于理解功能需求的信息
|
||||
placeholder: 参考资料、类似功能的例子、设计草图等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: 确认事项
|
||||
description: 请确认以下事项
|
||||
options:
|
||||
- label: 我已经搜索了现有的Issues,确认这不是重复建议
|
||||
required: true
|
||||
- label: 我愿意参与功能的讨论和测试
|
||||
required: false
|
||||
- label: 如果有能力,我愿意协助开发这个功能
|
||||
required: false
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
name: '❓ 提问 / Question'
|
||||
description: '遇到问题需要帮助?在这里提问,我们会尽快为您解答!'
|
||||
title: '[Question]: '
|
||||
labels: ['question', '需要帮助']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您使用 VoiceHub 校园广播点歌系统!如果您在使用过程中遇到任何问题,请填写以下信息,我们会尽快为您提供帮助。
|
||||
|
||||
💡 **提示**:提问前建议先查看已有的 Issues。
|
||||
|
||||
- type: dropdown
|
||||
id: question-type
|
||||
attributes:
|
||||
label: '问题类型'
|
||||
description: '请选择您遇到的问题类型'
|
||||
options:
|
||||
- '使用问题 - 不知道如何操作某个功能'
|
||||
- '技术问题 - 功能无法正常工作'
|
||||
- '配置问题 - 系统设置相关'
|
||||
- '账户问题 - 登录、权限等'
|
||||
- '音频问题 - 播放、上传相关'
|
||||
- '界面问题 - 显示异常或操作困难'
|
||||
- '其他问题'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: question-description
|
||||
attributes:
|
||||
label: '问题描述'
|
||||
description: '请详细描述您遇到的问题,包括具体的操作步骤和现象'
|
||||
placeholder: |
|
||||
例如:
|
||||
1. 我在尝试点歌时...
|
||||
2. 点击"添加到播放列表"按钮后...
|
||||
3. 期望的结果是...,但实际发生的是...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-role
|
||||
attributes:
|
||||
label: '用户角色'
|
||||
description: '您在系统中的角色是什么?'
|
||||
options:
|
||||
- '学生用户'
|
||||
- '教师用户'
|
||||
- '管理员'
|
||||
- '超级管理员'
|
||||
- '访客'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: '使用环境'
|
||||
description: '请提供您的使用环境信息'
|
||||
placeholder: '例如:Chrome 120, Windows 11, 移动端Safari等'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: attempted-solutions
|
||||
attributes:
|
||||
label: '已尝试的解决方法'
|
||||
description: '请描述您已经尝试过的解决方法(如果有的话)'
|
||||
placeholder: |
|
||||
例如:
|
||||
- 刷新了页面
|
||||
- 清除了浏览器缓存
|
||||
- 尝试了不同的浏览器
|
||||
- 重新登录了账户
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: urgency
|
||||
attributes:
|
||||
label: '紧急程度'
|
||||
description: '这个问题对您的使用影响有多大?'
|
||||
options:
|
||||
- '低 - 不影响正常使用,有时间时解决即可'
|
||||
- '中 - 影响部分功能,希望尽快解决'
|
||||
- '高 - 严重影响使用,需要紧急处理'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: '补充信息'
|
||||
description: '任何其他可能有帮助的信息,如截图、错误信息等'
|
||||
placeholder: '您可以在这里添加截图、错误截图或其他相关信息'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: '确认事项'
|
||||
description: '请确认以下事项'
|
||||
options:
|
||||
- label: '我已经搜索了现有的 Issues,确认这不是重复问题'
|
||||
required: true
|
||||
# - label: "我已经查看了项目文档和常见问题"
|
||||
# required: false
|
||||
- label: '我愿意提供更多信息来帮助解决这个问题'
|
||||
required: false
|
||||
@@ -0,0 +1,149 @@
|
||||
name: 🔒 安全问题报告
|
||||
description: 报告VoiceHub系统中的安全漏洞或安全相关问题
|
||||
title: '[Security]: '
|
||||
labels: ['security', '高优先级']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
⚠️ **重要提醒**:如果您发现了严重的安全漏洞,请不要在公开的Issue中详细描述。
|
||||
请发送邮件至 `contact@lao-shui.top` 。
|
||||
|
||||
感谢您帮助我们保护VoiceHub系统的安全!
|
||||
|
||||
- type: dropdown
|
||||
id: security-type
|
||||
attributes:
|
||||
label: 安全问题类型
|
||||
description: 请选择最符合的安全问题类型
|
||||
options:
|
||||
- 身份认证问题
|
||||
- 权限控制问题
|
||||
- 数据泄露风险
|
||||
- 跨站脚本攻击(XSS)
|
||||
- SQL注入
|
||||
- 跨站请求伪造(CSRF)
|
||||
- 文件上传安全
|
||||
- 会话管理问题
|
||||
- 密码安全
|
||||
- API安全
|
||||
- 其他安全问题
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: 严重程度
|
||||
description: 请评估安全问题的严重程度
|
||||
options:
|
||||
- 严重 - 可能导致系统完全被攻破
|
||||
- 高 - 可能导致重要数据泄露
|
||||
- 中 - 可能影响部分用户或功能
|
||||
- 低 - 轻微的安全改进建议
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: security-summary
|
||||
attributes:
|
||||
label: 安全问题概述
|
||||
description: 简要描述发现的安全问题(避免详细的攻击步骤)
|
||||
placeholder: 简要说明发现的安全问题类型和影响范围...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: affected-components
|
||||
attributes:
|
||||
label: 受影响的组件
|
||||
description: 列出可能受到影响的系统组件或功能模块
|
||||
placeholder: |
|
||||
例如:
|
||||
- 用户登录模块
|
||||
- 文件上传功能
|
||||
- 管理员面板
|
||||
- API接口
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: potential-impact
|
||||
attributes:
|
||||
label: 潜在影响
|
||||
description: 描述这个安全问题可能造成的影响
|
||||
placeholder: |
|
||||
可能的影响包括:
|
||||
- 用户数据泄露
|
||||
- 未授权访问
|
||||
- 系统功能被滥用
|
||||
- 其他安全风险
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction-info
|
||||
attributes:
|
||||
label: 复现信息
|
||||
description: 提供必要的复现信息(请避免详细的攻击步骤)
|
||||
placeholder: |
|
||||
请提供:
|
||||
- 发现问题的环境
|
||||
- 触发条件
|
||||
- 观察到的异常行为
|
||||
|
||||
注意:请不要提供完整的攻击代码或详细攻击步骤
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: mitigation-suggestions
|
||||
attributes:
|
||||
label: 缓解建议
|
||||
description: 如果您有安全方面的建议,请提供
|
||||
placeholder: |
|
||||
可能的解决方案:
|
||||
- 输入验证改进
|
||||
- 权限检查加强
|
||||
- 安全配置调整
|
||||
- 其他安全措施
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: disclosure-timeline
|
||||
attributes:
|
||||
label: 披露时间线
|
||||
description: 您希望的问题处理时间线
|
||||
options:
|
||||
- 立即处理 - 严重安全问题
|
||||
- 1周内 - 重要安全问题
|
||||
- 2周内 - 一般安全问题
|
||||
- 1个月内 - 安全改进建议
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 其他有助于理解和解决安全问题的信息
|
||||
placeholder: 相关文档、参考资料、类似案例等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: security-terms
|
||||
attributes:
|
||||
label: 安全报告确认
|
||||
description: 请确认以下事项
|
||||
options:
|
||||
- label: 我理解这是一个安全相关的问题报告
|
||||
required: true
|
||||
- label: 我已经避免在公开Issue中透露详细的攻击方法
|
||||
required: true
|
||||
- label: 我愿意配合安全团队进行问题验证和修复
|
||||
required: true
|
||||
- label: 我同意遵循负责任的安全披露原则
|
||||
required: true
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for npm
|
||||
- package-ecosystem: 'npm'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'saturday'
|
||||
time: '09:00'
|
||||
timezone: 'Asia/Shanghai'
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: 'chore'
|
||||
prefix-development: 'chore'
|
||||
include: 'scope'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'automated'
|
||||
- 'chore'
|
||||
allow:
|
||||
- dependency-type: 'direct'
|
||||
- dependency-type: 'indirect'
|
||||
ignore:
|
||||
# 忽略主要版本更新,避免破坏性变更
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
groups:
|
||||
# 将所有非重大(minor/patch)更新合并到一个 PR
|
||||
npm-minor-patch:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- '*'
|
||||
update-types:
|
||||
- 'minor'
|
||||
- 'patch'
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'saturday'
|
||||
time: '09:00'
|
||||
timezone: 'Asia/Shanghai'
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- 'laoshuikaixue'
|
||||
assignees:
|
||||
- 'laoshuikaixue'
|
||||
commit-message:
|
||||
prefix: 'chore'
|
||||
include: 'scope'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'automated'
|
||||
- 'chore'
|
||||
ignore:
|
||||
- dependency-name: '*node*'
|
||||
groups:
|
||||
docker-all:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- '*'
|
||||
|
||||
# Enable version updates for GitHub Actions
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'saturday'
|
||||
time: '09:00'
|
||||
timezone: 'Asia/Shanghai'
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: 'chore'
|
||||
include: 'scope'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'automated'
|
||||
- 'chore'
|
||||
groups:
|
||||
gha-all:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- '*'
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# .github/workflows/build-fpk.yml
|
||||
# VoiceHub 飞牛 fnOS FPK 安装包自动构建
|
||||
# 手动触发,版本号自动从 package.json 读取
|
||||
|
||||
name: Build VoiceHub FPK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-fpk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 读取版本号 & 安装 fnpack
|
||||
run: |
|
||||
VER=$(node -p "require('./package.json').version")
|
||||
echo "APP_VER=$VER" >> "$GITHUB_ENV"
|
||||
echo "📦 版本: $VER"
|
||||
|
||||
wget -q https://static2.fnnas.com/fnpack/fnpack-1.2.1-linux-amd64 -O /usr/local/bin/fnpack
|
||||
chmod +x /usr/local/bin/fnpack
|
||||
|
||||
- name: 创建 FPK 目录
|
||||
run: |
|
||||
fnpack create voicehub --template docker
|
||||
echo "📁 模板结构:"
|
||||
find voicehub -type f | sort
|
||||
|
||||
- name: 写入 manifest & 准备图标
|
||||
run: |
|
||||
# 复制仓库中的 fnos 模板
|
||||
cp -r fnos/* voicehub/
|
||||
|
||||
# 从 public 目录复制图标到构建目录
|
||||
mkdir -p voicehub/app/ui/images
|
||||
cp public/images/logo.png voicehub/app/ui/images/icon-64.png
|
||||
cp public/images/logo.png voicehub/app/ui/images/icon-256.png
|
||||
cp public/images/logo.png voicehub/ICON.PNG
|
||||
cp public/images/logo.png voicehub/ICON_256.PNG
|
||||
|
||||
# 更新版本号
|
||||
sed -i "s/^version=.*/version=${APP_VER}/" voicehub/manifest
|
||||
chmod +x voicehub/cmd/main
|
||||
echo "✅ manifest, cmd/main & icons 已就绪"
|
||||
|
||||
- name: 从仓库读取 docker-compose-prebuild-nju.yml 并写入
|
||||
run: |
|
||||
# 从项目根目录读取仓库内的 compose 文件
|
||||
SRC_COMPOSE="docker-compose/docker-compose-prebuild-nju.yml"
|
||||
# 目标路径
|
||||
DST_COMPOSE="voicehub/app/docker/docker-compose.yaml"
|
||||
|
||||
# 创建目录
|
||||
mkdir -p voicehub/app/docker
|
||||
|
||||
# 复制内容并进行修改:
|
||||
# 1. 移除 postgres 服务的端口映射,以避免 fnOS 5432 端口冲突
|
||||
# 2. 移除 voicehub 服务的代码挂载 (.:/app),避免覆盖镜像内的预构建代码导致 MODULE_NOT_FOUND
|
||||
# 3. 确保容器间通过 service name 互联
|
||||
# 直接使用 awk 工具更精确地过滤掉整个 volumes 块和 ports 块中我们不想要的行,或者删除空块
|
||||
awk '
|
||||
/^[[:space:]]+volumes:/ { in_volumes=1; print; next }
|
||||
/^[[:space:]]+ports:/ { in_ports=1; print; next }
|
||||
/^[[:space:]]+restart:/ { in_volumes=0; in_ports=0; print; next }
|
||||
/^[[:space:]]+[a-zA-Z0-9_]+:/ { in_volumes=0; in_ports=0; print; next }
|
||||
|
||||
in_volumes && /^[[:space:]]+-[[:space:]]+.:\/app/ { next }
|
||||
in_volumes && /^[[:space:]]+-[[:space:]]+\/app\/node_modules/ { next }
|
||||
in_ports && /^[[:space:]]+-[[:space:]]+.5432:5432./ { next }
|
||||
|
||||
{ print }
|
||||
' "$SRC_COMPOSE" > "$DST_COMPOSE.tmp"
|
||||
|
||||
# 清理由于上述删除可能产生的空的 volumes: 或 ports: 标签
|
||||
# 使用 grep 检查下一行是否是配置项或空行
|
||||
sed -E '/^[[:space:]]+volumes:[[:space:]]*$/{N;/\n[[:space:]]+-/!d}' "$DST_COMPOSE.tmp" > "$DST_COMPOSE.tmp2"
|
||||
sed -E '/^[[:space:]]+ports:[[:space:]]*$/{N;/\n[[:space:]]+-/!d}' "$DST_COMPOSE.tmp2" > "$DST_COMPOSE"
|
||||
rm "$DST_COMPOSE.tmp" "$DST_COMPOSE.tmp2"
|
||||
|
||||
echo "✅ 已处理 compose 文件:移除数据库端口暴露 & 移除应用代码挂载"
|
||||
cat "$DST_COMPOSE"
|
||||
|
||||
- name: 最终目录 & 打包
|
||||
run: |
|
||||
echo "📦 FPK 结构:"
|
||||
find voicehub -type f | sort
|
||||
echo "---"
|
||||
cd voicehub
|
||||
fnpack build
|
||||
ls -lh *.fpk 2>/dev/null || { echo "❌ 打包失败"; exit 1; }
|
||||
mv *.fpk ../
|
||||
|
||||
- name: 上传 Artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: voicehub-fpk-${{ env.APP_VER }}
|
||||
path: "*.fpk"
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: 'Reason for manual trigger'
|
||||
required: false
|
||||
default: 'Manual trigger'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# 提取版本号并处理成不同格式
|
||||
- name: Extract version from package.json
|
||||
id: extract-version
|
||||
run: |
|
||||
# 提取完整版本号
|
||||
FULL_VERSION=$(node -p "require('./package.json').version")
|
||||
# 提取主版本.次版本 (如 1.2 从 1.2.3)
|
||||
MAJOR_MINOR_VERSION=$(echo "$FULL_VERSION" | sed -E 's/^([0-9]+\.[0-9]+)\.[0-9]+$/\1/')
|
||||
|
||||
echo "full-version=$FULL_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "major-minor-version=$MAJOR_MINOR_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted full version: $FULL_VERSION"
|
||||
echo "Extracted major.minor version: $MAJOR_MINOR_VERSION"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm,arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
# 使用从package.json提取的完整版本号
|
||||
type=raw,value=${{ steps.extract-version.outputs.full-version }}
|
||||
# 使用主版本.次版本格式
|
||||
type=raw,value=${{ steps.extract-version.outputs.major-minor-version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,42 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
docs
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
*.backup
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
.kiro
|
||||
.cursor
|
||||
.vercel
|
||||
.netlify
|
||||
.vscode
|
||||
.trae
|
||||
.qoder
|
||||
.figma
|
||||
scripts/package-lock.json
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Tencent Cloud EdgeOne
|
||||
.edgeone/*
|
||||
.tef_dist/*
|
||||
@@ -0,0 +1,19 @@
|
||||
.nuxt/
|
||||
.output/
|
||||
dist/
|
||||
node_modules/
|
||||
public/
|
||||
coverage/
|
||||
*.min.js
|
||||
*.min.css
|
||||
.DS_Store
|
||||
*.log
|
||||
vendor/
|
||||
oh_modules/
|
||||
**/oh_modules/
|
||||
VoiceHub-hmos/oh_modules/
|
||||
VoiceHub-hmos/oh-package-lock.json5
|
||||
app/drizzle/
|
||||
**/build/
|
||||
**/.preview/
|
||||
VoiceHub-hmos/
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
README.md
|
||||
@@ -0,0 +1,76 @@
|
||||
# VoiceHub Code of Conduct
|
||||
|
||||
## Our Commitment
|
||||
|
||||
VoiceHub is an open source, community-driven campus broadcasting and song request system.
|
||||
|
||||
We are committed to providing a friendly, respectful, and harassment-free environment for everyone who participates in this project — including users, contributors, maintainers, and community members — regardless of age, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual orientation.
|
||||
|
||||
We aim to foster an open, inclusive, and constructive community where collaboration and learning are encouraged.
|
||||
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
We encourage all participants to:
|
||||
|
||||
- Be respectful and considerate in discussions, reviews, and feedback
|
||||
- Communicate thoughtfully, especially when opinions differ
|
||||
- Welcome newcomers and help them learn
|
||||
- Provide constructive criticism focused on improving the project
|
||||
- Take responsibility for mistakes and be willing to correct them
|
||||
- Respect the campus-oriented and community-serving nature of this project
|
||||
|
||||
---
|
||||
|
||||
## Unacceptable Behavior
|
||||
|
||||
The following behaviors are not tolerated within the VoiceHub community:
|
||||
|
||||
- Harassment, discrimination, or exclusion of any kind
|
||||
- Insulting, demeaning, or hostile language
|
||||
- Personal attacks, threats, or intimidation
|
||||
- Sexualized language, imagery, or unwanted attention
|
||||
- Publishing others’ private information without explicit permission
|
||||
- Deliberate disruption of discussions, development, or community activities
|
||||
- Any behavior that would be considered inappropriate in an educational or professional environment
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies to all VoiceHub-related spaces, including but not limited to:
|
||||
|
||||
- GitHub repositories, issues, pull requests, and discussions
|
||||
- Official communication channels and community platforms
|
||||
- Public spaces where individuals represent VoiceHub, including events, documentation, or social media
|
||||
|
||||
---
|
||||
|
||||
## Enforcement
|
||||
|
||||
If you experience or witness behavior that violates this Code of Conduct, please report it to the project maintainers.
|
||||
|
||||
All reports will be reviewed fairly and promptly. Maintainers will handle reports with discretion and respect the privacy of all parties involved.
|
||||
|
||||
Project maintainers have the responsibility and authority to take appropriate action in response to violations.
|
||||
|
||||
---
|
||||
|
||||
## Enforcement Actions
|
||||
|
||||
Depending on the severity and frequency of the behavior, maintainers may take one or more of the following actions:
|
||||
|
||||
1. **Private Reminder** – A private message explaining the issue and expected behavior.
|
||||
2. **Formal Warning** – A clear warning with expectations for future conduct.
|
||||
3. **Temporary Restriction** – Temporary limitation or suspension from community interaction.
|
||||
4. **Permanent Ban** – Permanent removal from participation in the project or community spaces.
|
||||
|
||||
Actions will be taken in a manner proportional to the impact on the community.
|
||||
|
||||
---
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is inspired by the Contributor Covenant, version 2.1.
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct
|
||||
@@ -0,0 +1,76 @@
|
||||
# VoiceHub 行为准则
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
VoiceHub 是一个面向校园与社区的开源广播与点歌系统项目。
|
||||
|
||||
我们致力于为所有参与本项目的人 —— 包括用户、贡献者、维护者以及社区成员 —— 提供一个友善、尊重、免于骚扰的环境,无论其年龄、残疾状况、族裔、性别认同与表达、经验水平、国籍、外表、宗教或性取向如何。
|
||||
|
||||
我们希望构建一个开放、包容、理性且富有建设性的社区氛围,鼓励协作与学习。
|
||||
|
||||
---
|
||||
|
||||
## 鼓励的行为
|
||||
|
||||
我们鼓励所有参与者:
|
||||
|
||||
- 在讨论、评审和反馈中保持尊重与克制
|
||||
- 在观点不一致时进行理性、善意的交流
|
||||
- 友好对待新参与者,帮助他们熟悉项目
|
||||
- 提供以改进项目为目标的建设性意见
|
||||
- 勇于承认并纠正错误
|
||||
- 尊重 VoiceHub 作为校园与社区服务项目的定位
|
||||
|
||||
---
|
||||
|
||||
## 不可接受的行为
|
||||
|
||||
在 VoiceHub 社区中,以下行为是不被允许的:
|
||||
|
||||
- 任何形式的骚扰、歧视或排斥行为
|
||||
- 侮辱性、贬损性或带有敌意的言辞
|
||||
- 人身攻击、威胁或恐吓
|
||||
- 带有性暗示的语言、图像或不受欢迎的关注
|
||||
- 未经许可公开他人的私人信息
|
||||
- 蓄意干扰社区讨论、开发流程或正常协作
|
||||
- 任何在教育或专业环境中被认为不恰当的行为
|
||||
|
||||
---
|
||||
|
||||
## 适用范围
|
||||
|
||||
本行为准则适用于所有与 VoiceHub 相关的空间,包括但不限于:
|
||||
|
||||
- GitHub 仓库、Issue、Pull Request 与讨论区
|
||||
- 官方社区交流渠道
|
||||
- 任何以 VoiceHub 名义进行的公开活动、文档或社交媒体发言
|
||||
|
||||
---
|
||||
|
||||
## 执行与举报
|
||||
|
||||
如果你遭遇或目睹了违反本行为准则的情况,请向项目维护者进行反馈。
|
||||
|
||||
所有举报都会被认真、公正地对待。维护者将尊重相关人员的隐私,并谨慎处理相关信息。
|
||||
|
||||
项目维护者有责任并有权对违规行为采取适当措施。
|
||||
|
||||
---
|
||||
|
||||
## 处理措施
|
||||
|
||||
根据违规行为的严重程度和影响,维护者可能采取以下一种或多种措施:
|
||||
|
||||
1. **私下提醒** —— 通过私下沟通说明问题并明确期望行为
|
||||
2. **正式警告** —— 明确指出违规行为及后续要求
|
||||
3. **临时限制** —— 暂时限制或暂停其参与社区互动
|
||||
4. **永久封禁** —— 永久取消其参与项目或社区的资格
|
||||
|
||||
所有处理措施都会遵循“与社区影响相匹配”的原则。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
本行为准则参考并改编自 Contributor Covenant 2.1 版本:
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct
|
||||
@@ -0,0 +1,77 @@
|
||||
# 支持: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
||||
# ==========================================
|
||||
# 第一阶段:构建阶段
|
||||
# ==========================================
|
||||
# 预定义各架构的构建镜像
|
||||
FROM node:24-alpine AS builder-amd64
|
||||
FROM node:24-alpine AS builder-arm64
|
||||
FROM arm32v7/node:22-alpine AS builder-arm
|
||||
# FROM s390x/node:24-alpine AS builder-s390x
|
||||
# FROM ppc64le/node:24-slim AS builder-ppc64le
|
||||
|
||||
# 根据 TARGETARCH 选择对应的构建镜像
|
||||
FROM builder-${TARGETARCH} AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件和 scripts 目录
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY scripts ./scripts
|
||||
|
||||
# 安装所有依赖
|
||||
RUN set -eux; \
|
||||
corepack enable; \
|
||||
pnpm config set fetch-retries 5; \
|
||||
pnpm config set fetch-retry-mintimeout 20000; \
|
||||
pnpm config set fetch-retry-maxtimeout 120000; \
|
||||
pnpm install --frozen-lockfile || ( \
|
||||
rm -rf node_modules; \
|
||||
pnpm install --no-frozen-lockfile || ( \
|
||||
pnpm config set registry https://registry.npmmirror.com; \
|
||||
pnpm install --no-frozen-lockfile \
|
||||
) \
|
||||
)
|
||||
|
||||
# 复制所有源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN pnpm run build
|
||||
|
||||
# ==========================================
|
||||
# 第二阶段:运行阶段
|
||||
# ==========================================
|
||||
# 预定义各架构的运行时镜像
|
||||
FROM node:24-alpine AS runtime-amd64
|
||||
FROM node:24-alpine AS runtime-arm64
|
||||
FROM arm32v7/node:22-alpine AS runtime-arm
|
||||
# FROM s390x/node:24-alpine AS runtime-s390x
|
||||
# FROM ppc64le/node:24-slim AS runtime-ppc64le
|
||||
|
||||
# 根据 TARGETARCH 选择对应的运行时镜像
|
||||
FROM runtime-${TARGETARCH} AS runtime
|
||||
|
||||
USER root
|
||||
WORKDIR /app
|
||||
|
||||
# 从构建阶段复制必要文件
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/drizzle.config.ts ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.output ./.output
|
||||
COPY --from=builder /app/app/drizzle ./app/drizzle
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
|
||||
# 环境变量配置
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false \
|
||||
PREBUILT=true
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE $PORT
|
||||
|
||||
# 启动命令:先执行数据库迁移,再启动应用
|
||||
CMD ["sh", "-c", "node scripts/deploy.js && node .output/server/index.mjs"]
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
||||
# VoiceHub 升级指南
|
||||
|
||||
本文档介绍如何将 VoiceHub 项目从旧版本升级到最新版本。请根据您的部署方式选择相应的升级步骤。
|
||||
|
||||
> ⚠️ **升级前请务必备份数据库和配置文件!**
|
||||
|
||||
## 1. 备份数据
|
||||
|
||||
无论使用何种部署方式,升级前请先备份数据库。
|
||||
|
||||
### 1.1 使用管理后台备份(推荐)
|
||||
|
||||
1. 登录管理员后台
|
||||
2. 进入"数据库管理"页面
|
||||
3. 点击"创建备份" -> 选择"完整备份"
|
||||
4. 待备份完成后,下载备份文件到本地保存
|
||||
|
||||
### 1.2 使用命令行备份
|
||||
|
||||
也可以使用 PostgreSQL 命令行工具:
|
||||
|
||||
```bash
|
||||
# Docker 部署用户
|
||||
docker exec voicehub-postgres-1 pg_dump -U user voicehub > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# 源码部署用户
|
||||
pg_dump -h localhost -U your_username -d voicehub > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Docker 部署升级
|
||||
|
||||
### 2.1 使用 Docker Compose(推荐)
|
||||
|
||||
如果您使用 `docker-compose.yml` 部署:
|
||||
|
||||
1. **拉取最新代码**
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
2. **重建并重启服务**
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
> **注意**:由于默认配置使用本地构建,单纯使用 `pull` 命令无效。必须添加 `--build` 参数以确保应用代码变更。
|
||||
|
||||
3. **验证升级**
|
||||
```bash
|
||||
docker-compose logs -f voicehub
|
||||
```
|
||||
查看日志确认没有报错,且显示 "部署完成"。
|
||||
|
||||
### 2.2 使用 Docker Run 命令
|
||||
|
||||
如果您直接使用 `docker run` 命令:
|
||||
|
||||
1. **停止并删除旧容器**
|
||||
|
||||
```bash
|
||||
docker stop voicehub
|
||||
docker rm voicehub
|
||||
```
|
||||
|
||||
2. **拉取最新镜像**
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/laoshuikaixue/voicehub:latest
|
||||
# 或
|
||||
docker pull ghcr.nju.edu.cn/laoshuikaixue/voicehub:latest
|
||||
```
|
||||
|
||||
3. **启动新容器**
|
||||
使用与之前相同的环境变量启动:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL="..." \
|
||||
-e JWT_SECRET="..." \
|
||||
--name voicehub \
|
||||
ghcr.io/laoshuikaixue/voicehub:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 源码/本地部署升级
|
||||
|
||||
### 3.1 停止运行中的服务
|
||||
|
||||
```bash
|
||||
# 如果使用 PM2
|
||||
pm2 stop voicehub
|
||||
|
||||
# 如果使用 systemd
|
||||
sudo systemctl stop voicehub
|
||||
|
||||
# 如果直接运行(Ctrl+C 停止进程)
|
||||
```
|
||||
|
||||
### 3.2 获取最新代码
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git pull origin main
|
||||
|
||||
# 或指定分支
|
||||
# git pull origin <branch-name>
|
||||
```
|
||||
|
||||
### 3.3 更新依赖
|
||||
|
||||
```bash
|
||||
# 安装最新依赖
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
### 3.4 数据库迁移
|
||||
|
||||
**重要**:新版本通常包含数据库结构变更,必须执行迁移。
|
||||
|
||||
```bash
|
||||
# 方式 1:使用自动同步脚本(推荐)
|
||||
node scripts/db-sync.js
|
||||
|
||||
# 方式 2:手动执行迁移
|
||||
pnpm run db:migrate
|
||||
|
||||
# 方式 3:如果迁移失败,且确信可以覆盖(仅开发环境)
|
||||
# pnpm run db:push
|
||||
```
|
||||
|
||||
**迁移说明**:
|
||||
|
||||
- `db-sync.js` 脚本会自动检测数据库状态并选择合适的迁移方式
|
||||
- 空数据库会执行完整迁移
|
||||
- 已有数据的数据库会使用 push 方式同步结构
|
||||
|
||||
### 3.5 重新构建应用
|
||||
|
||||
```bash
|
||||
# 清理旧的构建文件(可选)
|
||||
rm -rf .nuxt .output
|
||||
|
||||
# 构建生产版本
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
构建完成后,会在 `.output` 目录生成生产环境文件。
|
||||
|
||||
### 3.6 重启服务
|
||||
|
||||
根据您的部署方式选择:
|
||||
|
||||
#### 使用 PM2(推荐)
|
||||
|
||||
```bash
|
||||
# 重启服务
|
||||
pm2 restart voicehub
|
||||
|
||||
# 查看日志
|
||||
pm2 logs voicehub
|
||||
|
||||
# 查看状态
|
||||
pm2 status
|
||||
```
|
||||
|
||||
如果是首次使用 PM2:
|
||||
|
||||
```bash
|
||||
# 安装 PM2
|
||||
pnpm add -g pm2
|
||||
|
||||
# 启动应用
|
||||
pm2 start pnpm --name voicehub -- run start
|
||||
|
||||
# 设置开机自启
|
||||
pm2 startup
|
||||
pm2 save
|
||||
```
|
||||
|
||||
#### 使用 systemd
|
||||
|
||||
```bash
|
||||
# 重启服务
|
||||
sudo systemctl restart voicehub
|
||||
|
||||
# 查看状态
|
||||
sudo systemctl status voicehub
|
||||
|
||||
# 查看日志
|
||||
sudo journalctl -u voicehub -f
|
||||
```
|
||||
|
||||
#### 直接运行
|
||||
|
||||
```bash
|
||||
# 生产模式启动
|
||||
pnpm run start
|
||||
|
||||
# 或使用 nohup 后台运行
|
||||
nohup pnpm run start > voicehub.log 2>&1 &
|
||||
```
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="app" data-theme="dark" data-color-scheme="custom">
|
||||
<!-- 全局通知容器组件 -->
|
||||
<LazyUINotificationContainer ref="notificationContainer" />
|
||||
|
||||
<!-- 全局音频播放器 - 使用isPlayerVisible控制显示/隐藏 -->
|
||||
<LazyUIAudioPlayer
|
||||
v-show="isPlayerVisible"
|
||||
:song="currentSong"
|
||||
:is-playlist-mode="isPlaylistMode"
|
||||
@close="handlePlayerClose"
|
||||
@ended="handlePlayerEnded"
|
||||
/>
|
||||
|
||||
<main class="main-content">
|
||||
<div :key="route.path" :data-route="pageViewRouteName" class="page-view">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
// 导入通知容器组件和音频播放器
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
// 获取运行时配置
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const pageViewRouteName = computed(() => {
|
||||
if (typeof route.name === 'string' && route.name) {
|
||||
return route.name
|
||||
}
|
||||
|
||||
return route.path.split('/').filter(Boolean).join('-') || 'index'
|
||||
})
|
||||
|
||||
// 通知容器引用
|
||||
const notificationContainer = ref(null)
|
||||
|
||||
// 音频播放器
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const currentSong = ref(null)
|
||||
const isPlayerVisible = ref(false) // 控制播放器显示/隐藏
|
||||
|
||||
// 判断是否为播放列表模式
|
||||
// 投稿页面、搜索预览等场景不是播放列表模式,不应该自动跳过
|
||||
const isPlaylistMode = computed(() => {
|
||||
const currentPath = route.path
|
||||
// 投稿页面不是播放列表模式
|
||||
if (currentPath.includes('/request') || currentPath.includes('/submit')) {
|
||||
return false
|
||||
}
|
||||
// 其他页面默认为播放列表模式(如歌曲列表、播出排期等)
|
||||
return true
|
||||
})
|
||||
|
||||
// 监听路由变化,控制播放器显示/隐藏
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
// 其他页面,如果有当前歌曲则显示播放器
|
||||
if (currentSong.value) {
|
||||
isPlayerVisible.value = true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听当前播放的歌曲
|
||||
watch(
|
||||
() => audioPlayer.getCurrentSong().value,
|
||||
(newSong) => {
|
||||
if (newSong) {
|
||||
currentSong.value = newSong
|
||||
// 显示播放器
|
||||
isPlayerVisible.value = true
|
||||
} else {
|
||||
// 当没有歌曲时,不立即隐藏播放器,而是让动画完成
|
||||
currentSong.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 处理播放器关闭事件
|
||||
const handlePlayerClose = () => {
|
||||
// 延迟隐藏播放器,与AudioPlayer组件的动画时长同步
|
||||
setTimeout(() => {
|
||||
isPlayerVisible.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 处理播放结束事件
|
||||
const handlePlayerEnded = () => {
|
||||
// 不在这里处理播放结束逻辑
|
||||
// 播放结束的逻辑已经在 useAudioPlayerControl.ts 的 onEnded 中处理
|
||||
// 包括单曲循环、列表播放等模式
|
||||
// 只有在播放模式为 'off' 时才需要隐藏播放器
|
||||
const audioPlayerControl = useAudioPlayerControl()
|
||||
if (audioPlayerControl.playMode.value === 'off') {
|
||||
// 播放完成后停止播放器
|
||||
audioPlayer.stopSong()
|
||||
// 延迟隐藏播放器
|
||||
setTimeout(() => {
|
||||
isPlayerVisible.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用onMounted确保只在客户端初始化认证
|
||||
let auth = null
|
||||
let isAuthenticated = false
|
||||
|
||||
// 设置鸿蒙系统控制事件监听
|
||||
const setupHarmonyOSListeners = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// 监听鸿蒙系统控制事件
|
||||
const handleHarmonyOSPlay = () => {
|
||||
const currentGlobalSong = audioPlayer.getCurrentSong().value
|
||||
if (currentGlobalSong) {
|
||||
// 如果有当前歌曲,恢复播放
|
||||
audioPlayer.playSong(currentGlobalSong)
|
||||
}
|
||||
}
|
||||
|
||||
const handleHarmonyOSPause = () => {
|
||||
audioPlayer.pauseSong()
|
||||
}
|
||||
|
||||
const handleHarmonyOSStop = () => {
|
||||
audioPlayer.stopSong()
|
||||
}
|
||||
|
||||
const handleHarmonyOSNext = async () => {
|
||||
try {
|
||||
const success = await audioPlayer.playNext()
|
||||
if (!success) {
|
||||
console.log('没有下一首歌曲或切换失败,继续播放当前歌曲')
|
||||
// 如果切换失败,不做任何操作,继续播放当前歌曲
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换下一首歌曲失败:', error)
|
||||
// 切换失败时不停止播放,继续播放当前歌曲
|
||||
}
|
||||
}
|
||||
|
||||
const handleHarmonyOSPrevious = async () => {
|
||||
try {
|
||||
const success = await audioPlayer.playPrevious()
|
||||
if (!success) {
|
||||
console.log('没有上一首歌曲或切换失败,继续播放当前歌曲')
|
||||
// 如果切换失败,不做任何操作,继续播放当前歌曲
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换上一首歌曲失败:', error)
|
||||
// 切换失败时不停止播放,继续播放当前歌曲
|
||||
}
|
||||
}
|
||||
|
||||
// 使用Nuxt的事件总线监听鸿蒙控制事件
|
||||
if (window.__NUXT__ && window.__NUXT__.$nuxt) {
|
||||
const nuxtApp = window.__NUXT__.$nuxt
|
||||
if (nuxtApp.$on) {
|
||||
nuxtApp.$on('harmonyos-play', handleHarmonyOSPlay)
|
||||
nuxtApp.$on('harmonyos-pause', handleHarmonyOSPause)
|
||||
nuxtApp.$on('harmonyos-stop', handleHarmonyOSStop)
|
||||
nuxtApp.$on('harmonyos-next', handleHarmonyOSNext)
|
||||
nuxtApp.$on('harmonyos-previous', handleHarmonyOSPrevious)
|
||||
}
|
||||
}
|
||||
|
||||
// 备用方案:直接在window上监听自定义事件
|
||||
window.addEventListener('harmonyos-play', handleHarmonyOSPlay)
|
||||
window.addEventListener('harmonyos-pause', handleHarmonyOSPause)
|
||||
window.addEventListener('harmonyos-stop', handleHarmonyOSStop)
|
||||
window.addEventListener('harmonyos-next', handleHarmonyOSNext)
|
||||
window.addEventListener('harmonyos-previous', handleHarmonyOSPrevious)
|
||||
}
|
||||
|
||||
// 在组件挂载后初始化认证(只会在客户端执行)
|
||||
onMounted(async () => {
|
||||
auth = useAuth()
|
||||
isAuthenticated = auth.isAuthenticated.value
|
||||
|
||||
// 初始化鸿蒙系统控制事件监听
|
||||
setupHarmonyOSListeners()
|
||||
})
|
||||
|
||||
// 使用计算属性确保安全地访问auth对象
|
||||
const safeIsAuthenticated = computed(() => auth?.isAuthenticated?.value || false)
|
||||
|
||||
const handleLogout = () => {
|
||||
if (auth) {
|
||||
auth.logout()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 应用布局 */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 主要内容 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
/* 通用组件样式 - 统一设计系统 */
|
||||
|
||||
/* 按钮组件 */
|
||||
.btn-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn-base:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-base:disabled {
|
||||
background-color: var(--btn-disabled-bg);
|
||||
color: var(--btn-disabled-text);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-base svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 按钮变体 */
|
||||
.btn-primary {
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-text);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--btn-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--btn-secondary-bg);
|
||||
color: var(--btn-secondary-text);
|
||||
border: 1px solid var(--btn-secondary-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--btn-secondary-hover);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--btn-success-bg);
|
||||
color: var(--btn-success-text);
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background-color: var(--btn-success-hover);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--btn-warning-bg);
|
||||
color: var(--btn-warning-text);
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background-color: var(--btn-warning-hover);
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
background-color: var(--btn-error-bg);
|
||||
color: var(--btn-error-text);
|
||||
}
|
||||
|
||||
.btn-error:hover:not(:disabled) {
|
||||
background-color: var(--btn-error-hover);
|
||||
}
|
||||
|
||||
/* 按钮尺寸 */
|
||||
.btn-sm {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--spacing-lg) var(--spacing-2xl);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* 输入框组件 */
|
||||
.input-base {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background-color: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
transition:
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-base:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.input-base::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
}
|
||||
|
||||
.input-base:disabled {
|
||||
background-color: var(--bg-quaternary);
|
||||
color: var(--text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 卡片组件 */
|
||||
.card-base {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-2xl);
|
||||
box-shadow: var(--card-shadow);
|
||||
transition:
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.card-base:hover {
|
||||
border-color: var(--card-hover-border);
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.card-interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--card-hover-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* 模态框组件 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--modal-backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal-backdrop);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--modal-bg);
|
||||
border: 1px solid var(--modal-border);
|
||||
border-radius: var(--radius-xl);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-xl) var(--spacing-2xl);
|
||||
border-bottom: 1px solid var(--modal-header-border);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-xl) var(--spacing-2xl);
|
||||
border-top: 1px solid var(--modal-footer-border);
|
||||
}
|
||||
|
||||
/* 表格组件 */
|
||||
.table-container {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: var(--table-header-bg);
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: var(--table-row-hover);
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-row.selected {
|
||||
background-color: var(--table-selected);
|
||||
}
|
||||
|
||||
/* 状态徽章 */
|
||||
.badge-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: var(--status-approved-light);
|
||||
color: var(--status-approved);
|
||||
border: 1px solid var(--success-border);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: var(--status-pending-light);
|
||||
color: var(--status-pending);
|
||||
border: 1px solid var(--warning-border);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: var(--error-light);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: var(--info-light);
|
||||
color: var(--info);
|
||||
border: 1px solid var(--info-border);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--spinner-secondary);
|
||||
border-top: 3px solid var(--spinner-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.spinner-lg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 工具提示 */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-quaternary);
|
||||
color: var(--text-primary);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
z-index: var(--z-tooltip);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity var(--transition-normal),
|
||||
visibility var(--transition-normal);
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltip-content {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* 分页组件 */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--btn-secondary-bg);
|
||||
border: 1px solid var(--btn-secondary-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--btn-secondary-text);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: var(--btn-secondary-hover);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
background: var(--btn-disabled-bg);
|
||||
color: var(--btn-disabled-text);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-3xl) var(--spacing-xl);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
给各个内部元素使用的样式
|
||||
*/
|
||||
|
||||
/* 给每行歌词应用的样式 */
|
||||
.lyricLine {
|
||||
position: absolute;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: left;
|
||||
width: var(--amll-lp-width, 100%);
|
||||
min-width: var(--amll-lp-width, 100%);
|
||||
max-width: var(--amll-lp-width, 100%);
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
padding: 0.5em 1em;
|
||||
contain: content;
|
||||
will-change: transform;
|
||||
transition:
|
||||
opacity 0.25s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
box-sizing: border-box;
|
||||
border-radius: 0.25em;
|
||||
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
&.dirty {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:has(> *):hover {
|
||||
background-color: var(--amll-lp-hover-bg-color, #fff1);
|
||||
/* box-shadow: 0 0 0 4px var(--amll-lp-hover-bg-color, #fff1); */
|
||||
}
|
||||
|
||||
&:has(> *):active {
|
||||
background-color: var(--amll-lp-hover-bg-color, #ffffff05);
|
||||
/* box-shadow: 0 0 0 var(--amll-lp-hover-bg-color, #fff1); */
|
||||
}
|
||||
}
|
||||
|
||||
.lyricBgLine {
|
||||
opacity: 0.0001;
|
||||
font-size: max(calc(1em * var(--amll-lp-bg-line-scale, 0.7)), 10px);
|
||||
transition:
|
||||
opacity 0.25s,
|
||||
scale 0.5s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
/* 因为字体大小缩小了,故内边距要和主行字体大小统一,行边距计算公式为 100% / font-size 转 em 单位 */
|
||||
padding: 1vh calc(var(--amll-lp-line-padding-x, 1em) / var(--amll-lp-bg-line-scale, 0.7));
|
||||
|
||||
&.active {
|
||||
transition:
|
||||
opacity 0.5s 0.25s,
|
||||
scale 1.5s cubic-bezier(0, 1, 0, 1) 0.25s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.amll-lyric-player) {
|
||||
&:hover .lyricLine {
|
||||
filter: unset !important;
|
||||
}
|
||||
|
||||
&.hasDuetLine {
|
||||
.lyricLine:not(.lyricDuetLine) {
|
||||
padding-right: 15%;
|
||||
}
|
||||
|
||||
.lyricDuetLine {
|
||||
padding-left: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:global(.playing)) > .lyricBgLine {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.lyricDuetLine {
|
||||
text-align: right;
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
.lyricMainLine {
|
||||
transition: opacity 0.3s 0.1s;
|
||||
margin: -1em;
|
||||
padding: 1em;
|
||||
/* 为主歌词行增加文字阴影,以提升亮色背景下的可读性 */
|
||||
text-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.6),
|
||||
0 0 10px rgba(0, 0, 0, 0.5);
|
||||
|
||||
& span {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.romanWord {
|
||||
font-size: 0.5em;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
& > span,
|
||||
span.emphasizeWrapper {
|
||||
white-space: pre-wrap;
|
||||
display: inline-block;
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
will-change: transform;
|
||||
|
||||
&.emphasize,
|
||||
span.emphasize {
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
backface-visibility: hidden;
|
||||
|
||||
& > span {
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyricSubLine {
|
||||
font-size: max(0.5em, 10px);
|
||||
line-height: 1.5em;
|
||||
transition: opacity 0.2s 0.25s;
|
||||
opacity: 0.3;
|
||||
/* 为副歌词行增加轻度文字阴影,避免白字贴白背景 */
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.disableSpring > * {
|
||||
transition:
|
||||
filter 0.25s,
|
||||
transform 0.5s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
|
||||
.interludeDots {
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
transition: opacity 0.25s;
|
||||
transform-origin: center;
|
||||
width: fit-content;
|
||||
padding: 2.5% 0.75em;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
|
||||
&.enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: clamp(0.5em, 1vh, 3em);
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: var(--amll-lp-color, white);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.duet {
|
||||
right: 0;
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (mix-blend-mode: plus-lighter) {
|
||||
.lyricSubLine {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.tmpDisableTransition {
|
||||
transition: none !important;
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/* 导入浏览器主题保护样式 */
|
||||
@import url('./theme-protection.css');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 防止浏览器自动应用深色主题 */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* 流体背景与全局样式 */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 字体显示策略优化 */
|
||||
@font-face {
|
||||
font-family: 'MiSans';
|
||||
font-display: swap; /* 确保字体加载期间文本可见 */
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'MiSans',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Open Sans',
|
||||
sans-serif;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
font-size: var(--text-base);
|
||||
/* 字体渲染优化 */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'MiSans',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Open Sans',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 简洁现代背景 */
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 可选的微妙纹理效果 */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
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;
|
||||
}
|
||||
|
||||
/* 容器 */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 常用工具类 */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.gap-2 {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.gap-4 {
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
.gap-6 {
|
||||
gap: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* 间距 */
|
||||
.mt-2 {
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: var(--spacing-2xl);
|
||||
}
|
||||
.mt-8 {
|
||||
margin-top: var(--spacing-3xl);
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
.mb-8 {
|
||||
margin-bottom: var(--spacing-3xl);
|
||||
}
|
||||
.ml-2 {
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
.ml-4 {
|
||||
margin-left: var(--spacing-lg);
|
||||
}
|
||||
.mr-2 {
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
.mr-4 {
|
||||
margin-right: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
.p-4 {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
.p-6 {
|
||||
padding: var(--spacing-2xl);
|
||||
}
|
||||
.p-8 {
|
||||
padding: var(--spacing-3xl);
|
||||
}
|
||||
|
||||
/* 文本 */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-sm {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.text-lg {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.text-xl {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
.text-2xl {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.text-3xl {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
.font-bold {
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
.text-primary {
|
||||
color: var(--primary);
|
||||
}
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.text-tertiary {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.text-success {
|
||||
color: var(--success);
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--error);
|
||||
}
|
||||
.text-warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-2xl);
|
||||
box-shadow:
|
||||
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;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
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 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);
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md) var(--spacing-2xl);
|
||||
font-weight: var(--font-semibold);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: var(--text-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--btn-primary-bg) 0%, var(--primary-hover) 100%);
|
||||
color: var(--btn-primary-text);
|
||||
border: 1px solid var(--primary-border);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--primary-hover) 0%, var(--primary) 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, var(--btn-secondary-bg) 0%, var(--bg-quaternary) 100%);
|
||||
color: var(--btn-secondary-text);
|
||||
border: 1px solid var(--btn-secondary-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: linear-gradient(135deg, var(--bg-quaternary) 0%, #4a4a4a 100%);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 2px solid var(--border-tertiary);
|
||||
color: var(--text-primary);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: var(--btn-disabled-bg);
|
||||
color: var(--btn-disabled-text);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn:disabled::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 列表样式 */
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.list-none {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.pl-5 {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--input-bg);
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
outline: none;
|
||||
transition: all var(--transition-normal);
|
||||
font-size: var(--text-sm);
|
||||
position: relative;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow:
|
||||
var(--input-shadow-focus),
|
||||
0 4px 12px rgba(102, 126, 234, 0.1);
|
||||
background: var(--bg-secondary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.input:focus::placeholder {
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.form-group:focus-within label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (min-width: 640px) {
|
||||
.sm\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.sm\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.sm\:items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.sm\:justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.md\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.lg\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
/* 玻璃态效果 */
|
||||
.glass {
|
||||
background: var(--bg-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
/* 手机设备上的优化 */
|
||||
@media (max-width: 640px) {
|
||||
.home {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 2rem !important;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/* 移动端管理界面优化样式 */
|
||||
|
||||
/* 确保打印时不受移动端样式影响 */
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
.schedule-item-print,
|
||||
.print-page,
|
||||
.schedule-content {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 通用移动端表单优化 */
|
||||
@media screen and (max-width: 768px) {
|
||||
/* 模态框移动端优化 */
|
||||
.modal-overlay {
|
||||
padding: 0;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 16px 16px 0 0 !important;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 16px 20px !important;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: inherit;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 20px !important;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: inherit;
|
||||
border-top: 1px solid var(--border-secondary) !important;
|
||||
flex-direction: column !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
/* 表单元素移动端优化 */
|
||||
.form-group {
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 15px !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
padding: 14px 16px !important;
|
||||
font-size: 16px !important; /* 防止iOS缩放 */
|
||||
border-radius: 10px !important;
|
||||
min-height: 44px !important; /* 触摸友好的最小高度 */
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 120px !important;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 20px !important;
|
||||
}
|
||||
|
||||
/* 按钮移动端优化 */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-danger,
|
||||
.btn-warning {
|
||||
padding: 14px 20px !important;
|
||||
font-size: 16px !important;
|
||||
min-height: 48px !important;
|
||||
border-radius: 10px !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.modal-footer .btn-primary,
|
||||
.modal-footer .btn-secondary {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.modal-footer .btn-secondary {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
/* 搜索输入框优化 */
|
||||
.search-input {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 16px !important;
|
||||
border-radius: 10px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
/* 工具栏按钮优化 */
|
||||
.create-btn,
|
||||
.refresh-btn,
|
||||
.upload-btn {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 15px !important;
|
||||
min-height: 44px !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
/* 操作按钮优化 */
|
||||
.action-btn {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
/* 分页按钮优化 */
|
||||
.page-btn {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 15px !important;
|
||||
min-height: 44px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* 选择框优化 */
|
||||
.checkbox,
|
||||
.radio {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* 标签和徽章优化 */
|
||||
.role-badge,
|
||||
.status-badge,
|
||||
.permission-tag {
|
||||
padding: 6px 10px !important;
|
||||
font-size: 12px !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
/* 错误消息优化 */
|
||||
.error-message {
|
||||
padding: 14px 16px !important;
|
||||
font-size: 15px !important;
|
||||
border-radius: 10px !important;
|
||||
margin-top: 12px !important;
|
||||
}
|
||||
|
||||
/* 加载状态优化 */
|
||||
.loading-overlay {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 15px !important;
|
||||
margin-top: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕设备进一步优化 */
|
||||
@media screen and (max-width: 480px) {
|
||||
.modal-content {
|
||||
border-radius: 12px 12px 0 0 !important;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
padding: 12px 14px !important;
|
||||
font-size: 16px !important;
|
||||
min-height: 40px !important;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-danger,
|
||||
.btn-warning {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 15px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 触摸设备特殊优化 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* 增加触摸目标大小 */
|
||||
.nav-item,
|
||||
.action-btn,
|
||||
.page-btn,
|
||||
.close-btn {
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
/* 移除悬停效果 */
|
||||
.nav-item:hover,
|
||||
.action-btn:hover,
|
||||
.page-btn:hover,
|
||||
.close-btn:hover {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* 添加触摸反馈 */
|
||||
.nav-item:active,
|
||||
.action-btn:active,
|
||||
.page-btn:active,
|
||||
.close-btn:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* 横屏模式优化 */
|
||||
@media screen and (max-width: 768px) and (orientation: landscape) {
|
||||
.modal-content {
|
||||
max-height: 85vh !important;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 12px 20px !important;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px 20px !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端专用交互优化 */
|
||||
@media screen and (max-width: 768px) {
|
||||
/* 滑动手势支持 */
|
||||
.sidebar {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.activity-list,
|
||||
.modal-body {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 触摸反馈 */
|
||||
.nav-item,
|
||||
.action-btn,
|
||||
.page-btn,
|
||||
.close-btn,
|
||||
.mobile-menu-btn,
|
||||
.user-card,
|
||||
.data-card,
|
||||
.stat-card {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 防止双击缩放 */
|
||||
.nav-item,
|
||||
.action-btn,
|
||||
.page-btn,
|
||||
.close-btn,
|
||||
.mobile-menu-btn {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* 长按菜单禁用 */
|
||||
.action-btn,
|
||||
.page-btn,
|
||||
.close-btn {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 拖拽优化 */
|
||||
.user-card,
|
||||
.data-card,
|
||||
.role-card {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
/* 输入框优化 */
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea,
|
||||
.search-input {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* IOS特定优化 */
|
||||
.form-input[type='text'],
|
||||
.form-input[type='email'],
|
||||
.form-input[type='password'],
|
||||
.search-input {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 触摸设备特殊交互 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* 卡片点击效果 */
|
||||
.user-card:active,
|
||||
.data-card:active,
|
||||
.role-card:active,
|
||||
.stat-card:active {
|
||||
transform: scale(0.98);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* 按钮点击效果 */
|
||||
.nav-item:active,
|
||||
.action-btn:active,
|
||||
.page-btn:active,
|
||||
.close-btn:active,
|
||||
.mobile-menu-btn:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* 移除悬停效果 */
|
||||
.nav-item:hover,
|
||||
.action-btn:hover,
|
||||
.page-btn:hover,
|
||||
.close-btn:hover,
|
||||
.user-card:hover,
|
||||
.data-card:hover,
|
||||
.role-card:hover,
|
||||
.stat-card:hover {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 焦点样式优化 */
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus,
|
||||
.search-input:focus {
|
||||
transform: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 下拉刷新支持 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.overview-dashboard,
|
||||
.user-manager,
|
||||
.role-manager {
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
}
|
||||
|
||||
/* 安全区域适配 */
|
||||
@supports (padding: max(0px)) {
|
||||
@media screen and (max-width: 768px) {
|
||||
.mobile-header {
|
||||
padding-left: max(16px, env(safe-area-inset-left));
|
||||
padding-right: max(16px, env(safe-area-inset-right));
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding-left: max(0px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin-bottom: max(0px, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/* PDF导出宽度样式 */
|
||||
|
||||
@media print {
|
||||
/* 重置所有可能影响宽度的样式 */
|
||||
* {
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* 强制覆盖移动端样式 */
|
||||
.schedule-item-print .item-content {
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .sequence-number {
|
||||
align-self: center !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .cover-section {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .song-info {
|
||||
width: auto !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .requester-info,
|
||||
.schedule-item-print .votes-info,
|
||||
.schedule-item-print .playtime-info {
|
||||
width: auto !important;
|
||||
justify-content: flex-end !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* 确保页面容器宽度正确 */
|
||||
html,
|
||||
body {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* 打印页面容器 - 固定合理宽度 */
|
||||
.print-page {
|
||||
width: 210mm !important;
|
||||
max-width: 210mm !important;
|
||||
min-width: auto !important;
|
||||
box-sizing: border-box !important;
|
||||
margin: 0 auto !important;
|
||||
padding: 20mm !important;
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* 排期内容容器 */
|
||||
.schedule-content {
|
||||
width: 100% !important;
|
||||
max-width: 170mm !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* 排期项目 */
|
||||
.schedule-item-print {
|
||||
width: 100% !important;
|
||||
max-width: 170mm !important;
|
||||
box-sizing: border-box !important;
|
||||
margin-bottom: 8px !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* 排期项目内容 */
|
||||
.schedule-item-print .item-content {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
width: 100% !important;
|
||||
gap: 12px !important;
|
||||
padding: 8px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* 序号 */
|
||||
.schedule-item-print .sequence-number {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
flex-shrink: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
background: #f0f0f0 !important;
|
||||
border-radius: 50% !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* 封面 */
|
||||
.schedule-item-print .cover-section {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .song-cover,
|
||||
.schedule-item-print .cover-placeholder {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
/* 歌曲信息 */
|
||||
.schedule-item-print .song-info {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
margin-right: 12px !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .song-title {
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #000 !important;
|
||||
margin-bottom: 2px !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .song-artist {
|
||||
font-size: 14px !important;
|
||||
color: #333 !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
/* 附加信息 */
|
||||
.schedule-item-print .requester-info,
|
||||
.schedule-item-print .votes-info,
|
||||
.schedule-item-print .playtime-info {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
font-size: 12px !important;
|
||||
color: #666 !important;
|
||||
white-space: nowrap !important;
|
||||
flex-shrink: 0 !important;
|
||||
margin-left: 8px !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .label {
|
||||
font-weight: 500 !important;
|
||||
margin-right: 4px !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .value {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
/* 页面头部和尾部 */
|
||||
.page-header,
|
||||
.page-footer {
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* 学校Logo */
|
||||
.school-logo-print {
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center !important;
|
||||
margin-bottom: 20px !important;
|
||||
padding-bottom: 10px !important;
|
||||
border-bottom: 2px solid #000 !important;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
text-align: center !important;
|
||||
margin-top: 20px !important;
|
||||
padding-top: 10px !important;
|
||||
border-top: 1px solid #ccc !important;
|
||||
font-size: 12px !important;
|
||||
color: #666 !important;
|
||||
}
|
||||
|
||||
/* 隐藏不需要打印的元素 */
|
||||
.settings-panel,
|
||||
.preview-header,
|
||||
.mobile-header,
|
||||
.sidebar,
|
||||
.mobile-overlay,
|
||||
.back-to-top-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 确保主内容区域正确显示 */
|
||||
.main-content {
|
||||
margin-left: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* 移除所有阴影和边框效果 */
|
||||
* {
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 确保文字颜色正确 */
|
||||
* {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/* 页面分页控制 */
|
||||
.print-page {
|
||||
page-break-after: always !important;
|
||||
}
|
||||
|
||||
.print-page:last-child {
|
||||
page-break-after: auto !important;
|
||||
}
|
||||
|
||||
/* 防止内容被截断 */
|
||||
.schedule-item-print {
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* 优化分页效果 */
|
||||
.page-header {
|
||||
page-break-after: avoid !important;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
page-break-before: avoid !important;
|
||||
}
|
||||
|
||||
/* 确保歌曲信息完整显示 */
|
||||
.schedule-item-print .item-content {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* 防止封面图片被截断 */
|
||||
.schedule-item-print .cover-section {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* 防止歌曲标题和艺术家信息被截断 */
|
||||
.schedule-item-print .song-info {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* 防止附加信息被截断 */
|
||||
.schedule-item-print .requester-info,
|
||||
.schedule-item-print .votes-info,
|
||||
.schedule-item-print .playtime-info {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 专门针对PDF导出的样式 */
|
||||
@page {
|
||||
margin: 20mm;
|
||||
size: A4;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* SF Pro 字体导入 */
|
||||
@import url('https://cdn.jsdelivr.net/npm/sf-font@1.0.0/stylesheet.min.css');
|
||||
|
||||
/* SF Symbols 图标类 */
|
||||
.sf-symbol {
|
||||
font-family:
|
||||
'SF Pro Icons',
|
||||
'SF Symbols',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.sf-symbol.semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* SF Pro 文本类 */
|
||||
.sf-pro {
|
||||
font-family:
|
||||
'SF Pro',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* 温和的浏览器主题保护样式 */
|
||||
/* 只保护关键元素,不干扰正常的CSS */
|
||||
|
||||
/* 全局颜色方案声明 */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* 只保护表单元素的外观 */
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
input[type='password'],
|
||||
input[type='search'],
|
||||
input[type='url'],
|
||||
input[type='tel'],
|
||||
input[type='number'],
|
||||
input[type='date'],
|
||||
input[type='time'],
|
||||
input[type='datetime-local'],
|
||||
textarea,
|
||||
select {
|
||||
/* 禁用浏览器默认样式 */
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
/* 禁用浏览器的颜色方案自动调整 */
|
||||
color-scheme: none;
|
||||
-webkit-color-scheme: none;
|
||||
}
|
||||
|
||||
/* 保护按钮外观 */
|
||||
button {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* 保护选中文本颜色 */
|
||||
::selection {
|
||||
background-color: rgba(79, 70, 229, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background-color: rgba(79, 70, 229, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 保护占位符颜色 */
|
||||
::placeholder {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 在深色模式偏好下保护表单元素 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
input[type='password'],
|
||||
input[type='search'],
|
||||
input[type='url'],
|
||||
input[type='tel'],
|
||||
input[type='number'],
|
||||
textarea,
|
||||
select {
|
||||
color-scheme: none;
|
||||
-webkit-color-scheme: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高对比度模式下的调整 */
|
||||
@media (prefers-contrast: high) {
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
button {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/* 页面过渡动画 */
|
||||
|
||||
/* 通用页面过渡 */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(1.02);
|
||||
}
|
||||
|
||||
.page-view {
|
||||
animation: page-view-enter 0.55s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
.page-view[data-route='index'] {
|
||||
animation-duration: 0.75s;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation-name: page-view-index-enter;
|
||||
}
|
||||
|
||||
@keyframes page-view-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-view-index-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.96);
|
||||
filter: blur(6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-view-mobile-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 首页特殊过渡 */
|
||||
.page-enter-active[data-route='index'],
|
||||
.page-leave-active[data-route='index'] {
|
||||
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.page-enter-from[data-route='index'] {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.96);
|
||||
filter: blur(6px);
|
||||
}
|
||||
|
||||
.page-leave-to[data-route='index'] {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px) scale(1.04);
|
||||
filter: blur(6px);
|
||||
}
|
||||
|
||||
/* 布局过渡 */
|
||||
.layout-enter-active,
|
||||
.layout-leave-active {
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.layout-enter-from,
|
||||
.layout-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.page-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: 3px solid #4f46e5;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 特殊效果:从首页到后台的滑动效果 */
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 特殊效果:从后台到首页的滑动效果 */
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-right-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 淡入淡出效果 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 缩放效果 */
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.scale-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition-duration: 0.3s;
|
||||
}
|
||||
|
||||
.page-view,
|
||||
.page-view[data-route='index'] {
|
||||
animation-duration: 0.3s;
|
||||
animation-name: page-view-mobile-enter;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition-duration: 0.3s;
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
transform: translateY(15px) scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
/* 减少动画效果(用户偏好) */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-enter-active,
|
||||
.page-leave-active,
|
||||
.layout-enter-active,
|
||||
.layout-leave-active,
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active,
|
||||
.scale-enter-active,
|
||||
.scale-leave-active,
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.page-view,
|
||||
.page-view[data-route='index'] {
|
||||
animation-duration: 0.1s;
|
||||
animation-name: page-view-reduced-enter;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-view-reduced-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
/* 全局CSS变量 - 统一配色方案 */
|
||||
:root {
|
||||
/* 主色调 */
|
||||
--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;
|
||||
}
|
||||
|
||||
/* 深色主题特定调整 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* 在深色模式下可以进行微调 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 高对比度模式支持 */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--border-primary: #444444;
|
||||
--border-secondary: #555555;
|
||||
--border-tertiary: #666666;
|
||||
--text-tertiary: #aaaaaa;
|
||||
--text-quaternary: #888888;
|
||||
}
|
||||
}
|
||||
|
||||
/* 减少动画模式支持 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--transition-fast: 0s;
|
||||
--transition-normal: 0s;
|
||||
--transition-slow: 0s;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局滚动条样式 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* 全局选择文本样式 */
|
||||
::selection {
|
||||
background: var(--primary-light);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 全局焦点样式 */
|
||||
*:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
*:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 全局禁用状态样式 */
|
||||
*:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 全局链接样式 */
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* 全局按钮重置 */
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
textarea {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
*:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
button,
|
||||
a {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局输入框重置 */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* 全局图片样式 */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 全局表格样式 */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 全局代码样式 */
|
||||
code,
|
||||
pre {
|
||||
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 2px 4px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: var(--spacing-lg);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/* Year Review Global Styles */
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reverse-spin {
|
||||
from {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-25%);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
50% {
|
||||
transform: none;
|
||||
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin 12s linear infinite;
|
||||
}
|
||||
|
||||
.animate-reverse-spin-slow {
|
||||
animation: reverse-spin 15s linear infinite;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-transparent {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.bg-clip-text {
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.backdrop-blur-md {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.backdrop-blur-sm {
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Gradients */
|
||||
.bg-gradient-intro {
|
||||
background: linear-gradient(135deg, #111827 0%, #000000 50%, #111827 100%);
|
||||
}
|
||||
|
||||
.bg-gradient-text-intro {
|
||||
background-image: linear-gradient(to right, #ffffff, #e5e7eb, #6b7280);
|
||||
}
|
||||
|
||||
.bg-gradient-artist {
|
||||
background: linear-gradient(to bottom right, #e9d5ff, #f472b6, #fb923c);
|
||||
}
|
||||
|
||||
.bg-gradient-numbers {
|
||||
background-image: linear-gradient(to bottom, #60a5fa, #1d4ed8);
|
||||
}
|
||||
|
||||
/* Slide Transitions */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active,
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
transition: all 0.5s ease-in-out;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(100%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(-20%); /* Parallax effect */
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.slide-down-enter-from {
|
||||
transform: translateY(-100%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.slide-down-leave-to {
|
||||
transform: translateY(20%); /* Parallax effect */
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Content Entry Animations */
|
||||
.entry-enter-active {
|
||||
transition: all 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.entry-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
}
|
||||
|
||||
.entry-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.delay-100 {
|
||||
transition-delay: 100ms;
|
||||
}
|
||||
.delay-200 {
|
||||
transition-delay: 200ms;
|
||||
}
|
||||
.delay-300 {
|
||||
transition-delay: 300ms;
|
||||
}
|
||||
.delay-400 {
|
||||
transition-delay: 400ms;
|
||||
}
|
||||
.delay-500 {
|
||||
transition-delay: 500ms;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 歌词渲染组件 - 基于 @applemusic-like-lyrics/core
|
||||
*/
|
||||
import {
|
||||
type BaseRenderer,
|
||||
LyricPlayer as CoreLyricPlayer,
|
||||
type LyricLine,
|
||||
type LyricLineMouseEvent,
|
||||
type LyricPlayerBase,
|
||||
type spring
|
||||
} from '@applemusic-like-lyrics/core'
|
||||
import {
|
||||
type PropType,
|
||||
type Ref,
|
||||
type ShallowRef,
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
watch
|
||||
} from 'vue'
|
||||
import '@applemusic-like-lyrics/core/style.css'
|
||||
|
||||
/**
|
||||
* Props 定义
|
||||
*/
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 是否禁用歌词播放组件
|
||||
*/
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* 是否演出部分效果
|
||||
*/
|
||||
playing: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* 设置歌词行的对齐方式
|
||||
*/
|
||||
alignAnchor: {
|
||||
type: String as PropType<'top' | 'bottom' | 'center'>,
|
||||
default: 'center'
|
||||
},
|
||||
/**
|
||||
* 设置默认的歌词行对齐位置
|
||||
*/
|
||||
alignPosition: {
|
||||
type: Number,
|
||||
default: 0.5
|
||||
},
|
||||
/**
|
||||
* 设置是否使用物理弹簧算法
|
||||
*/
|
||||
enableSpring: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* 设置是否启用歌词行的模糊效果
|
||||
*/
|
||||
enableBlur: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* 设置是否启用缩放效果
|
||||
*/
|
||||
enableScale: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* 设置是否隐藏已经播放过的歌词行
|
||||
*/
|
||||
hidePassedLines: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* 设置当前播放歌词
|
||||
*/
|
||||
lyricLines: {
|
||||
type: Object as PropType<LyricLine[]>,
|
||||
required: false
|
||||
},
|
||||
/**
|
||||
* 设置当前播放进度
|
||||
*/
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/**
|
||||
* 设置文字动画的渐变宽度
|
||||
*/
|
||||
wordFadeWidth: {
|
||||
type: Number,
|
||||
default: 0.5
|
||||
},
|
||||
/**
|
||||
* X 轴弹簧参数
|
||||
*/
|
||||
linePosXSpringParams: {
|
||||
type: Object as PropType<Partial<spring.SpringParams>>,
|
||||
required: false
|
||||
},
|
||||
/**
|
||||
* Y 轴弹簧参数
|
||||
*/
|
||||
linePosYSpringParams: {
|
||||
type: Object as PropType<Partial<spring.SpringParams>>,
|
||||
required: false
|
||||
},
|
||||
/**
|
||||
* 缩放弹簧参数
|
||||
*/
|
||||
lineScaleSpringParams: {
|
||||
type: Object as PropType<Partial<spring.SpringParams>>,
|
||||
required: false
|
||||
},
|
||||
/**
|
||||
* 渲染器类
|
||||
*/
|
||||
lyricPlayer: {
|
||||
type: Object as PropType<{
|
||||
new (...args: ConstructorParameters<typeof BaseRenderer>): BaseRenderer
|
||||
}>,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 歌词播放组件的事件
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
lineClick: [event: LyricLineMouseEvent]
|
||||
lineContextmenu: [event: LyricLineMouseEvent]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 歌词播放组件的引用
|
||||
*/
|
||||
export interface LyricPlayerRef {
|
||||
/**
|
||||
* 歌词播放实例
|
||||
*/
|
||||
lyricPlayer: Ref<LyricPlayerBase | undefined>
|
||||
/**
|
||||
* 将歌词播放实例的元素包裹起来的 DIV 元素实例
|
||||
*/
|
||||
wrapperEl: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
}
|
||||
|
||||
// 模板引用
|
||||
const wrapperRef = ref<HTMLDivElement | null>(null)
|
||||
// 歌词播放实例
|
||||
const playerRef = ref<CoreLyricPlayer>()
|
||||
|
||||
// 事件处理器
|
||||
const lineClickHandler = (e: Event) => emit('lineClick', e as LyricLineMouseEvent)
|
||||
const lineContextMenuHandler = (e: Event) => emit('lineContextmenu', e as LyricLineMouseEvent)
|
||||
|
||||
// 底部行元素 (用于 Teleport)
|
||||
const bottomLineEl = computed(() => playerRef.value?.getBottomLineElement())
|
||||
|
||||
// 组件挂载时初始化
|
||||
onMounted(() => {
|
||||
const wrapper = wrapperRef.value
|
||||
if (wrapper) {
|
||||
playerRef.value = new CoreLyricPlayer()
|
||||
wrapper.appendChild(playerRef.value.getElement())
|
||||
playerRef.value.addEventListener('line-click', lineClickHandler)
|
||||
playerRef.value.addEventListener('line-contextmenu', lineContextMenuHandler)
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
const player = playerRef.value
|
||||
if (player) {
|
||||
player.removeEventListener('line-click', lineClickHandler)
|
||||
player.removeEventListener('line-contextmenu', lineContextMenuHandler)
|
||||
player.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// 动画帧更新
|
||||
watchEffect((onCleanup) => {
|
||||
if (!props.disabled) {
|
||||
let canceled = false
|
||||
let lastTime = -1
|
||||
const onFrame = (time: number) => {
|
||||
if (canceled) return
|
||||
if (lastTime !== -1) {
|
||||
playerRef.value?.update(time - lastTime)
|
||||
}
|
||||
lastTime = time
|
||||
requestAnimationFrame(onFrame)
|
||||
}
|
||||
requestAnimationFrame(onFrame)
|
||||
onCleanup(() => {
|
||||
canceled = true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 播放/暂停状态
|
||||
watchEffect(() => {
|
||||
if (props.playing !== undefined) {
|
||||
if (props.playing) {
|
||||
playerRef.value?.resume()
|
||||
} else {
|
||||
playerRef.value?.pause()
|
||||
}
|
||||
} else playerRef.value?.resume()
|
||||
})
|
||||
|
||||
// 对齐锚点
|
||||
watchEffect(() => {
|
||||
if (props.alignAnchor !== undefined) playerRef.value?.setAlignAnchor(props.alignAnchor)
|
||||
})
|
||||
|
||||
// 隐藏已播放歌词行
|
||||
watchEffect(() => {
|
||||
if (props.hidePassedLines !== undefined)
|
||||
playerRef.value?.setHidePassedLines(props.hidePassedLines)
|
||||
})
|
||||
|
||||
// 对齐位置
|
||||
watchEffect(() => {
|
||||
if (props.alignPosition !== undefined) playerRef.value?.setAlignPosition(props.alignPosition)
|
||||
})
|
||||
|
||||
// 弹簧动画
|
||||
watchEffect(() => {
|
||||
playerRef.value?.setEnableSpring(props.enableSpring)
|
||||
})
|
||||
|
||||
// 模糊效果
|
||||
watchEffect(() => {
|
||||
playerRef.value?.setEnableBlur(props.enableBlur)
|
||||
})
|
||||
|
||||
// 缩放效果
|
||||
watchEffect(() => {
|
||||
playerRef.value?.setEnableScale(props.enableScale)
|
||||
})
|
||||
|
||||
// 歌词行数据
|
||||
watchEffect(() => {
|
||||
if (props.lyricLines !== undefined) playerRef.value?.setLyricLines(props.lyricLines)
|
||||
})
|
||||
|
||||
// 当前播放时间
|
||||
watch(
|
||||
() => props.currentTime,
|
||||
(time, oldTime) => {
|
||||
if (time === undefined) return
|
||||
const isSeek = oldTime !== undefined && Math.abs(time - oldTime) > 1000
|
||||
playerRef.value?.setCurrentTime(time, isSeek)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 渐变宽度
|
||||
watchEffect(() => {
|
||||
if (props.wordFadeWidth !== undefined) playerRef.value?.setWordFadeWidth(props.wordFadeWidth)
|
||||
})
|
||||
|
||||
// X 轴弹簧参数
|
||||
watchEffect(() => {
|
||||
if (props.linePosXSpringParams !== undefined)
|
||||
playerRef.value?.setLinePosXSpringParams(props.linePosXSpringParams)
|
||||
})
|
||||
|
||||
// Y 轴弹簧参数
|
||||
watchEffect(() => {
|
||||
if (props.linePosYSpringParams !== undefined)
|
||||
playerRef.value?.setLinePosYSpringParams(props.linePosYSpringParams)
|
||||
})
|
||||
|
||||
// 缩放弹簧参数
|
||||
watchEffect(() => {
|
||||
if (props.lineScaleSpringParams !== undefined)
|
||||
playerRef.value?.setLineScaleSpringParams(props.lineScaleSpringParams)
|
||||
})
|
||||
|
||||
// 暴露给父组件
|
||||
defineExpose<LyricPlayerRef>({
|
||||
lyricPlayer: playerRef as unknown as Ref<LyricPlayerBase | undefined>,
|
||||
wrapperEl: wrapperRef as unknown as Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapperRef" class="amll-wrapper">
|
||||
<Teleport v-if="bottomLineEl" :to="bottomLineEl">
|
||||
<slot name="bottom-line" />
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.amll-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,985 @@
|
||||
<template>
|
||||
<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-[60] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh]"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-purple-600/10 flex items-center justify-center text-purple-500"
|
||||
>
|
||||
<Layers :size="20" />
|
||||
</div>
|
||||
批量更新学生信息
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">
|
||||
快速调整学生年级、班级或通过 Excel 批量修改
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-xl transition-all"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8 pt-6 custom-scrollbar space-y-8">
|
||||
<!-- 更新方式选择 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<label
|
||||
:class="[
|
||||
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
|
||||
updateType === 'grade-only'
|
||||
? 'bg-purple-500/5 border-purple-500/50 ring-4 ring-purple-500/10'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
>
|
||||
<input v-model="updateType" type="radio" value="grade-only" class="sr-only" >
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
|
||||
updateType === 'grade-only'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
|
||||
]"
|
||||
>
|
||||
<Calendar :size="18" />
|
||||
</div>
|
||||
<div
|
||||
v-if="updateType === 'grade-only'"
|
||||
class="w-5 h-5 rounded-full bg-purple-500 flex items-center justify-center"
|
||||
>
|
||||
<Check :size="12" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
|
||||
>仅更新年级</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
|
||||
>批量更新选中学生的年级,保持班级不变</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<label
|
||||
:class="[
|
||||
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
|
||||
updateType === 'excel-batch'
|
||||
? 'bg-emerald-500/5 border-emerald-500/50 ring-4 ring-emerald-500/10'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
>
|
||||
<input v-model="updateType" type="radio" value="excel-batch" class="sr-only" >
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
|
||||
updateType === 'excel-batch'
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
|
||||
]"
|
||||
>
|
||||
<FileSpreadsheet :size="18" />
|
||||
</div>
|
||||
<div
|
||||
v-if="updateType === 'excel-batch'"
|
||||
class="w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center"
|
||||
>
|
||||
<Check :size="12" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
|
||||
>Excel 批量更新</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
|
||||
>通过 Excel 文件精确匹配并批量修改学生信息</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<label
|
||||
:class="[
|
||||
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
|
||||
updateType === 'status-batch'
|
||||
? 'bg-amber-500/5 border-amber-500/50 ring-4 ring-amber-500/10'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
>
|
||||
<input v-model="updateType" type="radio" value="status-batch" class="sr-only" >
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
|
||||
updateType === 'status-batch'
|
||||
? 'bg-amber-500 text-white'
|
||||
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
|
||||
]"
|
||||
>
|
||||
<ShieldAlert :size="18" />
|
||||
</div>
|
||||
<div
|
||||
v-if="updateType === 'status-batch'"
|
||||
class="w-5 h-5 rounded-full bg-amber-500 flex items-center justify-center"
|
||||
>
|
||||
<Check :size="12" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
|
||||
>设置账户状态</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
|
||||
>批量设置选中学生的账户状态</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 学生选择面板 (年级更新和状态更新共用) -->
|
||||
<div
|
||||
v-if="['grade-only', 'status-batch'].includes(updateType)"
|
||||
class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
<div class="p-6 bg-zinc-950/50 border border-zinc-800/50 rounded-xl space-y-6">
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest"
|
||||
>
|
||||
<Filter :size="14" class="text-purple-500" />
|
||||
学生范围筛选
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>当前年级</label
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="gradeFilter"
|
||||
:options="gradeOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="全部年级"
|
||||
class-name="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>当前班级</label
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="classFilter"
|
||||
:options="classOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="全部班级"
|
||||
class-name="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between ml-1">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||
>选择学生 ({{ selectedUserIds.length }}/{{ filteredStudents.length }})</label
|
||||
>
|
||||
<button
|
||||
class="text-[10px] font-black text-purple-400 hover:text-purple-300 uppercase tracking-widest transition-colors"
|
||||
@click="toggleSelectAll"
|
||||
>
|
||||
{{ isAllSelected ? '取消全选' : '选择当前全部' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-48 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-950 p-2 custom-scrollbar"
|
||||
>
|
||||
<div
|
||||
v-if="filteredStudents.length === 0"
|
||||
class="py-10 text-center text-xs text-zinc-600 font-medium"
|
||||
>
|
||||
没有匹配条件的学生
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-1">
|
||||
<label
|
||||
v-for="student in filteredStudents"
|
||||
:key="student.id"
|
||||
class="flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-900/50 cursor-pointer transition-colors group"
|
||||
>
|
||||
<input
|
||||
v-model="selectedUserIds"
|
||||
:value="student.id"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded-md border-zinc-700 bg-zinc-950 text-purple-600 focus:ring-purple-500/20"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-xs font-bold text-zinc-200 group-hover:text-purple-400 transition-colors"
|
||||
>{{ student.name }}</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-600 font-mono">{{
|
||||
student.username
|
||||
}}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 目标年级设置 -->
|
||||
<div v-if="updateType === 'grade-only'" class="p-6 bg-purple-500/5 border border-purple-500/20 rounded-xl space-y-6">
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs font-black text-purple-400 uppercase tracking-widest"
|
||||
>
|
||||
<Save :size="14" />
|
||||
目标设置
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>目标年级</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<Calendar
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700 group-focus-within:text-purple-500 transition-colors"
|
||||
:size="16"
|
||||
/>
|
||||
<input
|
||||
v-model="targetGrade"
|
||||
type="text"
|
||||
placeholder="例如: 2025"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-purple-500/30 transition-all text-zinc-200"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="flex items-center gap-3 p-3 bg-zinc-950 border border-zinc-800 rounded-lg cursor-pointer hover:border-zinc-700 transition-all"
|
||||
>
|
||||
<input
|
||||
v-model="keepClass"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded-md border-zinc-700 bg-zinc-950 text-purple-600 focus:ring-purple-500/20"
|
||||
>
|
||||
<span class="text-xs font-bold text-zinc-300">保持原有班级不变</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 目标状态设置 -->
|
||||
<div v-if="updateType === 'status-batch'" class="p-6 bg-amber-500/5 border border-amber-500/20 rounded-xl space-y-6">
|
||||
<div class="flex items-center gap-2 text-xs font-black text-amber-400 uppercase tracking-widest">
|
||||
<Save :size="14" />
|
||||
目标状态设置
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1">选择账户状态</label>
|
||||
<CustomSelect
|
||||
v-model="targetStatus"
|
||||
:options="statusOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="请选择目标状态"
|
||||
class-name="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1">变更原因说明</label>
|
||||
<div class="relative group">
|
||||
<MessageSquare class="absolute left-4 top-3 text-zinc-700 group-focus-within:text-amber-500 transition-colors" :size="16" />
|
||||
<textarea
|
||||
v-model="statusReason"
|
||||
rows="2"
|
||||
placeholder="例如: 2025届学生统一毕业"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-500/30 transition-all text-zinc-200 resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Excel 批量更新面板 -->
|
||||
<div
|
||||
v-if="updateType === 'excel-batch'"
|
||||
class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
<!-- 文件上传区 -->
|
||||
<div
|
||||
:class="[
|
||||
'relative group cursor-pointer transition-all',
|
||||
isDragOver ? 'scale-[0.99]' : ''
|
||||
]"
|
||||
@drop="handleDrop"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@click="$refs.fileInput.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
accept=".xlsx,.xls"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'w-full py-12 border-2 border-dashed rounded-xl transition-all flex flex-col items-center justify-center gap-4',
|
||||
isDragOver
|
||||
? 'border-emerald-500 bg-emerald-500/10'
|
||||
: 'border-zinc-800 hover:border-emerald-500/50 hover:bg-emerald-500/5'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'w-16 h-16 rounded-lg bg-zinc-900 flex items-center justify-center transition-colors shadow-xl',
|
||||
isDragOver ? 'text-emerald-500' : 'text-zinc-600 group-hover:text-emerald-500'
|
||||
]"
|
||||
>
|
||||
<Upload :size="32" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-base font-black text-zinc-200 tracking-tight">
|
||||
拖拽 Excel 文件到此处
|
||||
</p>
|
||||
<p class="text-xs text-zinc-500 mt-1">
|
||||
或 <span class="text-emerald-500 font-bold">点击选择文件</span> (支持 .xlsx /
|
||||
.xls)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模板与说明 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="md:col-span-2 p-5 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||
>
|
||||
<Info :size="12" />
|
||||
文件格式规范
|
||||
</div>
|
||||
<ul class="text-[10px] text-zinc-600 space-y-1.5 font-medium leading-relaxed">
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
|
||||
第一行为表头:用户名、姓名、年级、班级、新用户名
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
|
||||
<span class="text-zinc-400 font-bold">用户名</span> 列用于精确匹配现有账户
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
|
||||
字段留空则保持原值不变,不进行覆盖更新
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
class="p-5 bg-emerald-500/5 border border-emerald-500/20 hover:border-emerald-500/40 rounded-xl transition-all flex flex-col items-center justify-center gap-2 group"
|
||||
@click="downloadTemplate"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-emerald-500 text-white flex items-center justify-center shadow-lg shadow-emerald-900/20 group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<Download :size="20" />
|
||||
</div>
|
||||
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-widest"
|
||||
>获取模板文件</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 预览表格 -->
|
||||
<div v-if="excelPreviewData.length > 0" class="space-y-4">
|
||||
<div class="flex items-center justify-between ml-1">
|
||||
<label class="text-xs font-black text-zinc-400 uppercase tracking-widest"
|
||||
>数据预览 ({{ excelPreviewData.length }}条)</label
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
<span class="text-[10px] text-zinc-500 font-bold">待更新</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span class="text-[10px] text-zinc-500 font-bold">错误</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-950 overflow-hidden shadow-xl">
|
||||
<div class="overflow-x-auto custom-scrollbar">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead
|
||||
class="bg-zinc-900/80 text-[10px] font-black text-zinc-500 uppercase tracking-widest border-b border-zinc-800"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-5 py-4 whitespace-nowrap">匹配用户</th>
|
||||
<th class="px-5 py-4 whitespace-nowrap">当前信息</th>
|
||||
<th class="px-5 py-4 whitespace-nowrap">更新后</th>
|
||||
<th class="px-5 py-4 whitespace-nowrap text-right">匹配状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-900">
|
||||
<tr
|
||||
v-for="(row, index) in excelPreviewData.slice(0, 10)"
|
||||
:key="index"
|
||||
:class="[
|
||||
row.error ? 'bg-red-500/5' : 'hover:bg-zinc-900/30 transition-colors'
|
||||
]"
|
||||
>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
:class="[
|
||||
'text-xs font-bold',
|
||||
row.error ? 'text-red-400' : 'text-zinc-200'
|
||||
]"
|
||||
>{{ row.username }}</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-600 font-medium">{{
|
||||
row.name || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="text-[10px] text-zinc-500 font-medium">
|
||||
{{ row.currentGrade || '-' }}年级 {{ row.currentClass || '-' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-bold text-emerald-400">{{
|
||||
row.newGrade || row.currentGrade || '-'
|
||||
}}</span>
|
||||
<span class="text-[10px] text-zinc-700">/</span>
|
||||
<span class="text-xs font-bold text-emerald-400">{{
|
||||
row.newClass || row.currentClass || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-right">
|
||||
<span
|
||||
v-if="row.error"
|
||||
class="px-2 py-0.5 bg-red-500/10 text-red-500 text-[10px] font-black rounded uppercase tracking-tighter border border-red-500/20"
|
||||
>
|
||||
{{ row.error }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-0.5 bg-emerald-500/10 text-emerald-500 text-[10px] font-black rounded uppercase tracking-tighter border border-emerald-500/20"
|
||||
>
|
||||
已就绪
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="excelPreviewData.length > 10"
|
||||
class="p-4 text-center border-t border-zinc-900 bg-zinc-900/20 text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
|
||||
>
|
||||
以及另外 {{ excelPreviewData.length - 10 }} 条记录已在队列中
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-400 text-xs animate-in shake duration-300"
|
||||
>
|
||||
<AlertCircle :size="16" />
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="p-8 pt-4 border-t border-zinc-800/50 bg-zinc-900/50 flex gap-3">
|
||||
<button
|
||||
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消操作
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading || !canUpdate"
|
||||
:class="[
|
||||
'flex-[2] px-6 py-4 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg active:scale-95',
|
||||
updateType === 'excel-batch'
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-900/20'
|
||||
: updateType === 'status-batch'
|
||||
? 'bg-amber-600 hover:bg-amber-500 shadow-amber-900/20'
|
||||
: 'bg-purple-600 hover:bg-purple-500 shadow-purple-900/20'
|
||||
]"
|
||||
@click="performUpdate"
|
||||
>
|
||||
<RefreshCw v-if="loading" class="animate-spin" :size="16" />
|
||||
<Save v-else :size="16" />
|
||||
{{ loading ? '正在提交更新...' : '确认并开始更新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import {
|
||||
Layers,
|
||||
X,
|
||||
Calendar,
|
||||
FileSpreadsheet,
|
||||
Check,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
Save,
|
||||
Upload,
|
||||
Download,
|
||||
Info,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
ShieldAlert,
|
||||
MessageSquare
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
users: Array
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'update-success'])
|
||||
|
||||
// 响应式数据
|
||||
const updateType = ref('grade-only')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 仅更新年级相关
|
||||
const gradeFilter = ref('')
|
||||
const classFilter = ref('')
|
||||
const selectedUserIds = ref([])
|
||||
const targetGrade = ref('')
|
||||
const keepClass = ref(true)
|
||||
|
||||
// 状态批量更新相关
|
||||
const targetStatus = ref('')
|
||||
const statusReason = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '正常访问', value: 'active' },
|
||||
{ label: '限制访问 (毕业生)', value: 'graduate' },
|
||||
{ label: '限制访问 (退学)', value: 'withdrawn' }
|
||||
]
|
||||
|
||||
// Excel批量更新相关
|
||||
const isDragOver = ref(false)
|
||||
const excelPreviewData = ref([])
|
||||
const fileInput = ref(null)
|
||||
|
||||
// 所有用户的年级班级信息
|
||||
const allGrades = ref([])
|
||||
const allClasses = ref([])
|
||||
// 所有学生用户数据
|
||||
const allStudents = ref([])
|
||||
|
||||
// 服务
|
||||
const auth = useAuth()
|
||||
|
||||
// 计算属性
|
||||
const students = computed(() => {
|
||||
return allStudents.value.length > 0
|
||||
? allStudents.value
|
||||
: props.users.filter((user) => user.role === 'USER')
|
||||
})
|
||||
|
||||
const availableGrades = computed(() => {
|
||||
return allGrades.value.length > 0
|
||||
? allGrades.value
|
||||
: [...new Set(students.value.map((s) => s.grade).filter(Boolean))].sort()
|
||||
})
|
||||
|
||||
const availableClasses = computed(() => {
|
||||
return allClasses.value.length > 0
|
||||
? allClasses.value
|
||||
: [...new Set(students.value.map((s) => s.class).filter(Boolean))].sort()
|
||||
})
|
||||
|
||||
const gradeOptions = computed(() => {
|
||||
return [
|
||||
{ label: '全部年级', value: '' },
|
||||
...availableGrades.value.map((g) => ({ label: g, value: g }))
|
||||
]
|
||||
})
|
||||
|
||||
const classOptions = computed(() => {
|
||||
return [
|
||||
{ label: '全部班级', value: '' },
|
||||
...availableClasses.value.map((c) => ({ label: c, value: c }))
|
||||
]
|
||||
})
|
||||
|
||||
const filteredStudents = computed(() => {
|
||||
let filtered = students.value
|
||||
|
||||
if (gradeFilter.value) {
|
||||
filtered = filtered.filter((s) => s.grade === gradeFilter.value)
|
||||
}
|
||||
|
||||
if (classFilter.value) {
|
||||
filtered = filtered.filter((s) => s.class === classFilter.value)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return (
|
||||
filteredStudents.value.length > 0 &&
|
||||
selectedUserIds.value.length === filteredStudents.value.length
|
||||
)
|
||||
})
|
||||
|
||||
const canUpdate = computed(() => {
|
||||
if (updateType.value === 'grade-only') {
|
||||
return selectedUserIds.value.length > 0 && targetGrade.value.trim()
|
||||
} else if (updateType.value === 'excel-batch') {
|
||||
return excelPreviewData.value.length > 0 && excelPreviewData.value.some((row) => !row.error)
|
||||
} else if (updateType.value === 'status-batch') {
|
||||
return selectedUserIds.value.length > 0 && targetStatus.value && statusReason.value.trim()
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// 方法
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
selectedUserIds.value = []
|
||||
} else {
|
||||
selectedUserIds.value = filteredStudents.value.map((s) => s.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file) {
|
||||
processExcelFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && (file.name.endsWith('.xlsx') || file.name.endsWith('.xls'))) {
|
||||
processExcelFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const processExcelFile = async (file) => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
// 确保学生数据已加载
|
||||
if (students.value.length === 0) {
|
||||
console.log('学生数据为空,重新获取数据...')
|
||||
await fetchAllStudents()
|
||||
// 等待一小段时间确保数据更新
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
// 动态加载XLSX库
|
||||
if (typeof window.XLSX === 'undefined') {
|
||||
await loadXLSX()
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target.result)
|
||||
const workbook = window.XLSX.read(data, { type: 'array' })
|
||||
const sheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[sheetName]
|
||||
const jsonData = window.XLSX.utils.sheet_to_json(worksheet)
|
||||
|
||||
parseExcelData(jsonData)
|
||||
} catch (parseError) {
|
||||
console.error('解析Excel文件失败:', parseError)
|
||||
error.value = 'Excel文件格式错误,请检查文件格式'
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
console.error('读取文件失败')
|
||||
error.value = '读取文件失败,请重试'
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
reader.readAsArrayBuffer(file)
|
||||
} catch (err) {
|
||||
console.error('处理Excel文件失败:', err)
|
||||
const errorMessage = err && err.message ? err.message : '未知错误,请检查文件格式或网络连接'
|
||||
error.value = '处理Excel文件失败: ' + errorMessage
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseExcelData = (jsonData) => {
|
||||
const previewData = []
|
||||
const userMap = new Map()
|
||||
|
||||
// 创建用户映射,同时处理用户名标准化
|
||||
students.value.forEach((user) => {
|
||||
if (user.username) {
|
||||
const normalizedUsername = user.username.trim().toLowerCase()
|
||||
userMap.set(normalizedUsername, user)
|
||||
userMap.set(user.username, user)
|
||||
}
|
||||
})
|
||||
|
||||
jsonData.forEach((row, index) => {
|
||||
const username = (row['用户名'] || row['username'] || '').toString().trim()
|
||||
const name = row['姓名'] || row['name'] || ''
|
||||
const newGrade = row['年级'] || row['grade'] ? String(row['年级'] || row['grade']).trim() : ''
|
||||
const newClass = row['班级'] || row['class'] ? String(row['班级'] || row['class']).trim() : ''
|
||||
const newUsername = row['新用户名'] || row['new_username'] || ''
|
||||
|
||||
if (!username) {
|
||||
previewData.push({
|
||||
username: '',
|
||||
name: name,
|
||||
newGrade: newGrade,
|
||||
newClass: newClass,
|
||||
newUsername: newUsername,
|
||||
error: '用户名不能为空'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const existingUser =
|
||||
userMap.get(username) ||
|
||||
userMap.get(username.toLowerCase()) ||
|
||||
userMap.get(username.toUpperCase())
|
||||
|
||||
if (!existingUser) {
|
||||
previewData.push({
|
||||
username: username,
|
||||
name: name,
|
||||
newGrade: newGrade,
|
||||
newClass: newClass,
|
||||
newUsername: newUsername,
|
||||
error: '用户不存在'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
previewData.push({
|
||||
userId: existingUser.id,
|
||||
username: username,
|
||||
name: existingUser.name,
|
||||
currentGrade: existingUser.grade,
|
||||
currentClass: existingUser.class,
|
||||
newGrade: newGrade,
|
||||
newClass: newClass,
|
||||
newUsername: newUsername
|
||||
})
|
||||
})
|
||||
|
||||
excelPreviewData.value = previewData
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const loadXLSX = async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof window.XLSX !== 'undefined') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js'
|
||||
script.onload = resolve
|
||||
script.onerror = (error) => {
|
||||
console.error('加载XLSX库失败:', error)
|
||||
reject(new Error('无法加载Excel处理库,请检查网络连接'))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const templateData = [
|
||||
{ 用户名: 'student001', 姓名: '张三', 年级: '2025', 班级: '1班', 新用户名: 'new_student001' },
|
||||
{ 用户名: 'student002', 姓名: '李四', 年级: '2025', 班级: '2班', 新用户名: '' }
|
||||
]
|
||||
|
||||
const ws = window.XLSX.utils.json_to_sheet(templateData)
|
||||
const wb = window.XLSX.utils.book_new()
|
||||
window.XLSX.utils.book_append_sheet(wb, ws, '学生信息')
|
||||
window.XLSX.writeFile(wb, '学生信息批量更新模板.xlsx')
|
||||
}
|
||||
|
||||
const performUpdate = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
if (updateType.value === 'grade-only') {
|
||||
await performGradeUpdate()
|
||||
} else if (updateType.value === 'excel-batch') {
|
||||
await performExcelUpdate()
|
||||
} else if (updateType.value === 'status-batch') {
|
||||
await performStatusUpdate()
|
||||
}
|
||||
|
||||
emit('update-success')
|
||||
emit('close')
|
||||
} catch (err) {
|
||||
console.error('批量更新失败:', err)
|
||||
error.value = '批量更新失败: ' + err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const performGradeUpdate = async () => {
|
||||
const response = await $fetch('/api/admin/users/batch-grade-update', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
userIds: selectedUserIds.value,
|
||||
targetGrade: targetGrade.value.trim(),
|
||||
keepClass: keepClass.value
|
||||
},
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || '批量更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const performExcelUpdate = async () => {
|
||||
const validUpdates = excelPreviewData.value.filter((row) => !row.error && row.userId)
|
||||
|
||||
if (validUpdates.length === 0) {
|
||||
throw new Error('没有有效的更新数据')
|
||||
}
|
||||
|
||||
// 分批更新,避免请求过大
|
||||
const batchSize = 50
|
||||
for (let i = 0; i < validUpdates.length; i += batchSize) {
|
||||
const batch = validUpdates.slice(i, i + batchSize)
|
||||
const updates = batch.map((row) => ({
|
||||
userId: row.userId,
|
||||
grade: row.newGrade ? String(row.newGrade).trim() : undefined,
|
||||
class: row.newClass ? String(row.newClass).trim() : undefined,
|
||||
username: row.newUsername ? String(row.newUsername).trim() : undefined
|
||||
}))
|
||||
|
||||
await $fetch('/api/admin/users/batch-update', {
|
||||
method: 'POST',
|
||||
body: { updates },
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const performStatusUpdate = async () => {
|
||||
const response = await $fetch('/api/admin/users/batch-status', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
userIds: selectedUserIds.value,
|
||||
status: targetStatus.value,
|
||||
reason: statusReason.value.trim()
|
||||
},
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || '批量更新状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有学生用户数据
|
||||
const fetchAllStudents = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/admin/users', {
|
||||
method: 'GET',
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 10000,
|
||||
role: 'USER'
|
||||
},
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
|
||||
if (response.success && response.users) {
|
||||
const users = response.users
|
||||
allStudents.value = users
|
||||
|
||||
const grades = [...new Set(users.map((u) => u.grade).filter(Boolean))].sort()
|
||||
const classes = [...new Set(users.map((u) => u.class).filter(Boolean))].sort()
|
||||
|
||||
allGrades.value = grades
|
||||
allClasses.value = classes
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取所有学生数据失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听显示状态
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
fetchAllStudents()
|
||||
// 重置状态
|
||||
selectedUserIds.value = []
|
||||
excelPreviewData.value = []
|
||||
targetStatus.value = ''
|
||||
statusReason.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
</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>
|
||||
@@ -0,0 +1,575 @@
|
||||
<template>
|
||||
<div
|
||||
class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2 animate-in fade-in slide-in-from-bottom-4 duration-700"
|
||||
>
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
歌曲黑名单管理
|
||||
</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1 font-medium">
|
||||
禁止特定歌曲或包含关键词的歌曲被点播,维护广播内容的健康与合规
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加黑名单项表单 -->
|
||||
<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-zinc-600 uppercase tracking-widest px-1"
|
||||
>封禁类型</label
|
||||
>
|
||||
<div class="flex p-1 bg-zinc-950 border border-zinc-800 rounded-lg">
|
||||
<button
|
||||
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" /> 具体歌曲
|
||||
</button>
|
||||
<button
|
||||
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" /> 关键词
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="xl:col-span-4 space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
|
||||
{{ newItem.type === 'SONG' ? '歌曲名称 - 艺术家' : '关键词内容' }}
|
||||
</label>
|
||||
<input
|
||||
v-model="newItem.value"
|
||||
type="text"
|
||||
:placeholder="
|
||||
newItem.type === 'SONG' ? '例如: 歌曲名 - 艺术家' : '例如: 敏感词、特定风格'
|
||||
"
|
||||
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-4 space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>封禁原因 (可选)</label
|
||||
>
|
||||
<input
|
||||
v-model="newItem.reason"
|
||||
type="text"
|
||||
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-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" />
|
||||
<div
|
||||
v-else
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
/>
|
||||
{{ loading ? '处理中...' : '添加到黑名单' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<div
|
||||
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-zinc-700" :size="18" />
|
||||
<input
|
||||
v-model="filters.search"
|
||||
type="text"
|
||||
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="类型筛选"
|
||||
:value="typeFilterLabel"
|
||||
:options="['全部类型', '具体歌曲', '关键词']"
|
||||
class="flex-1 md:w-48"
|
||||
@change="handleTypeFilterChange"
|
||||
/>
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 黑名单列表 -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<h4 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
|
||||
黑名单列表 ({{ pagination.total }} 项)
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<TransitionGroup
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in absolute w-full"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<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-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="[
|
||||
item.type === 'SONG'
|
||||
? 'bg-blue-600/10 text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/5'
|
||||
: 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5'
|
||||
]"
|
||||
>
|
||||
<Music v-if="item.type === 'SONG'" :size="22" />
|
||||
<Type 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="[
|
||||
item.type === 'SONG'
|
||||
? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
|
||||
: 'bg-purple-600/10 text-purple-400 border-purple-500/20'
|
||||
]"
|
||||
>
|
||||
{{ item.type === 'SONG' ? '具体歌曲' : '关键词' }}
|
||||
</span>
|
||||
<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-zinc-500 font-bold text-[11px]">
|
||||
<MessageSquare :size="12" class="text-zinc-700" />
|
||||
原因: {{ item.reason || '未填写原因' }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 text-zinc-600 font-bold text-[10px] uppercase tracking-tighter"
|
||||
>
|
||||
<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-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-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 ? '禁用' : '启用' }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading && blacklist.length === 0"
|
||||
class="py-20 flex flex-col items-center justify-center space-y-4"
|
||||
>
|
||||
<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-zinc-900/10 border border-zinc-800/40 border-dashed rounded-xl"
|
||||
>
|
||||
<div
|
||||
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-zinc-600">未找到匹配的黑名单项</h6>
|
||||
<p class="text-[10px] text-zinc-700 font-bold uppercase tracking-widest">
|
||||
请尝试调整筛选条件或添加新项目
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<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-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-zinc-900/50 border border-zinc-800 rounded-lg text-[11px] font-black text-zinc-400 uppercase tracking-widest"
|
||||
>
|
||||
第 {{ pagination.page }} 页 / 共 {{ pagination.pages }} 页
|
||||
</div>
|
||||
<button
|
||||
:disabled="pagination.page >= pagination.pages || loading"
|
||||
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" />
|
||||
</button>
|
||||
</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="showDeleteDialog"
|
||||
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-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-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-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-zinc-800 hover:bg-zinc-700 text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase tracking-widest"
|
||||
@click="showDeleteDialog = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
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 ? '删除中...' : '确认移除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, computed } from 'vue'
|
||||
import {
|
||||
ShieldAlert,
|
||||
Music,
|
||||
Type,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
Power,
|
||||
Trash2,
|
||||
Ban,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
|
||||
const blacklist = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
// 删除对话框相关
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTargetItem = ref(null)
|
||||
|
||||
const newItem = reactive({
|
||||
type: 'SONG',
|
||||
value: '',
|
||||
reason: ''
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
type: '',
|
||||
search: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const typeFilterLabel = computed(() => {
|
||||
if (filters.type === 'SONG') return '具体歌曲'
|
||||
if (filters.type === 'KEYWORD') return '关键词'
|
||||
return '全部类型'
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// 加载黑名单
|
||||
const loadBlacklist = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
...(filters.type && { type: filters.type }),
|
||||
...(filters.search && { search: filters.search })
|
||||
})
|
||||
|
||||
const response = await $fetch(`/api/admin/blacklist?${params}`, {
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
|
||||
blacklist.value = response.blacklist
|
||||
Object.assign(pagination, response.pagination)
|
||||
} catch (err) {
|
||||
error.value = '获取黑名单失败'
|
||||
console.error('获取黑名单失败:', err)
|
||||
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 filters.type = ''
|
||||
|
||||
pagination.page = 1
|
||||
loadBlacklist()
|
||||
}
|
||||
|
||||
// 添加黑名单项
|
||||
const addBlacklistItem = async () => {
|
||||
if (!newItem.value.trim()) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/admin/blacklist', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: newItem.type,
|
||||
value: newItem.value.trim(),
|
||||
reason: newItem.reason.trim() || null
|
||||
},
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
|
||||
showNotification('黑名单项添加成功', 'success')
|
||||
|
||||
// 重置表单
|
||||
newItem.value = ''
|
||||
newItem.reason = ''
|
||||
|
||||
// 重新加载列表
|
||||
pagination.page = 1
|
||||
await loadBlacklist()
|
||||
} catch (err) {
|
||||
error.value = err.data?.message || '添加黑名单项失败'
|
||||
console.error('添加黑名单项失败:', err)
|
||||
showNotification('添加失败: ' + error.value, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换项目状态
|
||||
const toggleItemStatus = async (item) => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/admin/blacklist/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
isActive: !item.isActive
|
||||
},
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
|
||||
item.isActive = !item.isActive
|
||||
showNotification(`黑名单项已${item.isActive ? '启用' : '禁用'}`, 'success')
|
||||
} catch (err) {
|
||||
error.value = '更新状态失败'
|
||||
console.error('更新状态失败:', err)
|
||||
showNotification('更新状态失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
const deleteItem = (item) => {
|
||||
deleteTargetItem.value = item
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetItem.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/admin/blacklist/${deleteTargetItem.value.id}`, {
|
||||
method: 'DELETE',
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
|
||||
showNotification('黑名单项删除成功', 'success')
|
||||
await loadBlacklist()
|
||||
} catch (err) {
|
||||
error.value = '删除失败'
|
||||
console.error('删除失败:', err)
|
||||
showNotification('删除失败: ' + err.message, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
showDeleteDialog.value = false
|
||||
deleteTargetItem.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索防抖
|
||||
const debounceSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadBlacklist()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 切换页面
|
||||
const changePage = (page) => {
|
||||
pagination.page = page
|
||||
loadBlacklist()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBlacklist()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animate-in {
|
||||
animation-duration: 0.5s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保持功能 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,883 @@
|
||||
<template>
|
||||
<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-zinc-100 tracking-tight">数据库操作</h2>
|
||||
<p class="text-xs text-zinc-500 font-medium">
|
||||
执行系统底层维护任务,包括备份、恢复及全局数据重置
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作卡片网格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
:class="[
|
||||
'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-zinc-950 border border-zinc-800 transition-all',
|
||||
card.isDanger
|
||||
? '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-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-zinc-100 group-hover:text-blue-400 transition-colors">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<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-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-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)">执行中...</span>
|
||||
<span v-else>{{ card.btnText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 背景装饰 -->
|
||||
<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-rose-500/5' : `bg-${card.color}-500/5`
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 维护建议 -->
|
||||
<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-zinc-300">数据库维护建议</p>
|
||||
<p class="text-[10px] text-zinc-500 leading-relaxed">
|
||||
建议每周进行一次全量备份。在执行“恢复备份”或“重置数据库”前,请务必先创建一份当前的数据备份,以免误操作造成不可挽回的损失。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建备份模态框 -->
|
||||
<div
|
||||
v-if="activeModal === 'backup'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
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-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-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="space-y-2">
|
||||
<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-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-zinc-800 bg-zinc-900 accent-blue-600"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="text-xs font-bold text-zinc-200 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ item.label }}
|
||||
</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-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-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="createLoading"
|
||||
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 ? '正在导出...' : '开始导出' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 恢复备份模态框 -->
|
||||
<div
|
||||
v-if="activeModal === 'restore'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
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-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-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div
|
||||
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-zinc-700 mb-4 group-hover:text-emerald-500 transition-colors"
|
||||
/>
|
||||
<h5 class="text-sm font-bold text-zinc-300">
|
||||
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
|
||||
</h5>
|
||||
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
|
||||
仅支持 VoiceHub 导出的 .json 格式
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<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-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-emerald-400' : 'text-zinc-500'
|
||||
]"
|
||||
>
|
||||
增量模式
|
||||
</h6>
|
||||
<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-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-emerald-400' : 'text-zinc-500'
|
||||
]"
|
||||
>
|
||||
覆盖模式
|
||||
</h6>
|
||||
<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-zinc-950 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="restoreForm.overwriteSuperAdmin"
|
||||
type="checkbox"
|
||||
class="mt-0.5 accent-emerald-500"
|
||||
>
|
||||
<div>
|
||||
<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-amber-500/5 border border-amber-500/10 rounded-xl flex items-start gap-3"
|
||||
>
|
||||
<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-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="uploadLoading || !selectedFile"
|
||||
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 || '正在恢复...' : '确认并开始恢复' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置序列模态框 -->
|
||||
<div
|
||||
v-if="activeModal === 'reset-seq'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
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-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-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>选择目标表</label
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="sequenceForm.table"
|
||||
:options="tableOptions"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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-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-zinc-300 uppercase tracking-widest">
|
||||
什么是重置序列?
|
||||
</h6>
|
||||
</div>
|
||||
<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-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="sequenceLoading || !sequenceForm.table"
|
||||
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 ? '正在重置...' : '执行重置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置数据库模态框 -->
|
||||
<div
|
||||
v-if="activeModal === 'reset-db'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
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-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-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div
|
||||
class="p-6 bg-rose-600/10 border border-rose-500/20 rounded-2xl flex flex-col items-center text-center"
|
||||
>
|
||||
<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-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-rose-500/80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
请输入以下代码以确认操作
|
||||
</label>
|
||||
<div
|
||||
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="在此输入上述代码..."
|
||||
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-zinc-900 hover:bg-zinc-800 text-zinc-500 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
取消
|
||||
</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-zinc-800 disabled:text-zinc-600 disabled:cursor-not-allowed disabled:border-zinc-700"
|
||||
:class="
|
||||
resetConfirmText === CONFIRM_CODE
|
||||
? 'bg-rose-600 hover:bg-rose-500 text-white shadow-rose-900/20 active:scale-95'
|
||||
: ''
|
||||
"
|
||||
@click="resetDatabase"
|
||||
>
|
||||
{{ resetLoading ? '正在重置...' : '确认彻底重置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Download, Upload, RotateCw, Trash2, AlertCircle, X } from 'lucide-vue-next'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
const auth = useAuth()
|
||||
|
||||
// 状态
|
||||
const activeModal = ref('none')
|
||||
const createLoading = ref(false)
|
||||
const uploadLoading = ref(false)
|
||||
const sequenceLoading = ref(false)
|
||||
const resetLoading = ref(false)
|
||||
const selectedFile = ref(null)
|
||||
const resetConfirmText = ref('')
|
||||
const restoreProgress = ref('')
|
||||
const hasSuperAdminInBackup = ref(false)
|
||||
const CONFIRM_CODE = 'CONFIRM-DATABASE-RESET-OPERATION'
|
||||
|
||||
// 卡片配置
|
||||
const cards = [
|
||||
{
|
||||
id: 'backup',
|
||||
title: '创建备份',
|
||||
desc: '导出当前数据库的所有数据到文件,用于安全备份或迁移。',
|
||||
icon: Download,
|
||||
color: 'blue',
|
||||
btnText: '创建备份文件'
|
||||
},
|
||||
{
|
||||
id: 'restore',
|
||||
title: '恢复备份',
|
||||
desc: '从之前导出的备份文件中恢复系统数据。',
|
||||
icon: Upload,
|
||||
color: 'emerald',
|
||||
btnText: '选择备份文件'
|
||||
},
|
||||
{
|
||||
id: 'reset-seq',
|
||||
title: '重置序列',
|
||||
desc: '修复数据表的自增ID序列,确保新记录的ID从正确值开始。',
|
||||
icon: RotateCw,
|
||||
color: 'amber',
|
||||
btnText: '开始重置序列'
|
||||
},
|
||||
{
|
||||
id: 'reset-db',
|
||||
title: '重置数据库',
|
||||
desc: '清空除管理员账号外的所有系统数据。此操作不可撤销。',
|
||||
icon: Trash2,
|
||||
color: 'rose',
|
||||
btnText: '立即重置数据库',
|
||||
isDanger: true
|
||||
}
|
||||
]
|
||||
|
||||
// 备份选项
|
||||
const backupOptions = [
|
||||
{
|
||||
key: 'includeSongs',
|
||||
label: '歌曲与排期数据',
|
||||
desc: '包含所有歌曲库、用户投稿记录及历史播音排期'
|
||||
},
|
||||
{
|
||||
key: 'includeSystemData',
|
||||
label: '系统配置信息',
|
||||
desc: '包含站点设置、黑名单、播出时段等全局参数'
|
||||
},
|
||||
{
|
||||
key: 'includeUsers',
|
||||
label: '用户账户数据',
|
||||
desc: '包含所有注册用户的权限、偏好设置(不含管理员敏感信息)'
|
||||
}
|
||||
]
|
||||
|
||||
// 表单数据
|
||||
const createForm = ref({
|
||||
includeSongs: true,
|
||||
includeUsers: true,
|
||||
includeSystemData: true
|
||||
})
|
||||
|
||||
const restoreForm = ref({
|
||||
mode: 'merge',
|
||||
overwriteSuperAdmin: false
|
||||
})
|
||||
|
||||
const sequenceForm = ref({
|
||||
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
|
||||
if (id === 'restore') return uploadLoading.value
|
||||
if (id === 'reset-seq') return sequenceLoading.value
|
||||
if (id === 'reset-db') return resetLoading.value
|
||||
return false
|
||||
}
|
||||
|
||||
const openModal = (id) => {
|
||||
activeModal.value = id
|
||||
if (id === 'reset-db') {
|
||||
resetConfirmText.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 文件处理
|
||||
const parseBackupSuperAdmin = async (file) => {
|
||||
try {
|
||||
const fileContent = await file.text()
|
||||
const backupData = JSON.parse(fileContent)
|
||||
const users = Array.isArray(backupData?.data?.users) ? backupData.data.users : []
|
||||
hasSuperAdminInBackup.value = users.some((item) => item?.role === 'SUPER_ADMIN')
|
||||
if (!hasSuperAdminInBackup.value) {
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
}
|
||||
} catch (error) {
|
||||
hasSuperAdminInBackup.value = false
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
showNotification('无法解析备份文件,请检查文件格式是否正确。', 'error')
|
||||
console.error('解析备份文件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file && file.type === 'application/json') {
|
||||
selectedFile.value = file
|
||||
await parseBackupSuperAdmin(file)
|
||||
} else {
|
||||
showNotification('请选择有效的JSON备份文件', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileDrop = async (event) => {
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && file.type === 'application/json') {
|
||||
selectedFile.value = file
|
||||
await parseBackupSuperAdmin(file)
|
||||
} else {
|
||||
showNotification('请选择有效的JSON备份文件', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// API 操作
|
||||
const createBackup = async () => {
|
||||
createLoading.value = true
|
||||
try {
|
||||
let tables = 'all'
|
||||
if (createForm.value.includeUsers && createForm.value.includeSongs) {
|
||||
tables = 'all'
|
||||
} else if (createForm.value.includeUsers) {
|
||||
tables = 'users'
|
||||
} else if (createForm.value.includeSongs) {
|
||||
tables = 'songs'
|
||||
} else if (createForm.value.includeSystemData) {
|
||||
tables = ['systemSettings']
|
||||
} else {
|
||||
throw new Error('请至少选择一项备份内容')
|
||||
}
|
||||
|
||||
const response = await $fetch('/api/admin/backup/export', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tables,
|
||||
includeSystemData: createForm.value.includeSystemData
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success && response.backup) {
|
||||
if (response.backup.downloadMode === 'direct' && response.backup.data) {
|
||||
const blob = new Blob([JSON.stringify(response.backup.data, null, 2)], {
|
||||
type: 'application/json'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = response.backup.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
showNotification('备份文件已下载', 'success')
|
||||
activeModal.value = 'none'
|
||||
} else if (response.backup.downloadMode === 'file' && response.backup.filename) {
|
||||
const downloadUrl = `/api/admin/backup/download/${response.backup.filename}`
|
||||
const downloadResponse = await fetch(downloadUrl, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
})
|
||||
if (!downloadResponse.ok) throw new Error(`下载失败: ${downloadResponse.status}`)
|
||||
const blob = await downloadResponse.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = response.backup.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
showNotification('备份文件已下载', 'success')
|
||||
activeModal.value = 'none'
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || '备份创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建备份失败:', error)
|
||||
showNotification('创建备份失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const restoreBackup = async () => {
|
||||
if (!selectedFile.value) return showNotification('请选择备份文件', 'error')
|
||||
|
||||
uploadLoading.value = true
|
||||
restoreProgress.value = '正在读取文件...'
|
||||
|
||||
try {
|
||||
const fileContent = await selectedFile.value.text()
|
||||
let backupData
|
||||
try {
|
||||
backupData = JSON.parse(fileContent)
|
||||
} catch {
|
||||
throw new Error('无法解析备份文件,文件格式不正确')
|
||||
}
|
||||
|
||||
if (!backupData.data) throw new Error('备份文件缺少数据部分')
|
||||
const fileHasSuperAdmin = hasSuperAdminInBackup.value
|
||||
if (!fileHasSuperAdmin) {
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
}
|
||||
|
||||
let preservedSuperAdminIds = []
|
||||
let temporaryPreservedUserId = null
|
||||
|
||||
if (restoreForm.value.mode === 'replace') {
|
||||
restoreProgress.value = '正在清空现有数据...'
|
||||
const clearResult = await $fetch('/api/admin/backup/clear', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
|
||||
hasSuperAdminInBackup: fileHasSuperAdmin
|
||||
}
|
||||
})
|
||||
if (!clearResult.success) throw new Error(clearResult.message || '清空数据失败')
|
||||
preservedSuperAdminIds = clearResult.preservedSuperAdminIds || []
|
||||
temporaryPreservedUserId = clearResult.temporaryPreservedUserId || null
|
||||
}
|
||||
|
||||
const tableOrder = [
|
||||
'users',
|
||||
'userIdentities',
|
||||
'systemSettings',
|
||||
'semesters',
|
||||
'playTimes',
|
||||
'songs',
|
||||
'votes',
|
||||
'schedules',
|
||||
'notificationSettings',
|
||||
'notifications',
|
||||
'songBlacklist',
|
||||
'userStatusLogs'
|
||||
]
|
||||
|
||||
const mappings = {
|
||||
users: {},
|
||||
songs: {},
|
||||
meta: {
|
||||
preservedSuperAdminIds,
|
||||
temporaryPreservedUserId
|
||||
}
|
||||
}
|
||||
const CHUNK_SIZE = 50
|
||||
let totalProcessed = 0
|
||||
let totalRecords = 0
|
||||
|
||||
for (const tableName of tableOrder) {
|
||||
if (backupData.data[tableName] && Array.isArray(backupData.data[tableName])) {
|
||||
totalRecords += backupData.data[tableName].length
|
||||
}
|
||||
}
|
||||
|
||||
for (const tableName of tableOrder) {
|
||||
const records = backupData.data[tableName]
|
||||
if (!records || !Array.isArray(records) || records.length === 0) continue
|
||||
|
||||
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 = `正在恢复 ${tableName} (${i + 1}-${Math.min(i + CHUNK_SIZE, records.length)}/${records.length}) ${progressPercent}%`
|
||||
|
||||
const response = await $fetch('/api/admin/backup/restore-chunk', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tableName,
|
||||
records: chunk,
|
||||
mappings,
|
||||
mode: restoreForm.value.mode,
|
||||
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
|
||||
hasSuperAdminInBackup: fileHasSuperAdmin
|
||||
}
|
||||
})
|
||||
|
||||
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)
|
||||
}
|
||||
totalProcessed += chunk.length
|
||||
}
|
||||
}
|
||||
|
||||
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 || '序列修复失败')
|
||||
}
|
||||
|
||||
restoreProgress.value = '正在重载SMTP配置...'
|
||||
const smtpReloadResult = await $fetch('/api/admin/smtp/reload', {
|
||||
method: 'POST'
|
||||
})
|
||||
if (!smtpReloadResult.success) {
|
||||
throw new Error(smtpReloadResult.message || 'SMTP配置重载失败')
|
||||
}
|
||||
|
||||
const shouldFinalizeTempUser =
|
||||
restoreForm.value.mode === 'replace' &&
|
||||
restoreForm.value.overwriteSuperAdmin &&
|
||||
fileHasSuperAdmin &&
|
||||
temporaryPreservedUserId
|
||||
|
||||
if (shouldFinalizeTempUser) {
|
||||
const restoredUserIds = Object.values(mappings.users).map((id) => Number(id))
|
||||
if (!restoredUserIds.includes(Number(temporaryPreservedUserId))) {
|
||||
restoreProgress.value = '正在完成管理员替换...'
|
||||
await $fetch('/api/admin/backup/clear', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
finalizeTempUser: true
|
||||
}
|
||||
})
|
||||
}
|
||||
showNotification('数据库恢复成功,正在重新登录', 'success')
|
||||
activeModal.value = 'none'
|
||||
setTimeout(() => {
|
||||
if (auth.logout) {
|
||||
auth.logout()
|
||||
}
|
||||
localStorage.removeItem('auth-token')
|
||||
localStorage.removeItem('user-info')
|
||||
window.location.href = '/'
|
||||
}, 1200)
|
||||
return
|
||||
}
|
||||
|
||||
showNotification('数据库恢复成功', 'success')
|
||||
activeModal.value = 'none'
|
||||
} catch (error) {
|
||||
console.error('恢复备份失败:', error)
|
||||
showNotification('恢复备份失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
restoreProgress.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
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: tableValue }
|
||||
})
|
||||
if (response.success) {
|
||||
showNotification(response.message || '序列重置成功', 'success')
|
||||
activeModal.value = 'none'
|
||||
} else {
|
||||
throw new Error(response.error || response.message || '重置失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置序列失败:', error)
|
||||
showNotification('重置序列失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
sequenceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetDatabase = async () => {
|
||||
if (resetConfirmText.value !== CONFIRM_CODE) return
|
||||
resetLoading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/admin/database/reset', { method: 'POST' })
|
||||
if (response.success) {
|
||||
showNotification('数据库已成功重置', 'success')
|
||||
activeModal.value = 'none'
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} else {
|
||||
throw new Error(response.message || '重置失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置数据库失败:', error)
|
||||
showNotification('重置数据库失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
resetLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<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-zinc-800 bg-zinc-900/20">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
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-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<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-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 ? '刷新预览' : '实时预览' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="selected"
|
||||
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 ? '保存中...' : '更新模板' }}
|
||||
</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-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-zinc-600" />
|
||||
<input
|
||||
type="text"
|
||||
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>
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
||||
<button
|
||||
v-for="t in templates"
|
||||
:key="t.key"
|
||||
class="w-full group flex flex-col gap-1 p-3 rounded-2xl transition-all text-left border"
|
||||
:class="[
|
||||
selectedKey === t.key
|
||||
? 'bg-blue-600/10 border-blue-500/20'
|
||||
: 'bg-transparent border-transparent hover:bg-zinc-800/40'
|
||||
]"
|
||||
@click="select(t)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="text-[11px] font-black tracking-tight transition-colors"
|
||||
:class="
|
||||
selectedKey === t.key
|
||||
? 'text-blue-400'
|
||||
: 'text-zinc-300 group-hover:text-zinc-100'
|
||||
"
|
||||
>
|
||||
{{ t.name }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<span
|
||||
v-if="t.isBuiltin && !t.isOverridden"
|
||||
class="px-1.5 py-0.5 rounded-md bg-zinc-800 text-[8px] font-black text-zinc-500 uppercase tracking-tighter"
|
||||
>
|
||||
内置
|
||||
</span>
|
||||
<span
|
||||
v-if="t.isOverridden"
|
||||
class="px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-[8px] font-black text-emerald-500 uppercase tracking-tighter"
|
||||
>
|
||||
自定义
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<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-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">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>模板名称</label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
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-zinc-600 uppercase tracking-widest"
|
||||
>邮件主题</label
|
||||
>
|
||||
<span class="text-[9px] text-zinc-500 font-bold uppercase">支持变量</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.subject"
|
||||
type="text"
|
||||
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-blue-600/5 border border-blue-500/10 rounded-2xl p-4 flex gap-4">
|
||||
<div
|
||||
class="w-8 h-8 rounded-xl bg-blue-500/10 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<Info :size="16" class="text-blue-500" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-[10px] font-black text-blue-400 uppercase tracking-widest">
|
||||
可用变量
|
||||
</h4>
|
||||
<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>
|
||||
<template v-else>
|
||||
<span v-pre>{{ title }}, {{ message }}, {{ actionUrl }}, {{ fromName }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容编辑器 -->
|
||||
<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-zinc-600 uppercase tracking-widest"
|
||||
>HTML 内容</label
|
||||
>
|
||||
<span
|
||||
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-rose-500 hover:text-rose-400 transition-colors"
|
||||
@click="restore"
|
||||
>
|
||||
<RotateCcw :size="12" /> 恢复默认配置
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<div
|
||||
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-zinc-500" />
|
||||
</div>
|
||||
<textarea
|
||||
v-model="form.html"
|
||||
rows="16"
|
||||
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>
|
||||
</div>
|
||||
|
||||
<!-- 预览框 -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
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-zinc-800">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<label
|
||||
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<Eye :size="14" /> 实时预览:
|
||||
<span class="text-zinc-400 font-normal normal-case">{{ previewSubject }}</span>
|
||||
</label>
|
||||
<button
|
||||
class="text-[10px] font-bold text-zinc-500 hover:text-zinc-300"
|
||||
@click="previewHtml = ''"
|
||||
>
|
||||
隐藏预览
|
||||
</button>
|
||||
</div>
|
||||
<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>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else
|
||||
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-zinc-900/50 flex items-center justify-center border border-zinc-800/50"
|
||||
>
|
||||
<Mail :size="32" class="text-zinc-800" />
|
||||
</div>
|
||||
<p class="text-xs font-bold tracking-widest uppercase">请在左侧选择一个邮件模板</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { Mail, Info, Save, RotateCcw, Eye, Code, Search, ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
type TemplateItem = {
|
||||
key: string
|
||||
name: string
|
||||
subject: string
|
||||
html: string
|
||||
isBuiltin: boolean
|
||||
isOverridden?: boolean
|
||||
}
|
||||
|
||||
const { success, error } = useToast()
|
||||
const templates = ref<TemplateItem[]>([])
|
||||
const selectedKey = ref<string>('')
|
||||
const form = ref<{ key: string; name: string; subject: string; html: string }>({
|
||||
key: '',
|
||||
name: '',
|
||||
subject: '',
|
||||
html: ''
|
||||
})
|
||||
const saving = ref(false)
|
||||
const previewHtml = ref<string>('')
|
||||
const previewSubject = ref<string>('')
|
||||
|
||||
const selected = computed(() => templates.value.find((t) => t.key === selectedKey.value))
|
||||
|
||||
async function loadList() {
|
||||
const res: any = await $fetch('/api/admin/email-templates')
|
||||
const list = Array.isArray(res?.templates) ? res.templates : []
|
||||
templates.value = list.map((t: any) => ({
|
||||
key: String(t.key),
|
||||
name: String(t.name),
|
||||
subject: String(t.subject),
|
||||
html: String(t.html),
|
||||
isBuiltin: !!t.isBuiltin,
|
||||
isOverridden: !!t.isOverridden
|
||||
}))
|
||||
if (!selectedKey.value && templates.value.length) select(templates.value[0])
|
||||
}
|
||||
|
||||
function select(t: TemplateItem) {
|
||||
selectedKey.value = t.key
|
||||
form.value = { key: t.key, name: t.name, subject: t.subject, html: t.html }
|
||||
previewHtml.value = ''
|
||||
previewSubject.value = ''
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
saving.value = true
|
||||
await $fetch('/api/admin/email-templates', { method: 'POST', body: form.value })
|
||||
success('模板已保存')
|
||||
await loadList()
|
||||
} catch (e: any) {
|
||||
error(e?.data?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
try {
|
||||
saving.value = true
|
||||
await $fetch(`/api/admin/email-templates?key=${encodeURIComponent(form.value.key)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
success('已恢复默认模板')
|
||||
await loadList()
|
||||
} catch (e: any) {
|
||||
error(e?.data?.message || '恢复失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doPreview() {
|
||||
try {
|
||||
const defaultData =
|
||||
form.value.key === 'verification.code'
|
||||
? { name: '张三', email: 'example@school.edu', code: '123456', expiresInMinutes: 5 }
|
||||
: {
|
||||
title: '系统通知',
|
||||
message: '这是一条预览内容\n支持换行与链接',
|
||||
actionUrl: 'https://example.com'
|
||||
}
|
||||
const res = await $fetch('/api/admin/email-templates/preview', {
|
||||
method: 'POST',
|
||||
body: { key: form.value.key, data: defaultData }
|
||||
})
|
||||
previewHtml.value = res.html
|
||||
previewSubject.value = res.subject
|
||||
} catch (e: any) {
|
||||
error(e?.data?.message || '预览失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,759 @@
|
||||
<template>
|
||||
<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-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-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-zinc-600 uppercase tracking-[0.2em] px-1"
|
||||
>通知标题</label
|
||||
>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
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-zinc-600 uppercase tracking-[0.2em] px-1"
|
||||
>通知内容</label
|
||||
>
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 范围选择 -->
|
||||
<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
|
||||
v-for="opt in targetOptions"
|
||||
:key="opt.id"
|
||||
: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-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"
|
||||
>
|
||||
<component :is="opt.icon" :size="14" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 动态配置区 -->
|
||||
<div class="pt-2">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="form.scope === 'ALL'"
|
||||
key="all"
|
||||
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-2xl flex items-center gap-4"
|
||||
>
|
||||
<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-zinc-600 uppercase tracking-widest px-1"
|
||||
>选择年级</span
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="form.grade"
|
||||
:options="['高一', '高二', '高三', '教师']"
|
||||
placeholder="请选择年级"
|
||||
class="w-full md:w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="form.scope === 'CLASS'"
|
||||
key="class"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>年级</span
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="form.classGrade"
|
||||
:options="['高一', '高二', '高三', '教师']"
|
||||
placeholder="请选择年级"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>班级</span
|
||||
>
|
||||
<input
|
||||
v-model="form.className"
|
||||
type="text"
|
||||
placeholder="如: 1班、2班"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-xs focus:outline-none focus:border-blue-500/30 text-zinc-200"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="form.scope === 'MULTI_CLASS'" key="multi" class="space-y-4">
|
||||
<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="['高一', '高二', '高三', '教师']"
|
||||
placeholder="请选择年级"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="multiClassForm.class"
|
||||
type="text"
|
||||
placeholder="输入班级名称"
|
||||
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-xs focus:outline-none text-zinc-200"
|
||||
>
|
||||
<button
|
||||
:disabled="!canAddClass"
|
||||
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"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.selectedClasses.length > 0" class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(cls, index) in form.selectedClasses"
|
||||
:key="index"
|
||||
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-zinc-400"
|
||||
>{{ cls.grade }} {{ cls.class }}</span
|
||||
>
|
||||
<button
|
||||
class="text-zinc-600 hover:text-red-400 transition-colors"
|
||||
@click="removeClassFromSelection(index)"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-4">
|
||||
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
|
||||
未选择任何班级
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="form.scope === 'SPECIFIC_USERS'" key="specific" class="space-y-4">
|
||||
<div class="relative flex items-center">
|
||||
<Search
|
||||
v-if="!userSearchLoading"
|
||||
class="absolute left-4 text-zinc-700 pointer-events-none"
|
||||
:size="16"
|
||||
/>
|
||||
<Loader2
|
||||
v-else
|
||||
class="absolute left-4 text-blue-500 animate-spin pointer-events-none"
|
||||
:size="16"
|
||||
/>
|
||||
<input
|
||||
v-model="userSearchQuery"
|
||||
type="text"
|
||||
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>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div
|
||||
v-if="showUserSearchResults && userSearchResults.length > 0"
|
||||
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-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-zinc-800/30 last:border-0 hover:bg-zinc-800/30 transition-all"
|
||||
>
|
||||
<div>
|
||||
<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-blue-500 font-black"
|
||||
>@{{ user.username }}</span
|
||||
>
|
||||
<span
|
||||
v-if="user.grade && user.class"
|
||||
class="text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
|
||||
>{{ user.grade }} {{ user.class }}</span
|
||||
>
|
||||
<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-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) ? '已选择' : '选择' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选择的用户 -->
|
||||
<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-zinc-600 uppercase tracking-widest"
|
||||
>已选择用户 ({{ form.selectedUsers.length }})</span
|
||||
>
|
||||
<button
|
||||
class="text-[9px] font-black text-red-500/70 hover:text-red-500 uppercase tracking-widest transition-colors"
|
||||
@click="clearAllSelectedUsers"
|
||||
>
|
||||
清空全部
|
||||
</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-zinc-900 border border-zinc-800 rounded-lg group"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[10px] font-bold text-zinc-300 leading-none">{{
|
||||
user.name || user.username
|
||||
}}</span>
|
||||
<span class="text-[8px] text-zinc-600">@{{ user.username }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="text-zinc-600 hover:text-red-400 transition-colors"
|
||||
@click="removeUserFromSelection(index)"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-4">
|
||||
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
|
||||
未选择任何用户
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误和成功提示 -->
|
||||
<div v-if="error || success" class="mt-6">
|
||||
<div
|
||||
v-if="error"
|
||||
class="p-4 bg-red-500/5 border border-red-500/10 rounded-2xl flex items-center gap-3"
|
||||
>
|
||||
<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-green-500/5 border border-green-500/10 rounded-2xl flex items-center gap-3"
|
||||
>
|
||||
<Check class="text-green-500 shrink-0" :size="18" />
|
||||
<p class="text-xs font-bold text-green-400">{{ success }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-end">
|
||||
<button
|
||||
:disabled="loading || !isFormValid"
|
||||
:class="[
|
||||
'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-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 ? '发送中...' : '发送通知' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:预览区 -->
|
||||
<div class="lg:col-span-5 space-y-6 lg:sticky lg:top-8">
|
||||
<div
|
||||
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-zinc-100 uppercase tracking-widest mb-6 flex items-center gap-2"
|
||||
>
|
||||
<Eye :size="16" class="text-blue-500" /> 通知预览
|
||||
</h3>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center justify-center p-4">
|
||||
<div
|
||||
class="w-full max-w-[320px] bg-[#0c0c0e] border border-zinc-800 rounded-3xl overflow-hidden shadow-2xl relative"
|
||||
>
|
||||
<!-- 顶部装饰 -->
|
||||
<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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import {
|
||||
Send,
|
||||
Users,
|
||||
GraduationCap,
|
||||
LayoutGrid,
|
||||
User,
|
||||
Search,
|
||||
Bell,
|
||||
Info,
|
||||
X,
|
||||
Check,
|
||||
Plus,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Loader2
|
||||
} from 'lucide-vue-next'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useAdmin } from '~/composables/useAdmin'
|
||||
|
||||
const { isAdmin } = useAuth()
|
||||
const { sendAdminNotification } = useAdmin()
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
scope: 'ALL', // 'ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'
|
||||
grade: '',
|
||||
classGrade: '',
|
||||
className: '',
|
||||
selectedClasses: [], // 用于多班级选择
|
||||
selectedUsers: [] // 用于指定用户选择
|
||||
})
|
||||
|
||||
// 多班级选择表单
|
||||
const multiClassForm = ref({
|
||||
grade: '',
|
||||
class: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
// 用户搜索相关
|
||||
const userSearchQuery = ref('')
|
||||
const userSearchResults = ref([])
|
||||
const showUserSearchResults = ref(false)
|
||||
const userSearchLoading = ref(false)
|
||||
let userSearchTimeout = null
|
||||
|
||||
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 canAddClass = computed(() => {
|
||||
return multiClassForm.value.grade && multiClassForm.value.class
|
||||
})
|
||||
|
||||
// 添加班级到选择列表
|
||||
const addClassToSelection = () => {
|
||||
if (!canAddClass.value) return
|
||||
|
||||
// 检查是否已经选择了这个班级
|
||||
const isDuplicate = form.value.selectedClasses.some(
|
||||
(cls) => cls.grade === multiClassForm.value.grade && cls.class === multiClassForm.value.class
|
||||
)
|
||||
|
||||
if (!isDuplicate) {
|
||||
form.value.selectedClasses.push({
|
||||
grade: multiClassForm.value.grade,
|
||||
class: multiClassForm.value.class
|
||||
})
|
||||
|
||||
// 清空输入
|
||||
multiClassForm.value.class = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 从选择列表中移除班级
|
||||
const removeClassFromSelection = (index) => {
|
||||
form.value.selectedClasses.splice(index, 1)
|
||||
}
|
||||
|
||||
// 用户搜索输入处理(防抖)
|
||||
const onUserSearchInput = () => {
|
||||
clearTimeout(userSearchTimeout)
|
||||
|
||||
if (!userSearchQuery.value.trim()) {
|
||||
userSearchResults.value = []
|
||||
showUserSearchResults.value = false
|
||||
return
|
||||
}
|
||||
|
||||
userSearchTimeout = setTimeout(async () => {
|
||||
await searchUsers(userSearchQuery.value.trim())
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 搜索用户API调用
|
||||
const searchUsers = async (query) => {
|
||||
if (!query) return
|
||||
|
||||
try {
|
||||
userSearchLoading.value = true
|
||||
const response = await $fetch('/api/admin/users', {
|
||||
method: 'GET',
|
||||
query: {
|
||||
search: query,
|
||||
limit: 20
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
userSearchResults.value = response.users || []
|
||||
showUserSearchResults.value = true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('搜索用户失败:', err)
|
||||
userSearchResults.value = []
|
||||
showUserSearchResults.value = false
|
||||
} finally {
|
||||
userSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户是否已被选择
|
||||
const isUserSelected = (userId) => {
|
||||
return form.value.selectedUsers.some((user) => user.id === userId)
|
||||
}
|
||||
|
||||
// 添加用户到选择列表
|
||||
const addUserToSelection = (user) => {
|
||||
if (isUserSelected(user.id)) return
|
||||
|
||||
form.value.selectedUsers.push({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
grade: user.grade,
|
||||
class: user.class,
|
||||
role: user.role
|
||||
})
|
||||
|
||||
// 清空搜索
|
||||
userSearchQuery.value = ''
|
||||
userSearchResults.value = []
|
||||
showUserSearchResults.value = false
|
||||
}
|
||||
|
||||
// 从选择列表中移除用户
|
||||
const removeUserFromSelection = (index) => {
|
||||
form.value.selectedUsers.splice(index, 1)
|
||||
}
|
||||
|
||||
// 清空所有已选用户
|
||||
const clearAllSelectedUsers = () => {
|
||||
form.value.selectedUsers = []
|
||||
}
|
||||
|
||||
// 获取角色文本
|
||||
const getRoleText = (role) => {
|
||||
const roleMap = {
|
||||
admin: '管理员',
|
||||
teacher: '教师',
|
||||
student: '学生'
|
||||
}
|
||||
return roleMap[role] || role
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
if (!form.value.title || !form.value.content) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.value.scope === 'GRADE' && !form.value.grade) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.value.scope === 'CLASS' && (!form.value.classGrade || !form.value.className)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.value.scope === 'MULTI_CLASS' && form.value.selectedClasses.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.value.scope === 'SPECIFIC_USERS' && form.value.selectedUsers.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 范围描述
|
||||
const scopeDescription = computed(() => {
|
||||
switch (form.value.scope) {
|
||||
case 'ALL':
|
||||
return '全体用户'
|
||||
case 'GRADE':
|
||||
return form.value.grade ? `${form.value.grade}年级` : '请选择年级'
|
||||
case 'CLASS':
|
||||
return form.value.classGrade && form.value.className
|
||||
? `${form.value.classGrade}年级${form.value.className}班`
|
||||
: '请选择班级'
|
||||
case 'MULTI_CLASS':
|
||||
return form.value.selectedClasses.length > 0
|
||||
? `${form.value.selectedClasses.length}个班级`
|
||||
: '请选择班级'
|
||||
case 'SPECIFIC_USERS':
|
||||
return form.value.selectedUsers.length > 0
|
||||
? `已选择${form.value.selectedUsers.length}个用户`
|
||||
: '请选择用户'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// 发送通知
|
||||
const sendNotification = async () => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = '只有管理员可以发送系统通知'
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFormValid.value) {
|
||||
error.value = '请填写完整信息'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
// 构建请求数据
|
||||
const notificationData = {
|
||||
title: form.value.title,
|
||||
content: form.value.content,
|
||||
scope: form.value.scope,
|
||||
filter: {}
|
||||
}
|
||||
|
||||
// 添加过滤条件
|
||||
if (form.value.scope === 'GRADE') {
|
||||
notificationData.filter.grade = form.value.grade
|
||||
} else if (form.value.scope === 'CLASS') {
|
||||
notificationData.filter.grade = form.value.classGrade
|
||||
notificationData.filter.class = form.value.className
|
||||
} else if (form.value.scope === 'MULTI_CLASS') {
|
||||
notificationData.filter.classes = form.value.selectedClasses
|
||||
} else if (form.value.scope === 'SPECIFIC_USERS') {
|
||||
notificationData.filter.userIds = form.value.selectedUsers.map((user) => user.id)
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
const result = await sendAdminNotification(notificationData)
|
||||
|
||||
if (result && result.success) {
|
||||
success.value = `成功发送通知给 ${result.sentCount} 名用户`
|
||||
|
||||
// 3秒后自动隐藏成功提示
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
|
||||
// 清空表单
|
||||
form.value = {
|
||||
title: '',
|
||||
content: '',
|
||||
scope: 'ALL',
|
||||
grade: '',
|
||||
classGrade: '',
|
||||
className: '',
|
||||
selectedClasses: [],
|
||||
selectedUsers: []
|
||||
}
|
||||
multiClassForm.value = {
|
||||
grade: '',
|
||||
class: ''
|
||||
}
|
||||
// 清空用户搜索相关状态
|
||||
userSearchQuery.value = ''
|
||||
userSearchResults.value = []
|
||||
showUserSearchResults.value = false
|
||||
userSearchLoading.value = false
|
||||
clearTimeout(userSearchTimeout)
|
||||
} else {
|
||||
throw new Error(result?.message || '发送通知失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || '发送通知时发生错误'
|
||||
console.error('发送通知错误:', err)
|
||||
|
||||
// 3秒后自动隐藏错误提示
|
||||
setTimeout(() => {
|
||||
error.value = ''
|
||||
}, 3000)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (userSearchTimeout) {
|
||||
clearTimeout(userSearchTimeout)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<!-- 统计卡片网格 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
<div
|
||||
v-for="(stat, i) in statCards"
|
||||
:key="i"
|
||||
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',
|
||||
stat.color === 'blue'
|
||||
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
|
||||
: stat.color === 'emerald'
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: stat.color === 'pink'
|
||||
? '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" />
|
||||
</div>
|
||||
<div
|
||||
v-if="stat.trend"
|
||||
:class="[
|
||||
'flex items-center gap-1 text-[11px] font-bold',
|
||||
stat.trendDown ? 'text-red-400' : 'text-emerald-400'
|
||||
]"
|
||||
>
|
||||
<TrendingDown v-if="stat.trendDown" :size="12" />
|
||||
<TrendingUp v-else :size="12" />
|
||||
{{ stat.trend }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<!-- 最近活动 -->
|
||||
<div
|
||||
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-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||
<Activity :size="18" class="text-blue-500" /> 最近活动
|
||||
</h3>
|
||||
<button
|
||||
class="p-2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
:class="{ 'animate-spin': loadingActivities }"
|
||||
@click="refreshActivities"
|
||||
>
|
||||
<RefreshCw :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1 min-h-[380px] max-h-[500px]"
|
||||
>
|
||||
<div
|
||||
v-if="loadingActivities && recentActivities.length === 0"
|
||||
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
|
||||
>
|
||||
<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-zinc-500 gap-3 py-20"
|
||||
>
|
||||
<Inbox :size="24" />
|
||||
<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-zinc-800/40 transition-all cursor-pointer group"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'shrink-0 w-10 h-10 rounded-xl flex items-center justify-center border shadow-sm',
|
||||
getActivityStyle(activity.type).bg
|
||||
]"
|
||||
>
|
||||
<component :is="getActivityStyle(activity.type).icon" :size="18" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h5
|
||||
class="font-bold text-sm text-zinc-200 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ activity.title }}
|
||||
</h5>
|
||||
<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-zinc-600" />
|
||||
<span class="text-[10px] text-zinc-600 font-medium uppercase tracking-wider">{{
|
||||
formatTime(activity.createdAt)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统状态 -->
|
||||
<div
|
||||
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-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||
<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-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
]"
|
||||
>
|
||||
{{ systemStatus.online ? '在线' : '离线' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div
|
||||
v-for="(status, i) in statusItems"
|
||||
:key="i"
|
||||
class="flex items-center justify-between group"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
status.active
|
||||
? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.7)]'
|
||||
: 'bg-zinc-600'
|
||||
]"
|
||||
/>
|
||||
<span class="text-xs font-semibold text-zinc-400">{{ status.label }}</span>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-zinc-200">{{ status.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<div
|
||||
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-zinc-800">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||
<Zap :size="18" class="text-yellow-500" /> 快速操作
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-3">
|
||||
<button
|
||||
v-for="(action, i) in quickActions"
|
||||
:key="i"
|
||||
: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-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)"
|
||||
>
|
||||
<component :is="action.icon" :size="18" />
|
||||
{{ action.label }}
|
||||
<ExternalLink v-if="action.primary" :size="14" class="ml-auto opacity-50" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import {
|
||||
Music,
|
||||
Users,
|
||||
Calendar,
|
||||
Heart,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
ShieldCheck,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Zap,
|
||||
Bell,
|
||||
Ban,
|
||||
ExternalLink,
|
||||
Inbox
|
||||
} from 'lucide-vue-next'
|
||||
import packageJson from '~~/package.json'
|
||||
|
||||
const emit = defineEmits(['navigate'])
|
||||
|
||||
const systemVersion = ref(packageJson.version)
|
||||
const stats = ref({
|
||||
totalSongs: 0,
|
||||
totalUsers: 0,
|
||||
todaySchedules: 0,
|
||||
weeklyRequests: 0,
|
||||
songsChange: 0,
|
||||
usersChange: 0,
|
||||
requestsChange: 0,
|
||||
totalSchedules: 0,
|
||||
currentSemester: '',
|
||||
blacklistCount: 0
|
||||
})
|
||||
|
||||
const recentActivities = ref([])
|
||||
const loadingActivities = ref(false)
|
||||
|
||||
const systemStatus = ref({
|
||||
online: true,
|
||||
database: true,
|
||||
api: true
|
||||
})
|
||||
|
||||
// 统计卡片数据
|
||||
const statCards = computed(() => [
|
||||
{
|
||||
label: '总歌曲数',
|
||||
value: formatNumber(stats.value.totalSongs),
|
||||
icon: Music,
|
||||
color: 'blue',
|
||||
trend: stats.value.songsChange !== 0 ? `${Math.abs(stats.value.songsChange)}%` : null,
|
||||
trendDown: stats.value.songsChange < 0
|
||||
},
|
||||
{
|
||||
label: '注册用户',
|
||||
value: formatNumber(stats.value.totalUsers),
|
||||
icon: Users,
|
||||
color: 'emerald'
|
||||
},
|
||||
{
|
||||
label: '今日排期',
|
||||
value: formatNumber(stats.value.todaySchedules),
|
||||
icon: Calendar,
|
||||
color: 'zinc'
|
||||
},
|
||||
{
|
||||
label: '本周点歌',
|
||||
value: formatNumber(stats.value.weeklyRequests),
|
||||
icon: Heart,
|
||||
color: 'pink',
|
||||
trend: stats.value.requestsChange !== 0 ? `${Math.abs(stats.value.requestsChange)}%` : null,
|
||||
trendDown: stats.value.requestsChange < 0
|
||||
}
|
||||
])
|
||||
|
||||
// 系统状态项
|
||||
const statusItems = computed(() => [
|
||||
{
|
||||
label: '数据库连接',
|
||||
value: systemStatus.value.database ? '正常' : '异常',
|
||||
active: systemStatus.value.database
|
||||
},
|
||||
{
|
||||
label: 'API服务',
|
||||
value: systemStatus.value.api ? '正常' : '异常',
|
||||
active: systemStatus.value.api
|
||||
},
|
||||
{
|
||||
label: '当前学期',
|
||||
value: stats.value.currentSemester || '未设置',
|
||||
active: !!stats.value.currentSemester
|
||||
},
|
||||
{
|
||||
label: '黑名单项目',
|
||||
value: `${stats.value.blacklistCount} 项`,
|
||||
active: stats.value.blacklistCount >= 0
|
||||
},
|
||||
{ label: '系统版本', value: `v${systemVersion.value}`, active: true }
|
||||
])
|
||||
|
||||
// 快速操作
|
||||
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'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const getActivityStyle = (type) => {
|
||||
const styles = {
|
||||
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-zinc-500/10 text-zinc-500 border-zinc-500/20' }
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/admin/stats', {
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
stats.value = response
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecentActivities = async () => {
|
||||
loadingActivities.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/admin/activities', {
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
recentActivities.value = response.activities || []
|
||||
} catch (error) {
|
||||
console.error('加载活动记录失败:', error)
|
||||
recentActivities.value = []
|
||||
} finally {
|
||||
loadingActivities.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshActivities = () => {
|
||||
loadRecentActivities()
|
||||
}
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
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) => {
|
||||
emit('navigate', tab)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadRecentActivities()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,685 @@
|
||||
<template>
|
||||
<div class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2">
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<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-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-zinc-500 uppercase tracking-widest"
|
||||
>启用播出时段选择</span
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'relative w-10 h-5 rounded-full transition-colors',
|
||||
enablePlayTimeSelection ? 'bg-blue-600' : 'bg-zinc-800'
|
||||
]"
|
||||
@click="toggleGlobalEnabled"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'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-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" /> 添加播出时段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息卡片 -->
|
||||
<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-zinc-300">关于时段限制</p>
|
||||
<p class="text-[11px] text-zinc-500 leading-relaxed">
|
||||
启用全局时段选择后,系统将仅在定义的时段内开放点歌功能。如果未设置开始或结束时间,系统将视为该边界不设限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-col items-center justify-center py-20 space-y-4 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
|
||||
>
|
||||
<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-red-500/5 border border-red-500/10 rounded-xl p-5 flex items-start gap-4"
|
||||
>
|
||||
<AlertCircle class="text-red-500 shrink-0 mt-0.5" :size="18" />
|
||||
<div class="space-y-1">
|
||||
<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-zinc-900/20 border border-zinc-800/50 rounded-xl"
|
||||
>
|
||||
<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-zinc-100">暂无播出时段</h3>
|
||||
<p class="text-xs text-zinc-500">点击“添加播出时段”按钮创建第一个播出时段</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时段网格 -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<TransitionGroup name="list">
|
||||
<div
|
||||
v-for="playTime in playTimes"
|
||||
:key="playTime.id"
|
||||
:class="[
|
||||
'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-zinc-950 border border-zinc-800 transition-all',
|
||||
playTime.enabled ? 'text-blue-500 border-blue-500/20' : 'text-zinc-700'
|
||||
]"
|
||||
>
|
||||
<Clock :size="20" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
|
||||
playTime.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-zinc-800/50 text-zinc-600 border-zinc-700/50'
|
||||
]"
|
||||
>
|
||||
{{ playTime.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
<button class="p-1.5 text-zinc-700 hover:text-zinc-400">
|
||||
<MoreVertical :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4
|
||||
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-blue-500/80 font-black tracking-tighter"
|
||||
>
|
||||
<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-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-zinc-800/50 flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
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-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" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
playTime.enabled
|
||||
? '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 ? '禁用' : '启用' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑弹窗 -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showAddForm || editingPlayTime"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="cancelForm" />
|
||||
<div
|
||||
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-zinc-100 tracking-tight">
|
||||
{{ editingPlayTime ? '编辑播出时段' : '添加播出时段' }}
|
||||
</h3>
|
||||
<button
|
||||
class="p-2 text-zinc-500 hover:text-zinc-200 transition-colors"
|
||||
@click="cancelForm"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>时段名称</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
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-zinc-600 uppercase tracking-widest px-1"
|
||||
>开始时间 (可选)</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="formData.startTime"
|
||||
type="time"
|
||||
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-zinc-700 pointer-events-none"
|
||||
:size="14"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制开始时间</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<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-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-zinc-700 pointer-events-none"
|
||||
:size="14"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制结束时间</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 flex items-center gap-2"
|
||||
>
|
||||
<AlignLeft :size="10" /> 描述 (可选)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="formData.description"
|
||||
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>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer group px-1">
|
||||
<input
|
||||
v-model="formData.enabled"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-blue-600"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
|
||||
>启用此播出时段</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
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-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-zinc-500 hover:text-zinc-300"
|
||||
@click="cancelForm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="formSubmitting"
|
||||
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 ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 删除确认弹窗 -->
|
||||
<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-black/80 backdrop-blur-sm"
|
||||
@click="showDeleteConfirm = false"
|
||||
/>
|
||||
<div
|
||||
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-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-zinc-100">
|
||||
确定要删除播出时段 "{{ playTimeToDelete?.name }}" 吗?
|
||||
</h4>
|
||||
<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-zinc-950 border border-zinc-800 text-zinc-500 text-xs font-black rounded-lg transition-all hover:bg-zinc-800"
|
||||
@click="showDeleteConfirm = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="deleteInProgress"
|
||||
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 ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import type { PlayTime } from '~/types'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Edit2,
|
||||
Trash2,
|
||||
MoreVertical,
|
||||
Power,
|
||||
Info,
|
||||
AlertCircle,
|
||||
X,
|
||||
AlignLeft
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { getAuthConfig, isAdmin } = useAuth()
|
||||
const { showToast: showNotification } = useToast()
|
||||
|
||||
const playTimes = ref<PlayTime[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const showAddForm = ref(false)
|
||||
const editingPlayTime = ref<PlayTime | null>(null)
|
||||
const playTimeToDelete = ref<PlayTime | null>(null)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const formSubmitting = ref(false)
|
||||
const deleteInProgress = ref(false)
|
||||
const formError = ref('')
|
||||
const enablePlayTimeSelection = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
description: '',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
await fetchPlayTimes()
|
||||
await fetchSystemSettings()
|
||||
})
|
||||
|
||||
// 获取播出时段列表
|
||||
const fetchPlayTimes = async () => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = '只有管理员才能管理播出时段'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const data = await $fetch('/api/admin/play-times', {
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 自定义排序:先按启用状态排序,然后有时间的排在前面,没有时间的排在后面
|
||||
playTimes.value = data.sort((a: PlayTime, b: PlayTime) => {
|
||||
// 首先按启用状态排序
|
||||
if (a.enabled !== b.enabled) {
|
||||
return a.enabled ? -1 : 1 // 启用的排在前面
|
||||
}
|
||||
|
||||
// 然后按是否有时间排序
|
||||
const aHasTime = !!(a.startTime || a.endTime)
|
||||
const bHasTime = !!(b.startTime || b.endTime)
|
||||
|
||||
if (aHasTime !== bHasTime) {
|
||||
return aHasTime ? -1 : 1 // 有时间的排在前面
|
||||
}
|
||||
|
||||
// 如果都有时间,按开始时间排序
|
||||
if (aHasTime && bHasTime) {
|
||||
if (a.startTime && b.startTime) {
|
||||
return a.startTime.localeCompare(b.startTime)
|
||||
} else if (a.startTime) {
|
||||
return -1
|
||||
} else if (b.startTime) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// 最后按名称排序
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '获取播出时段失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统设置
|
||||
const fetchSystemSettings = async () => {
|
||||
if (!isAdmin.value) return
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const data = await $fetch('/api/admin/system-settings', {
|
||||
...authConfig
|
||||
})
|
||||
enablePlayTimeSelection.value = data.enablePlayTimeSelection
|
||||
} catch (err: any) {
|
||||
console.error('获取系统设置失败:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换全局启用状态
|
||||
const toggleGlobalEnabled = async () => {
|
||||
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
|
||||
await updateSystemSettings()
|
||||
}
|
||||
|
||||
// 更新系统设置
|
||||
const updateSystemSettings = async () => {
|
||||
if (!isAdmin.value) return
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
await $fetch('/api/admin/system-settings', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
enablePlayTimeSelection: enablePlayTimeSelection.value
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
showNotification('系统设置已更新', 'success')
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '更新系统设置失败'
|
||||
showNotification(error.value, 'error')
|
||||
// 如果失败,恢复状态
|
||||
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
|
||||
}
|
||||
}
|
||||
|
||||
// 打开添加表单
|
||||
const openAddForm = () => {
|
||||
showAddForm.value = true
|
||||
Object.assign(formData, {
|
||||
id: 0,
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
description: '',
|
||||
enabled: true
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑播出时段
|
||||
const editPlayTime = (playTime: PlayTime) => {
|
||||
editingPlayTime.value = playTime
|
||||
Object.assign(formData, {
|
||||
id: playTime.id,
|
||||
name: playTime.name,
|
||||
startTime: playTime.startTime || '',
|
||||
endTime: playTime.endTime || '',
|
||||
description: playTime.description || '',
|
||||
enabled: playTime.enabled
|
||||
})
|
||||
}
|
||||
|
||||
// 切换播出时段状态
|
||||
const togglePlayTimeStatus = async (playTime: PlayTime) => {
|
||||
if (!isAdmin.value) return
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
await $fetch(`/api/admin/play-times/${playTime.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
enabled: !playTime.enabled
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
await fetchPlayTimes()
|
||||
showNotification(playTime.enabled ? '播出时段已禁用' : '播出时段已启用', 'success')
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '更新播出时段状态失败'
|
||||
showNotification(error.value, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = (playTime: PlayTime) => {
|
||||
playTimeToDelete.value = playTime
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
// 删除播出时段
|
||||
const deletePlayTime = async () => {
|
||||
if (!playTimeToDelete.value || !isAdmin.value) return
|
||||
|
||||
deleteInProgress.value = true
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
await $fetch(`/api/admin/play-times/${playTimeToDelete.value.id}`, {
|
||||
method: 'DELETE',
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
await fetchPlayTimes()
|
||||
showDeleteConfirm.value = false
|
||||
playTimeToDelete.value = null
|
||||
showNotification('播出时段已删除', 'success')
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '删除播出时段失败'
|
||||
showNotification(error.value, 'error')
|
||||
} finally {
|
||||
deleteInProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存播出时段
|
||||
const savePlayTime = async () => {
|
||||
formError.value = ''
|
||||
|
||||
// 时间验证(仅当两个时间都有填写时才进行比较)
|
||||
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
|
||||
formError.value = '开始时间必须早于结束时间'
|
||||
return
|
||||
}
|
||||
|
||||
// 至少要有名称
|
||||
if (!formData.name.trim()) {
|
||||
formError.value = '时段名称不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
// 检查名称是否重复
|
||||
const isUpdate = !!editingPlayTime.value
|
||||
const nameExists = playTimes.value.some(
|
||||
(pt) =>
|
||||
pt.name.toLowerCase() === formData.name.trim().toLowerCase() &&
|
||||
(!isUpdate || pt.id !== formData.id)
|
||||
)
|
||||
|
||||
if (nameExists) {
|
||||
formError.value = '播出时段名称已存在,请使用其他名称'
|
||||
return
|
||||
}
|
||||
|
||||
formSubmitting.value = true
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
await $fetch(isUpdate ? `/api/admin/play-times/${formData.id}` : '/api/admin/play-times', {
|
||||
method: isUpdate ? 'PUT' : 'POST',
|
||||
body: {
|
||||
name: formData.name.trim(),
|
||||
startTime: formData.startTime || null,
|
||||
endTime: formData.endTime || null,
|
||||
description: formData.description || null,
|
||||
enabled: formData.enabled
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
await fetchPlayTimes()
|
||||
cancelForm()
|
||||
showNotification(isUpdate ? '播出时段已更新' : '播出时段已创建', 'success')
|
||||
} catch (err: any) {
|
||||
formError.value = err.message || '保存播出时段失败'
|
||||
showNotification(formError.value, 'error')
|
||||
} finally {
|
||||
formSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消表单
|
||||
const cancelForm = () => {
|
||||
showAddForm.value = false
|
||||
editingPlayTime.value = null
|
||||
formError.value = ''
|
||||
|
||||
// 重置表单数据
|
||||
Object.assign(formData, {
|
||||
id: 0,
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
description: '',
|
||||
enabled: true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .relative,
|
||||
.modal-leave-active .relative {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.modal-enter-from .relative,
|
||||
.modal-leave-to .relative {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,857 @@
|
||||
<template>
|
||||
<div class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2">
|
||||
<!-- 顶部标题和全局开关 -->
|
||||
<div class="flex flex-col xl:flex-row xl:items-end justify-between gap-6">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">投稿开放管理</h2>
|
||||
<p class="text-xs text-zinc-500">管理全校师生点歌投稿的开放时间、限制数量及全局状态控制</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-4 bg-zinc-900/40 border border-zinc-800 p-2 rounded-3xl"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-3 border-r border-zinc-800/50 mr-1">
|
||||
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||
>当前状态</span
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[10px] font-black uppercase transition-all',
|
||||
hitRequestTime ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'
|
||||
]"
|
||||
>
|
||||
{{ hitRequestTime ? '已开放' : '已关闭' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 px-2">
|
||||
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest"
|
||||
>启用投稿</span
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'relative w-10 h-5 rounded-full transition-colors',
|
||||
enableRequest ? 'bg-blue-600' : 'bg-zinc-800'
|
||||
]"
|
||||
@click="toggleGlobalRequest"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
|
||||
enableRequest ? 'left-6' : 'left-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 px-2 border-l border-zinc-800/50">
|
||||
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest"
|
||||
>启用时段限制</span
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'relative w-10 h-5 rounded-full transition-colors',
|
||||
enableRequestTimeLimitation ? 'bg-purple-600' : 'bg-zinc-800'
|
||||
]"
|
||||
@click="toggleTimeLimitation"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
|
||||
enableRequestTimeLimitation ? 'left-6' : 'left-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- 添加时段按钮卡片 -->
|
||||
<button
|
||||
class="group relative h-full min-h-[220px] bg-zinc-900/20 border-2 border-dashed border-zinc-800 rounded-3xl flex flex-col items-center justify-center gap-4 hover:bg-blue-600/5 hover:border-blue-500/40 transition-all active:scale-95"
|
||||
@click="showAddForm = true"
|
||||
>
|
||||
<div
|
||||
class="w-14 h-14 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-500 group-hover:text-blue-500 group-hover:bg-blue-600/10 group-hover:border-blue-500/20 transition-all"
|
||||
>
|
||||
<Plus :size="28" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h4
|
||||
class="text-sm font-black text-zinc-400 group-hover:text-blue-400 transition-colors uppercase tracking-widest"
|
||||
>
|
||||
添加投稿时段
|
||||
</h4>
|
||||
<p class="text-[10px] text-zinc-600 mt-1">创建一个新的投稿开放周期</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- 时段列表 -->
|
||||
<TransitionGroup
|
||||
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-for="slot in requestTimes"
|
||||
:key="slot.id"
|
||||
:class="[
|
||||
'group bg-zinc-900/40 border rounded-3xl p-8 space-y-6 transition-all hover:shadow-2xl hover:shadow-black/40',
|
||||
slot.enabled && !slot.past ? 'border-zinc-800' : 'border-zinc-800/40 opacity-60'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div
|
||||
:class="[
|
||||
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 text-zinc-400 transition-colors',
|
||||
slot.enabled && !slot.past
|
||||
? 'text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/10'
|
||||
: ''
|
||||
]"
|
||||
>
|
||||
<Calendar :size="22" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
|
||||
slot.past
|
||||
? 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
: slot.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-zinc-800/50 text-zinc-600 border-zinc-700/50'
|
||||
]"
|
||||
>
|
||||
{{ slot.past ? '已过期' : slot.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4
|
||||
class="text-lg font-black text-zinc-100 flex items-baseline gap-2 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ slot.name }}
|
||||
<span class="text-xs font-bold text-zinc-600 tracking-tight"
|
||||
>({{ slot.accepted }}/{{ slot.expected || '∞' }})</span
|
||||
>
|
||||
</h4>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-center gap-2 text-zinc-500 font-bold">
|
||||
<Clock :size="12" class="text-zinc-700" />
|
||||
<span class="text-[10px] uppercase tracking-tighter truncate">{{
|
||||
slot.startTime || '不限'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-zinc-500 font-bold">
|
||||
<div class="w-3 h-[2px] bg-zinc-800 ml-1.5" />
|
||||
<span class="text-[10px] uppercase tracking-tighter truncate">{{
|
||||
slot.endTime || '不限'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-xs text-zinc-500 font-medium leading-relaxed min-h-[32px] line-clamp-2 italic"
|
||||
>
|
||||
{{ slot.description || '暂无详细描述...' }}
|
||||
</p>
|
||||
|
||||
<!-- 投稿进度条 -->
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
class="flex justify-between text-[9px] font-black text-zinc-600 uppercase tracking-widest px-0.5"
|
||||
>
|
||||
<span>投稿进度</span>
|
||||
<span
|
||||
>{{
|
||||
slot.expected > 0
|
||||
? Math.min(100, Math.round((slot.accepted / slot.expected) * 100))
|
||||
: 0
|
||||
}}%</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="h-1.5 w-full bg-zinc-950 rounded-full overflow-hidden border border-zinc-800/50"
|
||||
>
|
||||
<div
|
||||
class="h-full transition-all duration-500"
|
||||
:style="{
|
||||
width: `${slot.expected > 0 ? Math.min(100, (slot.accepted / slot.expected) * 100) : 0}%`
|
||||
}"
|
||||
:class="
|
||||
slot.expected > 0 && slot.accepted >= slot.expected
|
||||
? 'bg-rose-500'
|
||||
: 'bg-blue-500'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-6 border-t border-zinc-800/50 flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-show="!slot.past"
|
||||
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-blue-400 hover:border-blue-500/30 transition-all"
|
||||
@click="editRequestTime(slot)"
|
||||
>
|
||||
<Edit2 :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-red-400 hover:border-red-500/30 transition-all"
|
||||
@click="confirmDelete(slot)"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-show="!slot.past"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
slot.enabled
|
||||
? 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
|
||||
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
|
||||
]"
|
||||
@click="toggleRequestTimeStatus(slot)"
|
||||
>
|
||||
<Power :size="12" />
|
||||
{{ slot.enabled ? '禁用' : '启用' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- 统计概览 -->
|
||||
<div
|
||||
class="bg-zinc-900/20 border border-zinc-800 rounded-3xl p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
||||
>
|
||||
<div v-for="(stat, i) in stats" :key="i" class="flex items-center gap-4 group">
|
||||
<div
|
||||
:class="[
|
||||
'w-12 h-12 rounded-2xl bg-zinc-950 border border-zinc-800 flex items-center justify-center text-zinc-500 transition-colors',
|
||||
`group-hover:text-${stat.color}-500`
|
||||
]"
|
||||
>
|
||||
<component :is="stat.icon" :size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
|
||||
{{ stat.label }}
|
||||
</p>
|
||||
<h5 class="text-xl font-black text-zinc-200">{{ stat.value }}</h5>
|
||||
</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="showAddForm || editingRequestTime"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
|
||||
@click.self="cancelForm"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<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">
|
||||
{{ editingRequestTime ? '编辑投稿开放时段' : '添加投稿开放时段' }}
|
||||
</h3>
|
||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="cancelForm">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-8 space-y-6 max-h-[70vh] overflow-y-auto">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>时段名称</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
placeholder="例如: 2025冬季常规点歌"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>开始日期时间</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.startTime"
|
||||
type="datetime-local"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>结束日期时间</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.endTime"
|
||||
type="datetime-local"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>计划投稿数</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<input
|
||||
v-model="formData.expected"
|
||||
type="number"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
<div
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-zinc-600 font-bold text-[10px]"
|
||||
>
|
||||
<Hash :size="12" />
|
||||
首歌曲
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[9px] text-zinc-600 px-1">填 0 表示不限制投稿总数</p>
|
||||
</div>
|
||||
<div class="space-y-2 flex flex-col justify-center pt-2">
|
||||
<label class="flex items-center gap-3 cursor-pointer group px-1">
|
||||
<input
|
||||
v-model="formData.enabled"
|
||||
type="checkbox"
|
||||
class="w-4.5 h-4.5 rounded-lg border-zinc-800 bg-zinc-950 accent-blue-600"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
|
||||
>启用此投稿时段</span
|
||||
>
|
||||
<p class="text-[9px] text-zinc-600 font-medium">
|
||||
只有启用的时段才会根据时间限制生效
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>时段描述 (可选)</label
|
||||
>
|
||||
<textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入针对此开放周期的特殊规则或介绍信息..."
|
||||
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 min-h-[100px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-500 text-xs font-bold"
|
||||
>
|
||||
<AlertCircle :size="16" />
|
||||
{{ formError }}
|
||||
</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="cancelForm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="formSubmitting"
|
||||
class="px-10 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 disabled:opacity-50"
|
||||
@click="saveRequestTime"
|
||||
>
|
||||
{{ formSubmitting ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<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="showDeleteConfirm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
|
||||
@click.self="showDeleteConfirm = false"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden p-8"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-6">
|
||||
<div
|
||||
class="w-16 h-16 rounded-[2rem] bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/20 shadow-xl shadow-red-900/10"
|
||||
>
|
||||
<Trash2 :size="28" />
|
||||
</div>
|
||||
<div class="text-center space-y-2 px-4">
|
||||
<h4 class="text-lg font-bold text-zinc-100">
|
||||
确定要彻底移除 "{{ RequestTimeToDelete?.name }}" 吗?
|
||||
</h4>
|
||||
<p class="text-xs text-zinc-500 leading-relaxed">
|
||||
删除时段后,原本属于此周期的投稿关联记录可能失效。建议先尝试
|
||||
<span class="text-zinc-300 font-bold">禁用</span> 该时段。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full pt-4">
|
||||
<button
|
||||
class="flex-1 px-4 py-3 bg-zinc-900 hover:bg-zinc-800 text-zinc-500 text-xs font-black rounded-2xl transition-all"
|
||||
@click="showDeleteConfirm = false"
|
||||
>
|
||||
保留时段
|
||||
</button>
|
||||
<button
|
||||
:disabled="deleteInProgress"
|
||||
class="flex-1 px-4 py-3 bg-red-600 hover:bg-red-500 text-white text-xs font-black rounded-2xl shadow-xl shadow-red-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
@click="deleteRequestTime"
|
||||
>
|
||||
{{ deleteInProgress ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 说明部分 -->
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="flex-1 bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-4">
|
||||
<h4
|
||||
class="text-xs font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle2 :size="14" class="text-emerald-500" /> 逻辑优先级说明
|
||||
</h4>
|
||||
<ul class="text-[11px] text-zinc-500 space-y-2 font-medium">
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">1.</span>
|
||||
<span
|
||||
><span class="text-zinc-300">全局开关</span>
|
||||
具有最高优先级,关闭后所有投稿均不可用。</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">2.</span>
|
||||
<span
|
||||
>开启 <span class="text-zinc-300">时段限制</span> 后,系统会检查当前时间是否处于任一
|
||||
<span class="text-zinc-300">已启用</span> 且未达到
|
||||
<span class="text-zinc-300">计划数</span> 的时段内。</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">3.</span>
|
||||
<span>若关闭时段限制,则全天候均可投稿(只要全局开关开启)。</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-4">
|
||||
<h4
|
||||
class="text-xs font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<XCircle :size="14" class="text-rose-500" /> 注意事项
|
||||
</h4>
|
||||
<ul class="text-[11px] text-zinc-500 space-y-2 font-medium">
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">•</span>
|
||||
<span>时段重叠时,系统将合并开放范围。</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">•</span>
|
||||
<span>计划投稿数设置为 0 时,系统将忽略数量上限检查。</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">•</span>
|
||||
<span>过期的时段将自动显示为“已过期”,不可编辑或重新启用。</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, reactive, ref, computed } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import type { RequestTime } from '~/types'
|
||||
import {
|
||||
Plus,
|
||||
Calendar,
|
||||
Clock,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Power,
|
||||
AlertCircle,
|
||||
Hash,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
X,
|
||||
BarChart3,
|
||||
Filter
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { getAuthConfig, isAdmin } = useAuth()
|
||||
|
||||
const requestTimes = ref<RequestTime[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const showAddForm = ref(false)
|
||||
const editingRequestTime = ref<RequestTime | null>(null)
|
||||
const RequestTimeToDelete = ref<RequestTime | null>(null)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const formSubmitting = ref(false)
|
||||
const deleteInProgress = ref(false)
|
||||
const formError = ref('')
|
||||
const enableRequestTimeLimitation = ref(false)
|
||||
const hitRequestTime = ref(false)
|
||||
const enableRequest = ref(true)
|
||||
|
||||
let refreshInterval: any = null
|
||||
|
||||
const formData = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
expected: 0
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: '活跃时段',
|
||||
value: requestTimes.value.filter((s) => s.enabled && !s.past).length.toString(),
|
||||
icon: Clock,
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
label: '累计已接收',
|
||||
value: requestTimes.value.reduce((acc, s) => acc + s.accepted, 0).toString(),
|
||||
icon: BarChart3,
|
||||
color: 'emerald'
|
||||
},
|
||||
{
|
||||
label: '计划总容量',
|
||||
value: requestTimes.value.reduce((acc, s) => acc + (s.expected || 0), 0) || '不限',
|
||||
icon: Hash,
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
label: '剩余总名额',
|
||||
value: (() => {
|
||||
const totalExpected = requestTimes.value.reduce((acc, s) => acc + (s.expected || 0), 0)
|
||||
const totalAccepted = requestTimes.value.reduce((acc, s) => acc + s.accepted, 0)
|
||||
return totalExpected > 0 ? Math.max(0, totalExpected - totalAccepted).toString() : '不限'
|
||||
})(),
|
||||
icon: Filter,
|
||||
color: 'amber'
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchRequestTimes()
|
||||
await fetchSystemSettings()
|
||||
await fetchRequestTimeHit()
|
||||
|
||||
// 每 30 秒自动刷新一次状态,以确保时间同步
|
||||
refreshInterval = setInterval(fetchRequestTimeHit, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) clearInterval(refreshInterval)
|
||||
})
|
||||
|
||||
const fetchRequestTimes = async () => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = '只有管理员才能管理投稿开放时段'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch('/api/admin/request-times', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `获取投稿开放时段失败: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
requestTimes.value = data.sort((a: RequestTime, b: RequestTime) => {
|
||||
if (a.past !== b.past) return a.past ? 1 : -1
|
||||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
|
||||
const aHasTime = !!(a.startTime || a.endTime)
|
||||
const bHasTime = !!(b.startTime || b.endTime)
|
||||
if (aHasTime !== bHasTime) return aHasTime ? -1 : 1
|
||||
if (aHasTime && bHasTime) {
|
||||
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime)
|
||||
else if (a.startTime) return -1
|
||||
else if (b.startTime) return 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '获取投稿开放时段失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSystemSettings = async () => {
|
||||
if (!isAdmin.value) return
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch('/api/admin/system-settings', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...authConfig
|
||||
})
|
||||
if (!response.ok) return
|
||||
const data = await response.json()
|
||||
enableRequestTimeLimitation.value = data.enableRequestTimeLimitation
|
||||
enableRequest.value = !data.forceBlockAllRequests
|
||||
} catch (err: any) {
|
||||
console.error('获取系统设置失败:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRequestTimeHit = async () => {
|
||||
if (!isAdmin.value) return
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch('/api/request-times', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...authConfig
|
||||
})
|
||||
if (!response.ok) return
|
||||
const data = await response.json()
|
||||
hitRequestTime.value = data.hit
|
||||
} catch (err: any) {
|
||||
console.error('获取投稿开放状态失败:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleGlobalRequest = async () => {
|
||||
enableRequest.value = !enableRequest.value
|
||||
await updateSystemSettings()
|
||||
await fetchRequestTimeHit()
|
||||
}
|
||||
|
||||
const toggleTimeLimitation = async () => {
|
||||
enableRequestTimeLimitation.value = !enableRequestTimeLimitation.value
|
||||
await updateSystemSettings()
|
||||
await fetchRequestTimeHit()
|
||||
}
|
||||
|
||||
const updateSystemSettings = async () => {
|
||||
if (!isAdmin.value) return
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch('/api/admin/system-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enableRequestTimeLimitation: enableRequestTimeLimitation.value,
|
||||
forceBlockAllRequests: !enableRequest.value
|
||||
}),
|
||||
...authConfig
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `更新系统设置失败: ${response.status}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '更新系统设置失败'
|
||||
}
|
||||
}
|
||||
|
||||
const editRequestTime = (RequestTime: RequestTime) => {
|
||||
editingRequestTime.value = RequestTime
|
||||
Object.assign(formData, {
|
||||
id: RequestTime.id,
|
||||
name: RequestTime.name,
|
||||
startTime: RequestTime.startTime,
|
||||
endTime: RequestTime.endTime,
|
||||
description: RequestTime.description || '',
|
||||
enabled: RequestTime.enabled,
|
||||
expected: RequestTime.expected || 0
|
||||
})
|
||||
}
|
||||
|
||||
const toggleRequestTimeStatus = async (RequestTime: RequestTime) => {
|
||||
if (!isAdmin.value) return
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch(`/api/admin/request-times/${RequestTime.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !RequestTime.enabled }),
|
||||
...authConfig
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `更新投稿开放时段状态失败: ${response.status}`)
|
||||
}
|
||||
await fetchRequestTimes()
|
||||
await fetchRequestTimeHit()
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '更新投稿开放时段状态失败'
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (RequestTime: RequestTime) => {
|
||||
RequestTimeToDelete.value = RequestTime
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
const deleteRequestTime = async () => {
|
||||
if (!RequestTimeToDelete.value || !isAdmin.value) return
|
||||
deleteInProgress.value = true
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch(`/api/admin/request-times/${RequestTimeToDelete.value.id}`, {
|
||||
method: 'DELETE',
|
||||
...authConfig
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `删除投稿开放时段失败: ${response.status}`)
|
||||
}
|
||||
await fetchRequestTimes()
|
||||
await fetchRequestTimeHit()
|
||||
showDeleteConfirm.value = false
|
||||
RequestTimeToDelete.value = null
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '删除投稿开放时段失败'
|
||||
} finally {
|
||||
deleteInProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveRequestTime = async () => {
|
||||
formError.value = ''
|
||||
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
|
||||
formError.value = '开始时间必须早于结束时间'
|
||||
return
|
||||
}
|
||||
if (!formData.name.trim()) {
|
||||
formError.value = '时段名称不能为空'
|
||||
return
|
||||
}
|
||||
const isUpdate = !!editingRequestTime.value
|
||||
const nameExists = requestTimes.value.some(
|
||||
(pt) =>
|
||||
pt.name.toLowerCase() === formData.name.trim().toLowerCase() &&
|
||||
(!isUpdate || pt.id !== formData.id)
|
||||
)
|
||||
if (nameExists) {
|
||||
formError.value = '投稿开放时段名称已存在,请使用其他名称'
|
||||
return
|
||||
}
|
||||
formSubmitting.value = true
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch(
|
||||
isUpdate ? `/api/admin/request-times/${formData.id}` : '/api/admin/request-times',
|
||||
{
|
||||
method: isUpdate ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim(),
|
||||
startTime: formData.startTime || null,
|
||||
endTime: formData.endTime || null,
|
||||
description: formData.description || null,
|
||||
enabled: formData.enabled,
|
||||
expected: formData.expected || 0
|
||||
}),
|
||||
...authConfig
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(
|
||||
errorData.message || `${isUpdate ? '更新' : '创建'}投稿开放时段失败: ${response.status}`
|
||||
)
|
||||
}
|
||||
await fetchRequestTimes()
|
||||
await fetchRequestTimeHit()
|
||||
cancelForm()
|
||||
} catch (err: any) {
|
||||
formError.value = err.message || '保存投稿开放时段失败'
|
||||
} finally {
|
||||
formSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelForm = () => {
|
||||
showAddForm.value = false
|
||||
editingRequestTime.value = null
|
||||
formError.value = ''
|
||||
Object.assign(formData, {
|
||||
id: 0,
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
expected: 0
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div
|
||||
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-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-zinc-100" for="playDate">播放日期</label>
|
||||
<input
|
||||
id="playDate"
|
||||
v-model="playDate"
|
||||
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"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 播出时段选择 -->
|
||||
<div v-if="playTimeEnabled" class="mb-4">
|
||||
<CustomSelect
|
||||
v-model="playTimeId"
|
||||
:options="playTimeOptions"
|
||||
label="播出时段"
|
||||
placeholder="未指定"
|
||||
class-name="w-full"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="song?.preferredPlayTime"
|
||||
class="mt-3 p-3 bg-white/5 rounded-lg text-sm flex items-start gap-2"
|
||||
>
|
||||
<div class="text-base">💡</div>
|
||||
<div>
|
||||
用户期望的播出时段:
|
||||
<span class="font-medium text-indigo-300">
|
||||
{{ song.preferredPlayTime.name }}
|
||||
<template v-if="song.preferredPlayTime.startTime || song.preferredPlayTime.endTime">
|
||||
({{ formatPlayTimeRange(song.preferredPlayTime) }})
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-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')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
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 ? '创建中...' : '创建排期' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
song: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['schedule', 'cancel'])
|
||||
|
||||
const playDate = ref('')
|
||||
const playTimeId = ref('')
|
||||
const error = ref('')
|
||||
const playTimes = ref([])
|
||||
const { playTimeEnabled } = useSongs()
|
||||
|
||||
// 转换播出时段为 CustomSelect 选项格式
|
||||
const playTimeOptions = computed(() => {
|
||||
const options = [{ label: '未指定', value: '' }]
|
||||
|
||||
if (playTimes.value && playTimes.value.length > 0) {
|
||||
playTimes.value.forEach((pt) => {
|
||||
options.push({
|
||||
label: `${pt.name} (${pt.startTime} - ${pt.endTime})`,
|
||||
value: pt.id
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
await fetchPlayTimes()
|
||||
|
||||
// 如果歌曲有期望的播出时段,默认选择该时段
|
||||
if (props.song?.preferredPlayTimeId) {
|
||||
playTimeId.value = props.song.preferredPlayTimeId
|
||||
}
|
||||
})
|
||||
|
||||
// 获取播出时段
|
||||
const fetchPlayTimes = async () => {
|
||||
try {
|
||||
// 使用useSongs中的方法获取播放时段
|
||||
await useSongs().fetchPlayTimes()
|
||||
const response = await fetch('/api/admin/play-times')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// 只显示启用的播放时段
|
||||
playTimes.value = data.filter((pt) => pt.enabled)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取播出时段失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化播出时段时间范围
|
||||
const formatPlayTimeRange = (playTime) => {
|
||||
if (!playTime) return ''
|
||||
|
||||
if (playTime.startTime && playTime.endTime) {
|
||||
return `${playTime.startTime} - ${playTime.endTime}`
|
||||
} else if (playTime.startTime) {
|
||||
return `${playTime.startTime} 开始`
|
||||
} else if (playTime.endTime) {
|
||||
return `${playTime.endTime} 结束`
|
||||
}
|
||||
|
||||
return '不限时间'
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
error.value = ''
|
||||
|
||||
if (!playDate.value) {
|
||||
error.value = '请选择播放日期'
|
||||
return
|
||||
}
|
||||
|
||||
const selectedDate = new Date(playDate.value)
|
||||
|
||||
// 播出时段ID需要转换为数字或null
|
||||
const schedulePlayTimeId = playTimeId.value ? parseInt(playTimeId.value) : null
|
||||
|
||||
emit('schedule', {
|
||||
songId: props.song.id,
|
||||
playDate: selectedDate,
|
||||
playTimeId: schedulePlayTimeId
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="schedule-item-print">
|
||||
<div class="item-content">
|
||||
<!-- 序号 -->
|
||||
<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"
|
||||
: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>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="song-info">
|
||||
<div v-if="settings.showTitle" class="song-title">
|
||||
{{ schedule.song.title }}
|
||||
<!-- 重播标识 -->
|
||||
<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" 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>
|
||||
</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>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from 'vue'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
|
||||
// 定义props
|
||||
defineProps({
|
||||
schedule: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
settings: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleImageError = (event) => {
|
||||
event.target.style.display = 'none'
|
||||
event.target.nextElementSibling?.classList.add('show')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.schedule-item-print {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sequence-number {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover-section {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.cover-placeholder.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cover-placeholder svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 打印用重播标识 */
|
||||
.replay-badge-print {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #2196f3;
|
||||
border-radius: 3px;
|
||||
color: #1976d2;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.requester-info,
|
||||
.votes-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.schedule-item-print {
|
||||
color: #000 !important;
|
||||
background: #fff !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
width: 100% !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
.sequence-number {
|
||||
background: #f0f0f0 !important;
|
||||
color: #000 !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.cover-section {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
color: #000 !important;
|
||||
font-size: 16px !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
.song-artist,
|
||||
.label,
|
||||
.value {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.time-range {
|
||||
color: #666 !important;
|
||||
}
|
||||
|
||||
.requester-info,
|
||||
.votes-info,
|
||||
.playtime-info {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
font-size: 12px !important;
|
||||
white-space: nowrap !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 紧凑模式 */
|
||||
.compact .schedule-item-print {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.compact .item-content {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compact .sequence-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.compact .cover-section {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.compact .song-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.compact .song-artist {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.compact .requester-info,
|
||||
.compact .votes-info,
|
||||
.compact .playtime-info {
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,638 @@
|
||||
<template>
|
||||
<div class="max-w-[1200px] mx-auto space-y-10 pb-20 px-2">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<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-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"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
添加新学期
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
||||
<!-- 左侧栏:当前活跃学期 -->
|
||||
<div class="lg:col-span-5 space-y-6">
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<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"
|
||||
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-zinc-600 uppercase tracking-[0.2em]">
|
||||
当前活跃学期
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentSemester"
|
||||
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-white/10 backdrop-blur-md flex items-center justify-center text-white border border-white/20"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<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>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-3xl font-black text-white tracking-tight leading-tight">
|
||||
{{ currentSemester.name }}
|
||||
</h4>
|
||||
<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"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
创建于 {{ formatDate(currentSemester.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<span
|
||||
class="px-4 py-2 bg-white text-blue-600 text-[10px] font-black uppercase tracking-widest rounded-xl shadow-lg"
|
||||
>
|
||||
正在进行中
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装饰性背景元素 -->
|
||||
<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"
|
||||
width="160"
|
||||
height="160"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
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" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
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"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
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-zinc-600">未设置当前活跃学期</p>
|
||||
<button class="text-xs text-blue-500 font-black hover:underline" @click="openAddModal">
|
||||
立即创建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
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-zinc-400">学期切换说明</p>
|
||||
<p class="text-[11px] text-zinc-600 leading-relaxed">
|
||||
切换学期后,前端点歌界面将清空旧排期。所有历史数据会自动归档至对应学期下,您仍可在数据中心进行查看。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧栏:学期历史记录 -->
|
||||
<div class="lg:col-span-7 space-y-6">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<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"
|
||||
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-zinc-600 uppercase tracking-[0.2em]">
|
||||
学期历史记录
|
||||
</h3>
|
||||
</div>
|
||||
<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-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
|
||||
>
|
||||
<p class="text-xs font-bold text-zinc-600">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="sem in sortedSemesters"
|
||||
:key="sem.id"
|
||||
class="group flex items-center justify-between p-6 rounded-[2rem] border transition-all"
|
||||
:class="
|
||||
sem.isActive
|
||||
? '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">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl flex items-center justify-center transition-all"
|
||||
:class="
|
||||
sem.isActive
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'bg-zinc-800 text-zinc-600 border border-zinc-700'
|
||||
"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<polyline points="21 8 21 21 3 21 3 8" />
|
||||
<rect x="1" y="3" width="22" height="5" />
|
||||
<line x1="10" y1="12" x2="14" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h5
|
||||
class="font-bold transition-colors"
|
||||
:class="
|
||||
sem.isActive ? 'text-blue-400' : 'text-zinc-200 group-hover:text-blue-400'
|
||||
"
|
||||
>
|
||||
{{ sem.name }}
|
||||
</h5>
|
||||
<p class="text-[10px] text-zinc-600 font-medium uppercase tracking-widest mt-0.5">
|
||||
创建于 {{ formatDate(sem.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="sem.isActive"
|
||||
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"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<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">当前学期</span>
|
||||
</div>
|
||||
<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)"
|
||||
>
|
||||
<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)"
|
||||
>
|
||||
<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-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
|
||||
>
|
||||
<p class="text-xs font-bold text-zinc-600">暂无学期数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加学期弹窗 -->
|
||||
<div
|
||||
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-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-white tracking-tight">添加新学期</h3>
|
||||
<button
|
||||
class="p-2 text-zinc-500 hover:text-white transition-colors"
|
||||
@click="closeAddModal"
|
||||
>
|
||||
<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"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>学期名称</label
|
||||
>
|
||||
<input
|
||||
v-model="newSemester.name"
|
||||
type="text"
|
||||
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 class="flex items-center gap-3 cursor-pointer group px-1">
|
||||
<input
|
||||
v-model="newSemester.isActive"
|
||||
type="checkbox"
|
||||
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-zinc-300 group-hover:text-blue-400 transition-colors"
|
||||
>设为当前活跃学期</span
|
||||
>
|
||||
<p class="text-[10px] text-zinc-600 font-medium">
|
||||
创建后将立即激活此学期,原学期将变为历史状态
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<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"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
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-zinc-500 leading-normal">
|
||||
提示:请按照统一的命名规范设置学期名称,这有助于数据归档后的检索与分析。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@click="closeAddModal"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
: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 ? '创建中...' : '创建学期' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认删除对话框 -->
|
||||
<ConfirmDialog
|
||||
:loading="loading"
|
||||
:show="showDeleteDialog"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认删除"
|
||||
:message="deleteMessage"
|
||||
title="确认删除学期"
|
||||
type="danger"
|
||||
@close="showDeleteDialog = false"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
|
||||
const {
|
||||
semesters,
|
||||
currentSemester,
|
||||
loading,
|
||||
error,
|
||||
fetchSemesters,
|
||||
fetchCurrentSemester,
|
||||
createSemester,
|
||||
setActiveSemester,
|
||||
deleteSemester: deleteSemesterAPI
|
||||
} = useSemesters()
|
||||
|
||||
const showAddModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTargetId = ref(null)
|
||||
const deleteTargetName = ref('')
|
||||
const submitting = ref(false)
|
||||
const newSemester = ref({
|
||||
name: '',
|
||||
isActive: false
|
||||
})
|
||||
|
||||
// 按创建时间排序
|
||||
const sortedSemesters = computed(() => {
|
||||
return [...semesters.value].sort((a, b) => {
|
||||
return new Date(b.createdAt) - new Date(a.createdAt)
|
||||
})
|
||||
})
|
||||
|
||||
// 删除确认信息
|
||||
const deleteMessage = computed(() => {
|
||||
return `确定要彻底删除 "${deleteTargetName.value}" 吗?此操作将同步清除该学期下的所有点歌记录、排期数据和统计信息。数据一旦删除将无法找回。`
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 设置活跃学期
|
||||
const setActive = async (semesterId) => {
|
||||
try {
|
||||
const success = await setActiveSemester(semesterId)
|
||||
// 注意:这里需要重新获取error,因为useSemesters内部可能没有正确更新error的响应式引用
|
||||
// 或者直接依据success返回值判断
|
||||
if (success) {
|
||||
showNotification('活跃学期设置成功!', 'success')
|
||||
} else {
|
||||
// 如果setActiveSemester返回false,尝试直接显示一个通用错误,因为error可能为空
|
||||
showNotification(error.value || '设置活跃学期失败,请稍后重试', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err.message || '设置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 准备删除学期
|
||||
const deleteSemester = (semesterId) => {
|
||||
const semester = semesters.value.find((s) => s.id === semesterId)
|
||||
if (semester) {
|
||||
deleteTargetId.value = semesterId
|
||||
deleteTargetName.value = semester.name
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetId.value) return
|
||||
|
||||
try {
|
||||
const success = await deleteSemesterAPI(deleteTargetId.value)
|
||||
if (success) {
|
||||
showNotification('学期删除成功!', 'success')
|
||||
} else {
|
||||
showNotification(error.value || '删除学期失败', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err.message || '删除失败', 'error')
|
||||
}
|
||||
|
||||
showDeleteDialog.value = false
|
||||
deleteTargetId.value = null
|
||||
deleteTargetName.value = ''
|
||||
}
|
||||
|
||||
// 处理添加学期
|
||||
const handleAddSemester = async () => {
|
||||
if (!newSemester.value.name.trim()) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await createSemester({
|
||||
name: newSemester.value.name,
|
||||
isActive: newSemester.value.isActive
|
||||
})
|
||||
if (result) {
|
||||
showNotification('学期创建成功!', 'success')
|
||||
closeAddModal()
|
||||
} else {
|
||||
showNotification(error.value || '创建学期失败', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建学期失败:', err)
|
||||
showNotification(err.message || '创建学期失败', 'error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭添加弹窗
|
||||
const closeAddModal = () => {
|
||||
showAddModal.value = false
|
||||
newSemester.value = {
|
||||
name: '',
|
||||
isActive: false
|
||||
}
|
||||
}
|
||||
|
||||
const getRecommendedName = () => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = now.getMonth() + 1
|
||||
|
||||
let academicYear = year
|
||||
let term = '第一学期'
|
||||
|
||||
if (month >= 3 && month <= 8) {
|
||||
academicYear = year - 1
|
||||
term = '第二学期'
|
||||
} else if (month <= 2) {
|
||||
academicYear = year - 1
|
||||
term = '第一学期'
|
||||
} else {
|
||||
academicYear = year
|
||||
term = '第一学期'
|
||||
}
|
||||
|
||||
return `${academicYear}学年${term}`
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
newSemester.value.name = getRecommendedName()
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchCurrentSemester(), fetchSemesters()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<!-- 侧边栏组件 -->
|
||||
<template>
|
||||
<aside
|
||||
:class="[
|
||||
'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'
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col h-full p-4">
|
||||
<!-- 品牌标识区域 -->
|
||||
<div class="flex items-center px-2 mb-6 mt-2">
|
||||
<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="logo" alt="VoiceHub Logo" class="w-8 h-8 object-contain" >
|
||||
</div>
|
||||
<!-- 品牌文字 -->
|
||||
<div class="flex flex-col justify-center">
|
||||
<h1 class="font-bold text-lg text-zinc-100 leading-none tracking-tight">VoiceHub</h1>
|
||||
<p
|
||||
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
|
||||
>
|
||||
管理控制台
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单区域 -->
|
||||
<nav class="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-zinc-600 uppercase tracking-[0.2em] mb-2">
|
||||
{{ group.section }}
|
||||
</h3>
|
||||
<!-- 菜单项列表 -->
|
||||
<template v-for="item in group.items" :key="item.id">
|
||||
<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-blue-500/80 focus-visible:ring-inset',
|
||||
activeTab === item.id
|
||||
? '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'
|
||||
]"
|
||||
@click="onNavigate(item.id)"
|
||||
>
|
||||
<!-- 菜单图标 -->
|
||||
<component
|
||||
:is="item.icon"
|
||||
:size="18"
|
||||
:class="
|
||||
activeTab === item.id
|
||||
? '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-blue-400 rounded-full shadow-[0_0_8px_rgba(96,165,250,0.6)]"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 用户信息及退出登录 -->
|
||||
<div class="mt-4 pt-4 border-t border-zinc-800">
|
||||
<div
|
||||
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-zinc-700 shrink-0"
|
||||
@error="avatarError = true"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
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 || '管').charAt(0) }}
|
||||
</div>
|
||||
<!-- 用户详细信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-black truncate text-zinc-100">
|
||||
{{ currentUser?.name || '管理员' }}
|
||||
</p>
|
||||
<p
|
||||
class="text-[10px] text-zinc-500 truncate uppercase tracking-wider font-medium mt-0.5"
|
||||
>
|
||||
{{ currentUser?.role?.replace('_', ' ') || 'ADMIN' }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 退出按钮 -->
|
||||
<button
|
||||
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" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 后台管理侧边栏组件
|
||||
*/
|
||||
import {
|
||||
LayoutDashboard,
|
||||
CalendarDays,
|
||||
Printer,
|
||||
Music2,
|
||||
BarChart3,
|
||||
Users,
|
||||
Key,
|
||||
Bell,
|
||||
Mail,
|
||||
LogOut,
|
||||
Clock,
|
||||
FileEdit,
|
||||
BookOpen,
|
||||
Ban,
|
||||
Globe,
|
||||
Database,
|
||||
Lock
|
||||
} from 'lucide-vue-next'
|
||||
import logo from '~~/public/images/logo.png'
|
||||
|
||||
const avatarError = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
// 侧边栏是否打开(移动端)
|
||||
isOpen: Boolean,
|
||||
// 当前激活的标签页 ID
|
||||
activeTab: String,
|
||||
// 当前登录用户信息
|
||||
currentUser: Object,
|
||||
// 权限控制对象
|
||||
permissions: Object,
|
||||
// 站点标题
|
||||
siteTitle: String
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.currentUser?.avatar,
|
||||
() => {
|
||||
avatarError.value = false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['navigate', 'close', 'logout'])
|
||||
|
||||
// 菜单分组配置
|
||||
const menuGroups = [
|
||||
{
|
||||
section: '概览',
|
||||
items: [{ icon: LayoutDashboard, label: '数据概览', id: 'overview' }]
|
||||
},
|
||||
{
|
||||
section: '内容管理',
|
||||
items: [
|
||||
{ icon: CalendarDays, label: '排期管理', id: 'schedule' },
|
||||
{ icon: Printer, label: '打印排期', id: 'print' },
|
||||
{ icon: Music2, label: '歌曲管理', id: 'songs' },
|
||||
{ icon: BarChart3, label: '数据分析', id: 'data-analysis', permissionId: 'data-analysis' }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '用户管理',
|
||||
items: [{ icon: Users, label: '用户管理', id: 'users' }]
|
||||
},
|
||||
{
|
||||
section: 'API管理',
|
||||
items: [{ icon: Key, label: 'API密钥管理', id: 'api-keys' }]
|
||||
},
|
||||
{
|
||||
section: '系统管理',
|
||||
items: [
|
||||
{ icon: Bell, label: '通知管理', id: 'notifications' },
|
||||
{ icon: Mail, label: '邮件配置', id: 'smtp-config' },
|
||||
{ icon: Clock, label: '播出时段', id: 'playtimes' },
|
||||
{ icon: FileEdit, label: '投稿管理', id: 'request-times' },
|
||||
{ icon: BookOpen, label: '学期管理', id: 'semesters' },
|
||||
{ icon: Ban, label: '黑名单管理', id: 'blacklist' },
|
||||
{ icon: Globe, label: '站点配置', id: 'site-config' },
|
||||
{ icon: Database, label: '数据库操作', id: 'database' }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '账户管理',
|
||||
items: [{ icon: Lock, label: '修改密码', id: 'password' }]
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 判断是否应该显示该菜单组
|
||||
* @param {Object} group 菜单组对象
|
||||
*/
|
||||
const shouldShowGroup = (group) => {
|
||||
if (!props.permissions) return true
|
||||
return group.items.some((item) => props.permissions.canAccessPage(item.permissionId || item.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航点击处理
|
||||
* @param {string} id 菜单项 ID
|
||||
*/
|
||||
const onNavigate = (id) => {
|
||||
if (id === 'password') {
|
||||
navigateTo('/change-password')
|
||||
return
|
||||
}
|
||||
emit('navigate', id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色显示名称
|
||||
* @param {string} role 角色标识
|
||||
*/
|
||||
const getRoleDisplayName = (role) => {
|
||||
const roleNames = {
|
||||
USER: '普通用户',
|
||||
SONG_ADMIN: '歌曲管理员',
|
||||
ADMIN: '管理员',
|
||||
SUPER_ADMIN: '超级管理员'
|
||||
}
|
||||
return roleNames[role] || role
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,536 @@
|
||||
<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
|
||||
:disabled="loading || saving"
|
||||
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"
|
||||
@click="resetForm"
|
||||
>
|
||||
<RotateCcw :size="14" /> 重置
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading || saving"
|
||||
class="flex items-center gap-2 px-8 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 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="saveConfig"
|
||||
>
|
||||
<template v-if="saving">
|
||||
<div
|
||||
class="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
/>
|
||||
保存中...
|
||||
</template>
|
||||
<template v-else-if="saveSuccess"> <CheckCircle2 :size="14" /> 已保存 </template>
|
||||
<template v-else> <Save :size="14" /> 保存配置 </template>
|
||||
</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="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 基础信息 -->
|
||||
<section :class="cardClass">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
|
||||
>
|
||||
<Globe :size="16" class="text-blue-500" /> 基础信息
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label :class="labelClass">站点标题</label>
|
||||
<input
|
||||
v-model="formData.siteTitle"
|
||||
type="text"
|
||||
placeholder="请输入站点标题"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">备案号 (ICP)</label>
|
||||
<input
|
||||
v-model="formData.icpNumber"
|
||||
type="text"
|
||||
placeholder="请输入备案号"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">公安联网备案号</label>
|
||||
<input
|
||||
v-model="formData.gonganNumber"
|
||||
type="text"
|
||||
placeholder="请输入公安备案号 (如:陕公网安备61011302001964号)"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">站点描述</label>
|
||||
<textarea
|
||||
v-model="formData.siteDescription"
|
||||
:rows="3"
|
||||
placeholder="请输入站点描述"
|
||||
:class="[inputClass, 'resize-none']"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 视觉识别 -->
|
||||
<section :class="cardClass">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
|
||||
>
|
||||
<ImageIcon :size="16" class="text-purple-500" /> 视觉识别
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label :class="labelClass">站点 Logo URL</label>
|
||||
<input
|
||||
v-model="formData.siteLogoUrl"
|
||||
type="text"
|
||||
placeholder="请输入Logo图片URL"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">首页学校 Logo URL (大尺寸)</label>
|
||||
<input
|
||||
v-model="formData.schoolLogoHomeUrl"
|
||||
type="text"
|
||||
placeholder="请输入首页学校Logo URL"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">打印排期 Logo URL (小尺寸)</label>
|
||||
<input
|
||||
v-model="formData.schoolLogoPrintUrl"
|
||||
type="text"
|
||||
placeholder="请输入打印页学校Logo URL"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 投稿须知 -->
|
||||
<section
|
||||
class="lg:col-span-2 bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 space-y-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
|
||||
>
|
||||
<FileText :size="16" class="text-emerald-500" /> 投稿须知
|
||||
</h3>
|
||||
<textarea
|
||||
v-model="formData.submissionGuidelines"
|
||||
:rows="6"
|
||||
placeholder="请输入投稿须知内容"
|
||||
:class="[inputClass, 'font-mono text-xs leading-relaxed min-h-[150px]']"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- 投稿逻辑设置 -->
|
||||
<section :class="cardClass">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
|
||||
>
|
||||
<Settings2 :size="16" class="text-amber-500" /> 投稿逻辑设置
|
||||
</h3>
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<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="formData.enableCollaborativeSubmission"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<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="formData.enableSubmissionRemarks"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<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="formData.enableReplayRequests"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<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="formData.enableSubmissionLimit"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.enableSubmissionLimit" class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-2 p-1 bg-zinc-950 border border-zinc-800 rounded-xl">
|
||||
<button
|
||||
:class="[
|
||||
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
currentLimitType === 'daily'
|
||||
? 'bg-zinc-800 text-blue-400 shadow-sm'
|
||||
: 'text-zinc-600 hover:text-zinc-400'
|
||||
]"
|
||||
@click="handleLimitTypeChange('daily')"
|
||||
>
|
||||
每日限额
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
currentLimitType === 'weekly'
|
||||
? 'bg-zinc-800 text-blue-400 shadow-sm'
|
||||
: 'text-zinc-600 hover:text-zinc-400'
|
||||
]"
|
||||
@click="handleLimitTypeChange('weekly')"
|
||||
>
|
||||
每周限额
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
currentLimitType === 'monthly'
|
||||
? 'bg-zinc-800 text-blue-400 shadow-sm'
|
||||
: 'text-zinc-600 hover:text-zinc-400'
|
||||
]"
|
||||
@click="handleLimitTypeChange('monthly')"
|
||||
>
|
||||
每月限额
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label :class="labelClass"
|
||||
>{{ currentLimitType === 'daily' ? '单日' : (currentLimitType === 'weekly' ? '单周' : '单月') }}投稿上限</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="currentLimitValue"
|
||||
type="number"
|
||||
min="0"
|
||||
:class="inputClass"
|
||||
>
|
||||
<span
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-black text-zinc-700 uppercase"
|
||||
>首 / 人</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 安全与隐私设置 -->
|
||||
<section :class="cardClass">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
|
||||
>
|
||||
<Shield :size="16" class="text-rose-500" /> 安全与隐私设置
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="shrink-0 pt-0.5">
|
||||
<input
|
||||
id="show-keywords"
|
||||
v-model="formData.showBlacklistKeywords"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
<label for="show-keywords" class="cursor-pointer">
|
||||
<p class="text-xs font-bold text-zinc-200">显示黑名单具体关键词</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
|
||||
开启后,在投稿命中黑名单时将明确提示冲突关键词;关闭则仅提示“包含关键词”。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="shrink-0 pt-0.5">
|
||||
<input
|
||||
id="hide-students"
|
||||
v-model="formData.hideStudentInfo"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
<label for="hide-students" class="cursor-pointer">
|
||||
<p class="text-xs font-bold text-zinc-200">隐藏学生详细信息</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
|
||||
开启后,非管理员用户在前端点歌列表、排期预览中将无法查看投稿学生的完整学号与真实姓名。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-xl flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle class="text-blue-500 shrink-0 mt-0.5" :size="14" />
|
||||
<p class="text-[10px] text-zinc-500 leading-normal">
|
||||
站点基础配置在保存后将立即对所有终端生效。请在修改关键业务逻辑(如投稿限额)前确保已知晓对现有用户的影响。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
Globe,
|
||||
ImageIcon,
|
||||
FileText,
|
||||
Settings2,
|
||||
Shield,
|
||||
Save,
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saveSuccess = ref(false)
|
||||
|
||||
// 样式类常量
|
||||
const inputClass =
|
||||
'w-full bg-zinc-950 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:text-zinc-800'
|
||||
const labelClass = 'text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2'
|
||||
const cardClass = 'bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 shadow-xl space-y-6'
|
||||
|
||||
const defaultSubmissionGuidelines = `1. 投稿时无需加入书名号
|
||||
2. 除DJ外,其他类型歌曲均接收(包括小语种)
|
||||
3. 禁止投递含有违规内容的歌曲
|
||||
4. 点播的歌曲将由管理员进行审核
|
||||
5. 审核通过后将安排在播放时段播出
|
||||
6. 提交即表明我已阅读投稿须知并已知该歌曲有概率无法播出
|
||||
7. 本系统仅提供音乐搜索和播放管理功能,不存储任何音乐文件。所有音乐内容均来自第三方音乐平台,版权归原平台及版权方所有。用户点歌时请确保遵守相关音乐平台的服务条款,尊重音乐作品版权。我们鼓励用户支持正版音乐,在官方平台购买和收听喜爱的音乐作品。
|
||||
8. 最终解释权归广播站所有`
|
||||
|
||||
const formData = ref({
|
||||
siteTitle: '',
|
||||
siteLogoUrl: '',
|
||||
schoolLogoHomeUrl: '',
|
||||
schoolLogoPrintUrl: '',
|
||||
siteDescription: '',
|
||||
submissionGuidelines: '',
|
||||
icpNumber: '',
|
||||
gonganNumber: '',
|
||||
enableCollaborativeSubmission: true,
|
||||
enableSubmissionRemarks: false,
|
||||
enableReplayRequests: false,
|
||||
enableSubmissionLimit: false,
|
||||
dailySubmissionLimit: 5,
|
||||
weeklySubmissionLimit: null,
|
||||
monthlySubmissionLimit: null,
|
||||
showBlacklistKeywords: false,
|
||||
hideStudentInfo: true
|
||||
})
|
||||
|
||||
const originalData = ref({})
|
||||
|
||||
// 当前限额类型和值的快捷访问
|
||||
const currentLimitType = computed(() => {
|
||||
if (formData.value.monthlySubmissionLimit !== null) return 'monthly'
|
||||
return formData.value.weeklySubmissionLimit !== null ? 'weekly' : 'daily'
|
||||
})
|
||||
|
||||
const currentLimitValue = computed({
|
||||
get: () => {
|
||||
if (currentLimitType.value === 'monthly') return formData.value.monthlySubmissionLimit
|
||||
return currentLimitType.value === 'daily'
|
||||
? formData.value.dailySubmissionLimit
|
||||
: formData.value.weeklySubmissionLimit
|
||||
},
|
||||
set: (val) => {
|
||||
if (currentLimitType.value === 'monthly') {
|
||||
formData.value.monthlySubmissionLimit = val
|
||||
} else if (currentLimitType.value === 'daily') {
|
||||
formData.value.dailySubmissionLimit = val
|
||||
} else {
|
||||
formData.value.weeklySubmissionLimit = val
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await fetch('/api/admin/system-settings', {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('获取配置失败')
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
formData.value = {
|
||||
siteTitle: data.siteTitle || '',
|
||||
siteLogoUrl: data.siteLogoUrl || '',
|
||||
schoolLogoHomeUrl: data.schoolLogoHomeUrl || '',
|
||||
schoolLogoPrintUrl: data.schoolLogoPrintUrl || '',
|
||||
siteDescription: data.siteDescription || '',
|
||||
submissionGuidelines: data.submissionGuidelines || defaultSubmissionGuidelines,
|
||||
icpNumber: data.icpNumber || '',
|
||||
gonganNumber: data.gonganNumber || '',
|
||||
enableCollaborativeSubmission: data.enableCollaborativeSubmission !== false,
|
||||
enableSubmissionRemarks: !!data.enableSubmissionRemarks,
|
||||
enableReplayRequests: !!data.enableReplayRequests,
|
||||
enableSubmissionLimit: !!data.enableSubmissionLimit,
|
||||
dailySubmissionLimit: data.dailySubmissionLimit ?? 5,
|
||||
weeklySubmissionLimit: data.weeklySubmissionLimit ?? null,
|
||||
monthlySubmissionLimit: data.monthlySubmissionLimit ?? null,
|
||||
showBlacklistKeywords: !!data.showBlacklistKeywords,
|
||||
hideStudentInfo: data.hideStudentInfo ?? true
|
||||
}
|
||||
|
||||
originalData.value = JSON.parse(JSON.stringify(formData.value))
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
showNotification('加载配置失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
const configToSave = {
|
||||
...formData.value,
|
||||
siteTitle: (formData.value.siteTitle || '').trim() || '校园广播站点歌系统',
|
||||
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/favicon.ico',
|
||||
submissionGuidelines:
|
||||
(formData.value.submissionGuidelines || '').trim() || defaultSubmissionGuidelines,
|
||||
// 确保根据限额类型处理空值
|
||||
dailySubmissionLimit:
|
||||
currentLimitType.value === 'daily' ? formData.value.dailySubmissionLimit : null,
|
||||
weeklySubmissionLimit:
|
||||
currentLimitType.value === 'weekly' ? formData.value.weeklySubmissionLimit : null,
|
||||
monthlySubmissionLimit:
|
||||
currentLimitType.value === 'monthly' ? formData.value.monthlySubmissionLimit : null
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/system-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(configToSave)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('保存配置失败')
|
||||
|
||||
saveSuccess.value = true
|
||||
originalData.value = JSON.parse(JSON.stringify(formData.value))
|
||||
showNotification('配置保存成功!', 'success')
|
||||
|
||||
setTimeout(() => {
|
||||
saveSuccess.value = false
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
showNotification('保存配置失败,请重试', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理限额类型变化
|
||||
const handleLimitTypeChange = (type) => {
|
||||
const limits = {
|
||||
daily: { key: 'dailySubmissionLimit', default: 5 },
|
||||
weekly: { key: 'weeklySubmissionLimit', default: 20 },
|
||||
monthly: { key: 'monthlySubmissionLimit', default: 50 }
|
||||
}
|
||||
|
||||
// 将非当前类型的限额设为 null
|
||||
Object.keys(limits).forEach((limitType) => {
|
||||
if (limitType !== type) {
|
||||
formData.value[limits[limitType].key] = null
|
||||
}
|
||||
})
|
||||
|
||||
// 如果当前类型的限额为 null,则设置默认值
|
||||
const targetLimit = limits[type]
|
||||
if (formData.value[targetLimit.key] === null) {
|
||||
formData.value[targetLimit.key] = targetLimit.default
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.value = JSON.parse(JSON.stringify(originalData.value))
|
||||
}
|
||||
|
||||
onMounted(loadConfig)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="max-w-[1400px] mx-auto space-y-10 pb-20 px-2">
|
||||
<!-- 头部区域 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<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-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 ? '重载中...' : '重载SMTP' }}
|
||||
</button>
|
||||
<button
|
||||
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 ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
||||
<!-- 左侧栏:SMTP 设置与测试 -->
|
||||
<div class="xl:col-span-4 space-y-8">
|
||||
<!-- SMTP 核心设置 -->
|
||||
<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-zinc-100 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<Server :size="16" class="text-blue-500" /> 服务配置
|
||||
</h3>
|
||||
<button
|
||||
class="relative w-10 h-5 rounded-full transition-colors"
|
||||
:class="config.smtpEnabled ? 'bg-blue-600' : 'bg-zinc-800'"
|
||||
@click="config.smtpEnabled = !config.smtpEnabled"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 w-3 h-3 bg-white rounded-full transition-all"
|
||||
:class="config.smtpEnabled ? 'left-6' : 'left-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<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-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-zinc-600 uppercase tracking-widest px-1"
|
||||
>端口</label
|
||||
>
|
||||
<input
|
||||
v-model.number="config.smtpPort"
|
||||
type="number"
|
||||
placeholder="587"
|
||||
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-zinc-600 uppercase tracking-widest px-1"
|
||||
>加密方式</label
|
||||
>
|
||||
<CustomSelect
|
||||
:model-value="config.smtpSecure ? 'SSL/TLS' : '无'"
|
||||
:options="['SSL/TLS', '无']"
|
||||
class="w-full"
|
||||
@update:model-value="(val) => (config.smtpSecure = val === 'SSL/TLS')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<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-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-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-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-zinc-500 px-1 italic">
|
||||
通常与发件人账号一致,部分服务商强制要求一致。
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<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-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-zinc-600 uppercase tracking-widest px-1"
|
||||
>发件人姓名</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpFromName"
|
||||
type="text"
|
||||
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-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<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-zinc-600 uppercase tracking-widest px-1"
|
||||
>测试接收邮箱</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="testEmail"
|
||||
type="email"
|
||||
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-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-emerald-500" />
|
||||
<Send v-else :size="14" />
|
||||
{{ testing ? '发送中' : testResult?.success ? '已发送' : '发送' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult">
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 rounded-xl text-xs"
|
||||
:class="
|
||||
testResult.success
|
||||
? '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" />
|
||||
<XCircle v-else :size="14" />
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="font-bold">{{ testResult.message }}</p>
|
||||
<p v-if="testResult.detail" class="text-[10px] opacity-80 break-all font-mono">
|
||||
{{ testResult.detail }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 右侧栏:模板编辑器 -->
|
||||
<div class="xl:col-span-8">
|
||||
<EmailTemplateManager />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
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-next'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
|
||||
// 响应式数据
|
||||
const config = ref({
|
||||
smtpEnabled: false,
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUsername: '',
|
||||
smtpPassword: '',
|
||||
smtpFromEmail: '',
|
||||
smtpFromName: '校园广播站'
|
||||
})
|
||||
|
||||
const testEmail = ref('')
|
||||
const testing = ref(false)
|
||||
const saving = ref(false)
|
||||
const reloading = ref(false)
|
||||
const testResult = ref(null)
|
||||
const originalConfig = ref({})
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/admin/system-settings')
|
||||
|
||||
config.value = {
|
||||
smtpEnabled: response.smtpEnabled || false,
|
||||
smtpHost: response.smtpHost || '',
|
||||
smtpPort: response.smtpPort || 587,
|
||||
smtpSecure: response.smtpSecure || false,
|
||||
smtpUsername: response.smtpUsername || '',
|
||||
smtpPassword: response.smtpPassword || '',
|
||||
smtpFromEmail: response.smtpFromEmail || '',
|
||||
smtpFromName: response.smtpFromName || '校园广播站'
|
||||
}
|
||||
|
||||
// 保存原始配置用于重置
|
||||
originalConfig.value = { ...config.value }
|
||||
} catch (error) {
|
||||
console.error('加载SMTP配置失败:', error)
|
||||
showNotification('加载配置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
if (config.value.smtpEnabled) {
|
||||
// 验证必填字段
|
||||
if (!config.value.smtpHost || !config.value.smtpUsername || !config.value.smtpPassword) {
|
||||
showNotification('请填写完整的SMTP配置信息', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await $fetch('/api/admin/system-settings', {
|
||||
method: 'POST',
|
||||
body: config.value
|
||||
})
|
||||
|
||||
originalConfig.value = { ...config.value }
|
||||
showNotification('SMTP配置保存成功', 'success')
|
||||
} catch (error) {
|
||||
console.error('保存SMTP配置失败:', error)
|
||||
showNotification(error.data?.message || '保存配置失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
const resetConfig = () => {
|
||||
config.value = { ...originalConfig.value }
|
||||
testResult.value = null
|
||||
showNotification('配置已重置', 'info')
|
||||
}
|
||||
|
||||
const reloadSmtpConfig = async () => {
|
||||
reloading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/admin/smtp/reload', {
|
||||
method: 'POST'
|
||||
})
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'SMTP重载失败')
|
||||
}
|
||||
showNotification(response.message || 'SMTP配置已重载', 'success')
|
||||
} catch (error) {
|
||||
console.error('重载SMTP配置失败:', error)
|
||||
showNotification(error.data?.message || error.message || '重载SMTP配置失败', 'error')
|
||||
} finally {
|
||||
reloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const testConnection = async () => {
|
||||
if (!config.value.smtpEnabled || !config.value.smtpHost) {
|
||||
showNotification('请先配置SMTP服务器信息', '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 = response
|
||||
} catch (error) {
|
||||
console.error('测试连接失败:', error)
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: error.data?.message || '测试连接失败'
|
||||
}
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 发送测试邮件
|
||||
const sendTestEmail = async () => {
|
||||
if (!testEmail.value) {
|
||||
showNotification('请输入测试邮箱地址', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/admin/smtp/test-email', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...config.value,
|
||||
testEmail: testEmail.value
|
||||
}
|
||||
})
|
||||
|
||||
testResult.value = response
|
||||
if (response.success) {
|
||||
showNotification('测试邮件发送成功', 'success')
|
||||
setTimeout(() => {
|
||||
testResult.value = null
|
||||
}, 5000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送测试邮件失败:', error)
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: error.data?.message || '发送测试邮件失败'
|
||||
}
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<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="show"
|
||||
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-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="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-zinc-500 font-medium">{{ songTitle }}</p>
|
||||
<span
|
||||
: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'
|
||||
]"
|
||||
>
|
||||
{{ isPublic ? '公开备注' : '仅管理员可见' }}
|
||||
</span>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
songTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isPublic: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
@click="handleOverlayClick"
|
||||
>
|
||||
<!-- 遮罩层 -->
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<!-- 模态框面板 -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-4"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-4"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
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-zinc-800 bg-zinc-900/50 backdrop-blur-sm"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-white">用户歌曲信息</h3>
|
||||
<button
|
||||
class="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<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-zinc-900">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center">
|
||||
<LoadingState message="加载数据中..." spinner-type="circle" />
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-4"
|
||||
>
|
||||
<div class="text-4xl">⚠️</div>
|
||||
<p>{{ error }}</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
@click="retryFetch"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div v-else-if="userSongs" class="flex-1 flex flex-col min-h-0">
|
||||
<!-- 用户信息头部 -->
|
||||
<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-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-zinc-400"
|
||||
>
|
||||
{{ userSongs.user.grade || '' }} {{ userSongs.user.class || '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 (标签页 + 过滤) -->
|
||||
<div
|
||||
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">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="relative px-6 py-3 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0"
|
||||
:class="
|
||||
activeTab === tab.id
|
||||
? 'text-blue-500'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
|
||||
"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span
|
||||
class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full"
|
||||
:class="
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-500/10 text-blue-500'
|
||||
: 'bg-zinc-800 text-zinc-500'
|
||||
"
|
||||
>
|
||||
{{ getTabCount(tab.id) }}
|
||||
</span>
|
||||
<!-- 激活指示器 -->
|
||||
<div
|
||||
v-if="activeTab === tab.id"
|
||||
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-zinc-800 flex items-center gap-3 bg-zinc-900/30"
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="selectedSemester"
|
||||
:options="[{ label: '全部学期', value: null }, ...semesterOptions]"
|
||||
label="学期过滤"
|
||||
placeholder="全部学期"
|
||||
class-name="w-full sm:w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<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-zinc-500"
|
||||
>
|
||||
<div class="text-5xl mb-4 opacity-50">{{ activeTabIcon }}</div>
|
||||
<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-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-white truncate">{{ song.title }}</h5>
|
||||
<span
|
||||
v-if="song.semester"
|
||||
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-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-zinc-500"
|
||||
>
|
||||
<span>{{ getMetaTime(song) }}</span>
|
||||
<span v-if="song.voteCount !== undefined">{{ song.voteCount }} 票</span>
|
||||
<span v-if="song.requestCount !== undefined"
|
||||
>{{ song.requestCount }} 人申请</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态与元数据 (桌面端) -->
|
||||
<div class="flex items-center gap-4 sm:gap-6">
|
||||
<!-- 状态标签 -->
|
||||
<div class="flex-shrink-0">
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium border"
|
||||
:class="getStatusClasses(song)"
|
||||
>
|
||||
{{ getStatusText(song) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Meta Info (Desktop) -->
|
||||
<div class="hidden sm:flex flex-col items-end gap-0.5 min-w-[100px]">
|
||||
<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-zinc-500"
|
||||
>{{ song.voteCount }} 票</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-zinc-300">{{ song.requester.name }}</div>
|
||||
<div class="text-[10px] text-zinc-500">
|
||||
{{ song.requester.grade }}{{ song.requester.class }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 投稿人信息 (移动端) -->
|
||||
<div
|
||||
v-if="song.requester"
|
||||
class="sm:hidden pt-3 mt-1 border-t border-zinc-800/50 flex justify-between items-center text-xs"
|
||||
>
|
||||
<span class="text-zinc-500">投稿人:</span>
|
||||
<span class="text-zinc-300">
|
||||
{{ song.requester.name }}
|
||||
<span class="text-zinc-500 ml-1">
|
||||
{{ song.requester.grade }}{{ song.requester.class }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useSemesters } from '~/composables/useSemesters'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import LoadingState from '~/components/UI/Common/LoadingState.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
userId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// Data
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const userSongs = ref(null)
|
||||
const activeTab = ref('submitted')
|
||||
const selectedSemester = ref(null)
|
||||
|
||||
// Composables
|
||||
const { semesters, fetchSemesterOptions } = useSemesters()
|
||||
|
||||
// Computed
|
||||
const semesterOptions = computed(() => {
|
||||
return semesters.value.map((s) => ({
|
||||
label: s.name,
|
||||
value: s.name
|
||||
}))
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ id: 'submitted', label: '投稿歌曲' },
|
||||
{ id: 'voted', label: '投票歌曲' },
|
||||
{ id: 'replay', label: '重播申请' }
|
||||
]
|
||||
|
||||
const activeTabIcon = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'submitted':
|
||||
return '🎵'
|
||||
case 'voted':
|
||||
return '❤️'
|
||||
case 'replay':
|
||||
return '🔁'
|
||||
default:
|
||||
return '📄'
|
||||
}
|
||||
})
|
||||
|
||||
const currentSongs = computed(() => {
|
||||
if (!userSongs.value) return []
|
||||
switch (activeTab.value) {
|
||||
case 'submitted':
|
||||
return userSongs.value.submittedSongs || []
|
||||
case 'voted':
|
||||
return userSongs.value.votedSongs || []
|
||||
case 'replay':
|
||||
return userSongs.value.replayRequestedSongs || []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const filteredSongs = computed(() => {
|
||||
let songs = currentSongs.value
|
||||
|
||||
if (selectedSemester.value) {
|
||||
songs = songs.filter((song) => song.semester === selectedSemester.value)
|
||||
}
|
||||
|
||||
return songs
|
||||
})
|
||||
|
||||
// Methods
|
||||
const getTabCount = (tabId) => {
|
||||
if (!userSongs.value) return 0
|
||||
|
||||
let songs = []
|
||||
switch (tabId) {
|
||||
case 'submitted':
|
||||
songs = userSongs.value.submittedSongs || []
|
||||
break
|
||||
case 'voted':
|
||||
songs = userSongs.value.votedSongs || []
|
||||
break
|
||||
case 'replay':
|
||||
songs = userSongs.value.replayRequestedSongs || []
|
||||
break
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
if (selectedSemester.value) {
|
||||
songs = songs.filter((song) => song.semester === selectedSemester.value)
|
||||
}
|
||||
|
||||
return songs.length
|
||||
}
|
||||
|
||||
const fetchUserSongs = async () => {
|
||||
if (!props.userId) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const auth = useAuth()
|
||||
|
||||
// 并行获取歌曲数据和学期列表
|
||||
const promises = [$fetch(`/api/admin/users/${props.userId}/songs`, { ...auth.getAuthConfig() })]
|
||||
|
||||
// 如果学期列表为空,尝试获取
|
||||
if (semesters.value.length === 0) {
|
||||
promises.push(fetchSemesterOptions())
|
||||
}
|
||||
|
||||
const [songsResponse] = await Promise.all(promises)
|
||||
userSongs.value = songsResponse
|
||||
} catch (err) {
|
||||
console.error('获取用户歌曲信息失败:', err)
|
||||
error.value = err.data?.message || '获取用户歌曲信息失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const retryFetch = () => {
|
||||
fetchUserSongs()
|
||||
}
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Helpers
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
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('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const getMetaTime = (song) => {
|
||||
if (activeTab.value === 'submitted') return formatDate(song.createdAt)
|
||||
if (activeTab.value === 'voted') return `${formatDate(song.votedAt)} 投票`
|
||||
if (activeTab.value === 'replay') return `${formatDate(song.requestedAt)} 申请`
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStatusText = (song) => {
|
||||
if (song.played) return '已播放'
|
||||
if (song.scheduled) return '已排期'
|
||||
return '待排期'
|
||||
}
|
||||
|
||||
const getStatusClasses = (song) => {
|
||||
if (song.played) {
|
||||
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
}
|
||||
if (song.scheduled) {
|
||||
return 'bg-blue-500/10 text-blue-500 border-blue-500/20'
|
||||
}
|
||||
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}
|
||||
|
||||
// 监听器
|
||||
watch(
|
||||
() => props.userId,
|
||||
(newUserId) => {
|
||||
if (newUserId && props.show) {
|
||||
fetchUserSongs()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newShow) => {
|
||||
if (newShow && props.userId) {
|
||||
fetchUserSongs()
|
||||
} else if (!newShow) {
|
||||
// 关闭时重置状态
|
||||
userSongs.value = null
|
||||
error.value = ''
|
||||
activeTab.value = 'submitted'
|
||||
selectedSemester.value = null // 可选:关闭时重置过滤器
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 列表的自定义滚动条 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(113, 113, 122, 0.3); /* zinc-500/30 */
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(113, 113, 122, 0.5);
|
||||
}
|
||||
|
||||
/* 标签页隐藏滚动条 */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<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="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>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="close-button" @click="closeModal">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
songId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const songInfo = ref(null)
|
||||
const voters = ref([])
|
||||
const totalVotes = ref(0)
|
||||
|
||||
// 方法
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const fetchVoters = async () => {
|
||||
if (!props.songId) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await $fetch(`/api/songs/${props.songId}/voters`)
|
||||
|
||||
songInfo.value = response.song
|
||||
voters.value = response.voters || []
|
||||
totalVotes.value = response.totalVotes || 0
|
||||
} catch (err) {
|
||||
console.error('获取投票人员失败:', err)
|
||||
error.value = err.data?.message || '获取投票人员失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getAvatarText = (name) => {
|
||||
if (!name) return '?'
|
||||
// 提取中文姓名的最后一个字符,或英文名的首字母
|
||||
const cleanName = name.replace(/[((].*[))]/, '').trim()
|
||||
return cleanName.slice(-1).toUpperCase()
|
||||
}
|
||||
|
||||
const formatVoteTime = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
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('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗显示状态
|
||||
watch(
|
||||
() => props.show,
|
||||
(newShow) => {
|
||||
if (newShow && props.songId) {
|
||||
fetchVoters()
|
||||
} else {
|
||||
// 重置数据
|
||||
songInfo.value = null
|
||||
voters.value = []
|
||||
totalVotes.value = 0
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.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: #1a1a1a;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #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: #ffffff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #2a2a2a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.close-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
background: #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
color: #888;
|
||||
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 #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 #3a3a3a;
|
||||
}
|
||||
|
||||
.voters-title {
|
||||
font-weight: 600;
|
||||
color: #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: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3a3a3a;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.voter-item:hover {
|
||||
background: #333333;
|
||||
border-color: #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: #ffffff;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vote-time {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.voter-number {
|
||||
color: #888;
|
||||
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: #666;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #888;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 0 24px 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: #3a3a3a;
|
||||
color: #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;
|
||||
}
|
||||
|
||||
.voters-container::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.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,363 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<form class="space-y-5" @submit.prevent="handleChangePassword">
|
||||
<div v-if="!isFirstLogin" class="space-y-2">
|
||||
<label
|
||||
for="current-password"
|
||||
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-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<Lock :size="18" />
|
||||
</div>
|
||||
<input
|
||||
id="current-password"
|
||||
v-model="currentPassword"
|
||||
:class="[
|
||||
inputClass,
|
||||
error
|
||||
? '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'"
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
@click="showCurrentPassword = !showCurrentPassword"
|
||||
>
|
||||
<Eye v-if="!showCurrentPassword" :size="18" />
|
||||
<EyeOff v-else :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="new-password"
|
||||
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-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<KeyRound :size="18" />
|
||||
</div>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
:class="[
|
||||
inputClass,
|
||||
error
|
||||
? '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'"
|
||||
placeholder="请输入新密码"
|
||||
required
|
||||
@input="
|
||||
error = '';
|
||||
validatePassword()
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
@click="showNewPassword = !showNewPassword"
|
||||
>
|
||||
<Eye v-if="!showNewPassword" :size="18" />
|
||||
<EyeOff v-else :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 密码强度指示器 -->
|
||||
<div v-if="newPassword" class="px-1 pt-1 space-y-2">
|
||||
<div class="h-1 w-full bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-500"
|
||||
:class="passwordStrength.colorClass"
|
||||
:style="{ width: passwordStrength.width }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-zinc-500"
|
||||
>密码强度</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-black uppercase tracking-widest"
|
||||
:class="passwordStrength.textColorClass"
|
||||
>
|
||||
{{ passwordStrength.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="confirm-password"
|
||||
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-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<CheckCircle2 :size="18" />
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password"
|
||||
v-model="confirmPassword"
|
||||
:class="[
|
||||
inputClass,
|
||||
error || (confirmPassword && newPassword !== confirmPassword)
|
||||
? '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'"
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<Eye v-if="!showConfirmPassword" :size="18" />
|
||||
<EyeOff v-else :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 密码匹配提示 -->
|
||||
<div v-if="confirmPassword" class="px-1">
|
||||
<div
|
||||
v-if="newPassword !== confirmPassword"
|
||||
class="flex items-center gap-1.5 text-rose-500"
|
||||
>
|
||||
<XCircle :size="12" />
|
||||
<span class="text-[10px] font-bold">密码不匹配</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.5 text-emerald-500">
|
||||
<CheckCircle2 :size="12" />
|
||||
<span class="text-[10px] font-bold">密码匹配</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态消息 -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-center gap-3"
|
||||
>
|
||||
<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"
|
||||
class="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-3"
|
||||
>
|
||||
<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-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 ? '处理中...' : isFirstLogin ? '设置初始密码' : '确认修改密码' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
Lock,
|
||||
KeyRound,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Loader2
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
// 组件属性
|
||||
const props = defineProps({
|
||||
isFirstLogin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// 样式类
|
||||
const inputClass =
|
||||
'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)
|
||||
const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
|
||||
// 密码强度计算
|
||||
const passwordStrength = computed(() => {
|
||||
const password = newPassword.value
|
||||
if (!password) return { width: '0%', colorClass: '', textColorClass: '', text: '' }
|
||||
|
||||
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(() => {
|
||||
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 validatePassword = () => {
|
||||
if (newPassword.value && newPassword.value.length < 8) {
|
||||
error.value = '密码长度至少为8位'
|
||||
} else {
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = '新密码和确认密码不匹配'
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.value.length < 8) {
|
||||
error.value = '新密码长度至少为8位'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
if (props.isFirstLogin) {
|
||||
await auth.setInitialPassword(newPassword.value)
|
||||
success.value = '密码设置成功!正在跳转...'
|
||||
|
||||
// 清空表单
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
|
||||
// 密码设置完成后跳转
|
||||
setTimeout(async () => {
|
||||
// 更新用户状态
|
||||
await auth.refreshUser()
|
||||
|
||||
if (auth.isAdmin.value) {
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}, 2000)
|
||||
} else {
|
||||
await auth.changePassword(currentPassword.value, newPassword.value)
|
||||
success.value = '密码修改成功!请重新登录'
|
||||
|
||||
// 清空表单
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
|
||||
// 密码修改后登出
|
||||
setTimeout(() => {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
}
|
||||
} catch (err) {
|
||||
// 提取错误信息,支持多种错误格式
|
||||
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
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,651 @@
|
||||
<template>
|
||||
<div class="login-form">
|
||||
<div class="form-header">
|
||||
<h2>{{ isBindMode ? '绑定账号' : '欢迎回来' }}</h2>
|
||||
<p v-if="isBindMode">即将绑定 {{ providerName }} 账号: {{ providerUsername }}</p>
|
||||
<p v-else>登录您的VoiceHub账户</p>
|
||||
</div>
|
||||
|
||||
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleLogin">
|
||||
<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 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" 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">{{ isBindMode ? '绑定中...' : '登录中...' }}</span>
|
||||
<span v-else>{{ isBindMode ? '绑定并登录' : '登录' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="!isBindMode && isWebAuthnSupported" class="webauthn-section">
|
||||
<div class="divider">
|
||||
<span>或</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="webauthn-btn"
|
||||
:disabled="loading"
|
||||
@click="handleWebAuthnLogin"
|
||||
>
|
||||
<Fingerprint :size="20" class="webauthn-icon" />
|
||||
<span>使用 Windows Hello / Passkey 登录</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AuthOAuthButtons v-if="!isBindMode" />
|
||||
|
||||
<div class="form-footer">
|
||||
<p class="help-text">不同VoiceHub平台的账号不互通</p>
|
||||
</div>
|
||||
|
||||
<AuthTwoFactorVerify
|
||||
:show="show2FA"
|
||||
:user-id="userId2FA"
|
||||
:available-methods="methods2FA"
|
||||
:masked-email="maskedEmail2FA"
|
||||
:temp-token="tempToken2FA"
|
||||
@success="handle2FASuccess"
|
||||
@cancel="show2FA = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { getProviderDisplayName } from '~/utils/oauth'
|
||||
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser'
|
||||
import { Fingerprint } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const isBindMode = computed(() => route.query.action === 'bind')
|
||||
const providerUsername = computed(() => route.query.username || '')
|
||||
const providerName = computed(() => {
|
||||
const provider = (route.query.provider as string) || '第三方'
|
||||
return getProviderDisplayName(provider)
|
||||
})
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const isWebAuthnSupported = ref(false)
|
||||
const show2FA = ref(false)
|
||||
const userId2FA = ref(0)
|
||||
const methods2FA = ref<string[]>([])
|
||||
const tempToken2FA = ref('')
|
||||
const maskedEmail2FA = ref('')
|
||||
|
||||
const auth = useAuth()
|
||||
|
||||
const handle2FASuccess = async () => {
|
||||
if (auth.isAdmin.value) {
|
||||
await navigateTo('/dashboard')
|
||||
} else {
|
||||
await navigateTo('/')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const isApiSupported = browserSupportsWebAuthn()
|
||||
let isPlatformAuthenticatorAvailable = false
|
||||
|
||||
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
|
||||
try {
|
||||
isPlatformAuthenticatorAvailable = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
} catch (e) {
|
||||
console.warn('WebAuthn 平台认证器检查失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容外部安全密钥(如 YubiKey),即使没有内置平台认证器也允许尝试
|
||||
isWebAuthnSupported.value = isApiSupported
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.value || !password.value) {
|
||||
error.value = '请填写完整的登录信息'
|
||||
return
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
if (isBindMode.value) {
|
||||
await $fetch('/api/auth/bind', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: username.value,
|
||||
password: password.value
|
||||
}
|
||||
})
|
||||
|
||||
// 绑定成功后刷新认证状态
|
||||
await auth.initAuth()
|
||||
await navigateTo('/')
|
||||
} else {
|
||||
const response = await auth.login(username.value, password.value)
|
||||
|
||||
if (response.requires2FA) {
|
||||
userId2FA.value = response.userId
|
||||
methods2FA.value = response.methods
|
||||
tempToken2FA.value = response.tempToken
|
||||
maskedEmail2FA.value = response.maskedEmail || ''
|
||||
show2FA.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 登录成功后跳转
|
||||
if (auth.isAdmin.value) {
|
||||
await navigateTo('/dashboard')
|
||||
} else {
|
||||
await navigateTo('/')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err.message || (isBindMode.value ? '绑定失败,请检查账号密码' : '登录失败,请检查账号密码')
|
||||
// 密码错误时清空密码字段
|
||||
if (error.value.includes('密码') || error.value.includes('错误')) {
|
||||
password.value = ''
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebAuthnLogin = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 1. 获取登录选项
|
||||
const options = await $fetch('/api/auth/webauthn/login/options', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
// 2. 调用浏览器 WebAuthn API
|
||||
const credential = await startAuthentication(options)
|
||||
|
||||
// 3. 验证登录
|
||||
const verification = await $fetch('/api/auth/webauthn/login/verify', {
|
||||
method: 'POST',
|
||||
body: credential
|
||||
})
|
||||
|
||||
if (verification.success) {
|
||||
// 登录成功
|
||||
await auth.initAuth()
|
||||
await navigateTo(verification.redirect || '/')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WebAuthn 登录错误:', e)
|
||||
const apiError = e as { data?: { message?: string }, message?: string }
|
||||
error.value = apiError.data?.message || apiError.message || 'Passkey 登录失败'
|
||||
} 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;
|
||||
}
|
||||
|
||||
.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(--btn-primary-bg);
|
||||
color: var(--btn-primary-text);
|
||||
border: 1px solid var(--btn-primary-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 16px;
|
||||
font-weight: var(--font-semibold);
|
||||
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::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--btn-primary-hover);
|
||||
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);
|
||||
}
|
||||
|
||||
.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>
|
||||
@@ -0,0 +1,496 @@
|
||||
<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-blue-500 animate-spin mb-3" />
|
||||
<p class="text-zinc-500 text-xs font-medium">加载绑定状态...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- 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-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<AuthProvidersGitHubIcon class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-zinc-200">{{
|
||||
getProviderDisplayName('github')
|
||||
}}</span>
|
||||
<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="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="confirmUnbind('github')"
|
||||
>
|
||||
{{ 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
|
||||
v-if="isWebAuthnSupported || webauthnIdentities.length > 0 || !isSecureContext"
|
||||
:class="[
|
||||
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-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-zinc-200">Windows Hello / Passkey</span>
|
||||
<ChevronDown
|
||||
v-if="webauthnIdentities.length > 0"
|
||||
:size="14"
|
||||
class="text-zinc-500 transition-transform duration-300"
|
||||
:class="{ 'rotate-180': isWebAuthnExpanded }"
|
||||
/>
|
||||
</div>
|
||||
<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-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 ? '处理中...' : '添加设备' }}
|
||||
</button>
|
||||
<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">需要 HTTPS 环境</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebAuthn 设备列表 -->
|
||||
<Transition name="expand">
|
||||
<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-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-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"
|
||||
@click.stop
|
||||
ref="editInput"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 mb-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="重命名"
|
||||
>
|
||||
<Pencil :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-[10px] text-zinc-600"
|
||||
>添加于 {{ new Date(cred.createdAt).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
}) }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="editingId === cred.id">
|
||||
<button
|
||||
class="text-zinc-400 hover:text-green-400 transition-colors p-1"
|
||||
:disabled="isRenaming"
|
||||
@click.stop="saveEditing(cred.id)"
|
||||
title="保存"
|
||||
>
|
||||
<Check :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="text-zinc-400 hover:text-zinc-200 transition-colors p-1"
|
||||
:disabled="isRenaming"
|
||||
@click.stop="cancelEditing"
|
||||
title="取消"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
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)"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 确认对话框 -->
|
||||
<ConfirmDialog
|
||||
v-model:show="showConfirmDialog"
|
||||
:loading="confirmDialog.loading"
|
||||
:message="confirmDialog.message"
|
||||
:title="confirmDialog.title"
|
||||
:type="confirmDialog.type"
|
||||
@cancel="confirmDialog.onCancel"
|
||||
@confirm="confirmDialog.onConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { Loader2, Shield, Fingerprint, ChevronDown, Pencil, Check, X, AlertTriangle } from 'lucide-vue-next'
|
||||
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { getProviderDisplayName } from '~/utils/oauth'
|
||||
import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const { showToast } = useToast()
|
||||
const identities = ref([])
|
||||
const loading = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const isWebAuthnSupported = ref(false)
|
||||
const isSecureContext = ref(true)
|
||||
|
||||
// 编辑相关
|
||||
const editingId = ref<string | null>(null)
|
||||
const editingName = ref('')
|
||||
const isRenaming = ref(false)
|
||||
const editInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
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 as HTMLInputElement)?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
editingId.value = null
|
||||
editingName.value = ''
|
||||
}
|
||||
|
||||
const saveEditing = async (id: string) => {
|
||||
if (!editingName.value.trim()) {
|
||||
showToast('设备名称不能为空', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (editingName.value.trim().length > 50) {
|
||||
showToast('设备名称过长 (最大50个字符)', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
isRenaming.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/webauthn/rename', {
|
||||
method: 'POST',
|
||||
body: { id, name: editingName.value }
|
||||
})
|
||||
showToast('设备名称修改成功', 'success')
|
||||
await fetchIdentities()
|
||||
cancelEditing()
|
||||
} catch (e: any) {
|
||||
showToast(e.data?.message || '修改失败', 'error')
|
||||
} finally {
|
||||
isRenaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isWebAuthnExpanded = ref(false)
|
||||
const toggleWebAuthnList = () => {
|
||||
if (webauthnIdentities.value.length > 0) {
|
||||
isWebAuthnExpanded.value = !isWebAuthnExpanded.value
|
||||
}
|
||||
}
|
||||
|
||||
// 确认对话框相关
|
||||
const showConfirmDialog = ref(false)
|
||||
const confirmDialog = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'warning',
|
||||
loading: false,
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {}
|
||||
})
|
||||
|
||||
// 样式类
|
||||
const itemClass =
|
||||
'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')
|
||||
} catch (e) {
|
||||
console.error('获取绑定信息失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBind = (provider) => {
|
||||
actionLoading.value = true
|
||||
// 绑定也是通过 OAuth 流程,最终回调时会自动识别已登录状态并执行绑定
|
||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||
}
|
||||
|
||||
const confirmUnbind = (provider) => {
|
||||
const providerName = getProviderDisplayName(provider)
|
||||
|
||||
confirmDialog.value = {
|
||||
title: '解除绑定',
|
||||
message: `确定要解除 ${providerName} 账号的绑定吗?解除后您将无法使用该账号登录。`,
|
||||
type: 'danger',
|
||||
loading: false,
|
||||
onConfirm: () => handleUnbind(provider),
|
||||
onCancel: () => {
|
||||
showConfirmDialog.value = false
|
||||
}
|
||||
}
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
const confirmUnbindWebAuthn = (cred) => {
|
||||
confirmDialog.value = {
|
||||
title: '移除 Passkey',
|
||||
message: `确定要移除设备 "${cred.providerUsername}" 吗?移除后将无法使用该设备登录。`,
|
||||
type: 'danger',
|
||||
loading: false,
|
||||
onConfirm: () => handleUnbind('webauthn', cred.id),
|
||||
onCancel: () => {
|
||||
showConfirmDialog.value = false
|
||||
}
|
||||
}
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
const handleUnbind = async (provider, id = null) => {
|
||||
confirmDialog.value.loading = true
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/unbind', {
|
||||
method: 'POST',
|
||||
body: { provider, id }
|
||||
})
|
||||
await fetchIdentities()
|
||||
showToast('解除绑定成功', 'success')
|
||||
showConfirmDialog.value = false
|
||||
} catch (e) {
|
||||
showToast(e.data?.message || '解绑失败', 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
confirmDialog.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebAuthnRegister = async () => {
|
||||
if (!isWebAuthnSupported.value) {
|
||||
showToast('您的浏览器不支持 Windows Hello / Passkey', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const options = await $fetch('/api/auth/webauthn/register/options')
|
||||
|
||||
let attResp
|
||||
try {
|
||||
attResp = await startRegistration(options)
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
throw new Error('用户取消了操作')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
// 提示用户输入设备名称(可选,这里先用默认的)
|
||||
// attResp.label = 'Windows Hello'
|
||||
|
||||
await $fetch('/api/auth/webauthn/register/verify', {
|
||||
method: 'POST',
|
||||
body: attResp
|
||||
})
|
||||
|
||||
showToast('设备添加成功', 'success')
|
||||
await fetchIdentities()
|
||||
} catch (e) {
|
||||
console.error('WebAuthn 注册错误:', e)
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
fetchIdentities()
|
||||
|
||||
isSecureContext.value = window.isSecureContext
|
||||
|
||||
const isApiSupported = browserSupportsWebAuthn()
|
||||
let isPlatformAuthenticatorAvailable = false
|
||||
|
||||
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
|
||||
try {
|
||||
isPlatformAuthenticatorAvailable = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
} catch (e) {
|
||||
console.warn('WebAuthn 平台认证器检查失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容外部安全密钥(如 YubiKey),即使没有内置平台认证器也允许尝试
|
||||
isWebAuthnSupported.value = isApiSupported
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.3s ease-in-out;
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div v-if="hasEnabledProviders" class="mt-6 w-full">
|
||||
<div class="flex items-center text-center text-[var(--text-tertiary)] text-xs mb-4">
|
||||
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
|
||||
<span class="px-2.5">或使用第三方账号登录</span>
|
||||
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
|
||||
</div>
|
||||
<div class="flex justify-center gap-4">
|
||||
<button
|
||||
v-if="config.public.oauth.github"
|
||||
type="button"
|
||||
class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-[#24292e] hover:text-white hover:border-[#24292e]"
|
||||
title="使用 GitHub 登录"
|
||||
@click="loginWith('github')"
|
||||
>
|
||||
<AuthProvidersGitHubIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="config.public.oauth.google"
|
||||
type="button"
|
||||
class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-white hover:text-black hover:border-[#dadce0]"
|
||||
title="使用 Google 登录"
|
||||
@click="loginWith('google')"
|
||||
>
|
||||
<AuthProvidersGoogleIcon />
|
||||
</button>
|
||||
|
||||
<!-- 其他提供商可在此添加 -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const hasEnabledProviders = computed(() => {
|
||||
return config.public.oauth && Object.values(config.public.oauth).some((enabled) => enabled)
|
||||
})
|
||||
|
||||
const loginWith = (provider: string) => {
|
||||
// 外部导航到 API 端点
|
||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<img src="https://casdoor.org/img/casdoor.png" alt="Casdoor" class="h-5 w-auto object-contain" >
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<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="#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="#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="#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="#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>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 状态显示 -->
|
||||
<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"
|
||||
: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-zinc-100">双重认证 (2FA)</h3>
|
||||
<p class="text-xs text-zinc-500">
|
||||
{{ isEnabled ? '您的账户已开启双重认证保护' : '未开启,建议开启以提升账户安全性' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!isEnabled"
|
||||
@click="startSetup"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-lg transition-colors"
|
||||
>
|
||||
开启
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="confirmDisable"
|
||||
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"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 开启流程 -->
|
||||
<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-zinc-100 mb-1">设置双重认证</h4>
|
||||
<p class="text-sm text-zinc-400">请使用 Google Authenticator 或其他验证器应用扫描下方二维码</p>
|
||||
</div>
|
||||
<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-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-zinc-100 text-zinc-400">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证输入区域 -->
|
||||
<div class="flex-1 space-y-4 w-full">
|
||||
<div>
|
||||
<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-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-zinc-800 rounded-lg text-zinc-400 transition-colors"
|
||||
title="复制密钥"
|
||||
>
|
||||
<Copy :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<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="请输入6位验证码"
|
||||
maxlength="6"
|
||||
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-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>验证并开启</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UIConfirmDialog
|
||||
:show="showDisableConfirm"
|
||||
title="关闭双重认证"
|
||||
content="关闭后,您的账户将不再受到双重认证保护。为确保安全,请输入您的登录密码以确认。"
|
||||
confirm-text="验证并关闭"
|
||||
confirm-type="danger"
|
||||
:show-input="true"
|
||||
input-placeholder="请输入当前登录密码"
|
||||
input-type="password"
|
||||
@confirm="disable2FA"
|
||||
@cancel="showDisableConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ShieldCheck, X, Copy, Loader2 } from 'lucide-vue-next'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
initialEnabled?: boolean
|
||||
}>()
|
||||
|
||||
const isEnabled = ref(props.initialEnabled || false)
|
||||
const showSetup = ref(false)
|
||||
const showDisableConfirm = ref(false)
|
||||
const loading = ref(false)
|
||||
const qrCodeUrl = ref('')
|
||||
const secret = ref('')
|
||||
const verificationCode = ref('')
|
||||
const { showToast } = useToast()
|
||||
|
||||
// 获取当前状态(可选,如果父组件没传可以通过API获取,这里暂时假设父组件会传或者初始化为false,
|
||||
// 实际生产中最好有一个 API check status,但为了简化,这里先依赖父组件或默认)
|
||||
// 修正:我们应该在组件挂载时检查状态,但目前没有专门的 check status API,
|
||||
// 通常这包含在 user profile 信息中。如果 user 对象里有 has2FA 字段最好。
|
||||
// 假设父组件通过 prop 传入,或者我们添加一个 check API。
|
||||
// 为了稳健,我们可以复用 generate 接口之前的逻辑,或者在 login 时返回的信息里带。
|
||||
// 这里的实现先依赖 props,如果需要更精确,可以加一个 api。
|
||||
|
||||
const startSetup = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await $fetch('/api/user/2fa/generate', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (data) {
|
||||
qrCodeUrl.value = data.qrCode
|
||||
secret.value = data.secret
|
||||
showSetup.value = true
|
||||
verificationCode.value = ''
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '获取验证码失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelSetup = () => {
|
||||
showSetup.value = false
|
||||
qrCodeUrl.value = ''
|
||||
secret.value = ''
|
||||
verificationCode.value = ''
|
||||
}
|
||||
|
||||
const copySecret = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret.value)
|
||||
showToast('密钥已复制', 'success')
|
||||
} catch (err) {
|
||||
showToast('复制失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const enable2FA = async () => {
|
||||
if (verificationCode.value.length !== 6) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const { error } = await useFetch('/api/user/2fa/enable', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
token: verificationCode.value,
|
||||
secret: secret.value
|
||||
}
|
||||
})
|
||||
|
||||
if (error.value) throw error.value
|
||||
|
||||
showToast('双重认证已开启', 'success')
|
||||
isEnabled.value = true
|
||||
cancelSetup()
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '验证失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDisable = () => {
|
||||
showDisableConfirm.value = true
|
||||
}
|
||||
|
||||
const disable2FA = async (password: string) => {
|
||||
if (!password) {
|
||||
showToast('请输入密码', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const { error } = await useFetch('/api/user/2fa/disable', {
|
||||
method: 'POST',
|
||||
body: { password }
|
||||
})
|
||||
|
||||
if (error.value) throw error.value
|
||||
|
||||
showToast('双重认证已关闭', 'success')
|
||||
isEnabled.value = false
|
||||
showDisableConfirm.value = false
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '关闭失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
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-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-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-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="请输入完整邮箱地址"
|
||||
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()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div v-if="method === 'totp' || emailSent">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="code"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="000000"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发送邮箱验证码按钮 -->
|
||||
<div v-if="method === 'email'" class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="sendEmailCode"
|
||||
:disabled="cooldown > 0 || sending || !emailInput"
|
||||
class="text-sm text-blue-500 hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ cooldown > 0 ? `${cooldown}秒后重新发送` : (emailSent ? '重新发送验证码' : '发送验证码') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<button
|
||||
@click="handleVerify"
|
||||
:disabled="loading || code.length !== 6"
|
||||
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>验证登录</span>
|
||||
</button>
|
||||
|
||||
<!-- 切换验证方式按钮 -->
|
||||
<button
|
||||
v-if="hasMultipleMethods"
|
||||
@click="toggleMethod"
|
||||
class="w-full py-2 text-blue-500 hover:text-blue-400 text-sm transition-colors"
|
||||
>
|
||||
{{ method === 'totp' ? '无法使用验证器?切换到邮箱验证' : '使用验证器应用验证' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="$emit('cancel')"
|
||||
class="w-full py-2 text-zinc-500 hover:text-zinc-300 text-sm transition-colors"
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed, onUnmounted } from 'vue'
|
||||
import { Loader2, AlertCircle } from 'lucide-vue-next'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
userId: number
|
||||
availableMethods?: string[]
|
||||
maskedEmail?: string
|
||||
tempToken?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success', data: any): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const { showToast } = useToast()
|
||||
// 默认优先使用 TOTP,如果没有则使用 Email
|
||||
const defaultMethod = computed(() => props.availableMethods?.includes('totp') ? 'totp' : 'email')
|
||||
const method = ref(defaultMethod.value)
|
||||
const methods = computed(() => props.availableMethods || ['totp', 'email'])
|
||||
const hasMultipleMethods = computed(() => methods.value.length > 1)
|
||||
|
||||
const code = ref('')
|
||||
const emailInput = ref('')
|
||||
const emailSent = ref(false)
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const cooldown = ref(0)
|
||||
const error = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const toggleMethod = () => {
|
||||
method.value = method.value === 'totp' ? 'email' : 'totp'
|
||||
// 切换回 totp 时清空邮箱输入
|
||||
if (method.value === 'totp') {
|
||||
emailInput.value = ''
|
||||
emailSent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const sendEmailCode = async () => {
|
||||
try {
|
||||
if (!emailInput.value) {
|
||||
error.value = '请输入您的邮箱地址'
|
||||
return
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
error.value = ''
|
||||
|
||||
await $fetch('/api/auth/2fa/send-email', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: props.userId,
|
||||
token: props.tempToken,
|
||||
email: emailInput.value
|
||||
}
|
||||
})
|
||||
|
||||
showToast('验证码已发送', 'success')
|
||||
emailSent.value = true
|
||||
cooldown.value = 60
|
||||
timer = setInterval(() => {
|
||||
cooldown.value--
|
||||
if (cooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// 发送成功后自动聚焦验证码输入框
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.message || err.message || '发送失败'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (code.value.length !== 6) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
const { verify2FA } = useAuth()
|
||||
const response = await verify2FA(props.userId, code.value, method.value as 'totp' | 'email', props.tempToken)
|
||||
|
||||
emit('success', response)
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.message || err.message || '验证失败'
|
||||
code.value = '' // 出错清空
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换方式时清空输入
|
||||
watch(method, () => {
|
||||
code.value = ''
|
||||
error.value = ''
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
})
|
||||
|
||||
// 显示时聚焦输入框
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
// 每次重新打开时重置为默认方式(优先 TOTP)
|
||||
method.value = defaultMethod.value
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,293 @@
|
||||
<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-[2000] flex items-center justify-center p-4 sm:p-6"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-8 pb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||||
>
|
||||
<Icon name="user" :size="24" />
|
||||
</div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">{{ title }}</h3>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-all"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主体 -->
|
||||
<div class="flex-1 overflow-y-auto p-8 pt-4 custom-scrollbar">
|
||||
<!-- 搜索输入框 -->
|
||||
<div class="relative mb-6 group">
|
||||
<div class="absolute inset-y-0 left-4 flex items-center pointer-events-none">
|
||||
<Icon
|
||||
name="search"
|
||||
:size="20"
|
||||
class="text-zinc-500 group-focus-within:text-zinc-300 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
class="w-full pl-12 pr-12 py-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500/30 transition-all"
|
||||
placeholder="搜索用户名或姓名..."
|
||||
type="text"
|
||||
@input="handleSearch"
|
||||
@keyup.enter="performSearch"
|
||||
>
|
||||
<div v-if="loading" class="absolute inset-y-0 right-4 flex items-center">
|
||||
<Icon name="loader" :size="20" class="text-zinc-400 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div class="user-list space-y-3 min-h-[300px]">
|
||||
<div
|
||||
v-if="users.length === 0 && !loading && hasSearched"
|
||||
class="flex flex-col items-center justify-center py-12 text-zinc-500"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-800/50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="search" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">未找到匹配的用户</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:class="[
|
||||
'group flex items-center p-4 rounded-2xl border transition-all cursor-pointer',
|
||||
isSelected(user)
|
||||
? 'bg-blue-600/10 border-blue-500/30 shadow-lg'
|
||||
: 'bg-zinc-950 border-transparent hover:border-zinc-800'
|
||||
]"
|
||||
@click="toggleSelection(user)"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<div
|
||||
:class="[
|
||||
'w-12 h-12 rounded-xl flex items-center justify-center text-lg font-black mr-4 transition-colors',
|
||||
isSelected(user) ? 'bg-blue-600 text-white' : 'bg-zinc-800 text-zinc-500'
|
||||
]"
|
||||
>
|
||||
{{ user.name.charAt(0) }}
|
||||
</div>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-zinc-100 truncate">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] font-black uppercase tracking-widest flex flex-wrap gap-2 mt-0.5 text-zinc-500"
|
||||
>
|
||||
<span v-if="user.grade" class="flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
{{ user.grade }}
|
||||
</span>
|
||||
<span v-if="user.class" class="flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
{{ user.class }}
|
||||
</span>
|
||||
<span class="truncate">@{{ user.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选中指示器 -->
|
||||
<div class="ml-4">
|
||||
<div
|
||||
:class="[
|
||||
'w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-all',
|
||||
isSelected(user)
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-zinc-800 group-hover:border-zinc-700'
|
||||
]"
|
||||
>
|
||||
<Icon v-if="isSelected(user)" name="check" :size="14" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div class="p-8 pt-0">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-[10px] font-black text-zinc-500 uppercase tracking-widest">
|
||||
<span v-if="selectedUsers.length > 0" class="text-blue-500">
|
||||
已选择 {{ selectedUsers.length }} 人
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="px-6 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click="close"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="selectedUsers.length === 0"
|
||||
class="px-8 py-3 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest shadow-lg shadow-blue-900/20"
|
||||
@click="confirm"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
// 简单的防抖函数实现
|
||||
function useDebounceFn(fn, delay) {
|
||||
let timeoutId
|
||||
return (...args) => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => {
|
||||
fn(...args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
title: {
|
||||
type: String,
|
||||
default: '搜索用户'
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
excludeIds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'select'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
const selectedUsers = ref([])
|
||||
const searchInput = ref(null)
|
||||
|
||||
const close = () => {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
const isSelected = (user) => {
|
||||
return selectedUsers.value.some((u) => u.id === user.id)
|
||||
}
|
||||
|
||||
const toggleSelection = (user) => {
|
||||
if (props.multiple) {
|
||||
const index = selectedUsers.value.findIndex((u) => u.id === user.id)
|
||||
if (index === -1) {
|
||||
selectedUsers.value.push(user)
|
||||
} else {
|
||||
selectedUsers.value.splice(index, 1)
|
||||
}
|
||||
} else {
|
||||
selectedUsers.value = [user]
|
||||
}
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
emit('select', props.multiple ? selectedUsers.value : selectedUsers.value[0])
|
||||
close()
|
||||
}
|
||||
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) return
|
||||
|
||||
loading.value = true
|
||||
hasSearched.value = true
|
||||
|
||||
try {
|
||||
const data = await $fetch('/api/users/search', {
|
||||
params: { keyword: searchQuery.value }
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
// 过滤掉排除的用户
|
||||
users.value = data.users.filter((u) => !props.excludeIds.includes(u.id))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索用户失败', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = useDebounceFn(() => {
|
||||
performSearch()
|
||||
}, 500)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
searchQuery.value = ''
|
||||
users.value = []
|
||||
selectedUsers.value = []
|
||||
hasSearched.value = false
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<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-blue-500 animate-spin mb-3" />
|
||||
<p class="text-zinc-500 text-xs font-medium">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<AlertCircle :size="16" class="text-rose-500" />
|
||||
<span class="text-xs text-rose-500 font-medium">{{ error }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 bg-rose-500 text-white text-[10px] font-black uppercase rounded-lg hover:bg-rose-400 transition-all"
|
||||
@click="fetchSettings"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-zinc-200">歌曲被选中通知</h3>
|
||||
<p class="text-[11px] text-zinc-500 mt-0.5">当您投稿的歌曲被选中安排播放时通知您</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.songSelectedNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-zinc-200">歌曲已播放通知</h3>
|
||||
<p class="text-[11px] text-zinc-500 mt-0.5">当您投稿的歌曲被播放时通知您</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.songPlayedNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-zinc-200">歌曲获得投票通知</h3>
|
||||
<p class="text-[11px] text-zinc-500 mt-0.5">当您投稿的歌曲获得新投票时通知您</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.songVotedNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="localSettings.songVotedNotify"
|
||||
class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-black text-zinc-500 uppercase tracking-widest">投票通知阈值</h3>
|
||||
<span class="text-xs font-bold text-blue-500"
|
||||
>每获得 {{ localSettings.songVotedThreshold }} 票通知一次</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="localSettings.songVotedThreshold"
|
||||
type="range"
|
||||
max="10"
|
||||
min="1"
|
||||
step="1"
|
||||
class="w-full h-1.5 bg-zinc-800 rounded-full appearance-none accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
<div class="flex justify-between text-[10px] font-black text-zinc-700">
|
||||
<span>1</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-zinc-200">系统通知</h3>
|
||||
<p class="text-[11px] text-zinc-500 mt-0.5">接收系统公告和其他重要通知</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.systemNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-zinc-200">通知刷新间隔</h3>
|
||||
<p class="text-[11px] text-zinc-500 mt-0.5">设置通知自动刷新的时间间隔</p>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-blue-500">{{
|
||||
formatRefreshInterval(localSettings.refreshInterval)
|
||||
}}</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="localSettings.refreshInterval"
|
||||
type="range"
|
||||
max="300"
|
||||
min="10"
|
||||
step="10"
|
||||
class="w-full h-1.5 bg-zinc-800 rounded-full appearance-none accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
<div class="flex justify-between text-[10px] font-black text-zinc-700">
|
||||
<span>10s</span>
|
||||
<span>5m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Loader2, AlertCircle } from 'lucide-vue-next'
|
||||
import { useNotifications } from '~/composables/useNotifications'
|
||||
|
||||
const notificationsService = useNotifications()
|
||||
const loading = computed(() => notificationsService.loading.value)
|
||||
const error = computed(() => notificationsService.error.value)
|
||||
const settings = computed(() => notificationsService.settings.value)
|
||||
|
||||
// 样式类
|
||||
const itemClass =
|
||||
'flex items-center justify-between p-4 bg-zinc-950/30 border border-zinc-900 rounded-2xl hover:bg-zinc-900/50 transition-all'
|
||||
|
||||
// 本地设置,用于双向绑定
|
||||
const localSettings = ref({
|
||||
songSelectedNotify: true,
|
||||
songPlayedNotify: true,
|
||||
songVotedNotify: true,
|
||||
songVotedThreshold: 1,
|
||||
systemNotify: true,
|
||||
refreshInterval: 60
|
||||
})
|
||||
|
||||
// 监听设置变化
|
||||
watch(
|
||||
settings,
|
||||
(newSettings) => {
|
||||
if (newSettings) {
|
||||
localSettings.value = {
|
||||
songSelectedNotify: newSettings.songSelectedNotify,
|
||||
songPlayedNotify: newSettings.songPlayedNotify,
|
||||
songVotedNotify: newSettings.songVotedNotify,
|
||||
songVotedThreshold: newSettings.songVotedThreshold || 1,
|
||||
systemNotify: newSettings.systemNotify,
|
||||
refreshInterval: newSettings.refreshInterval || 60
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
})
|
||||
|
||||
// 获取设置
|
||||
const fetchSettings = async () => {
|
||||
await notificationsService.fetchNotificationSettings()
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
const saveSettings = async () => {
|
||||
await notificationsService.updateNotificationSettings(localSettings.value)
|
||||
}
|
||||
|
||||
// 格式化刷新间隔
|
||||
const formatRefreshInterval = (seconds) => {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}秒`
|
||||
} else {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return remainingSeconds > 0 ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #2563eb; /* blue-600 */
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div
|
||||
class="lyric-am"
|
||||
:class="{
|
||||
'align-right': settings.lyricAlignRight.value
|
||||
}"
|
||||
:style="{
|
||||
'--amll-lp-color': 'rgb(var(--main-cover-color, 239 239 239))',
|
||||
'--amll-lp-hover-bg-color': 'rgba(255,255,255,0.08)',
|
||||
'--amll-lyric-left-padding': settings.lyricAlignRight.value
|
||||
? ''
|
||||
: `${settings.lyricHorizontalOffset.value}px`,
|
||||
'--amll-lyric-right-padding': settings.lyricAlignRight.value
|
||||
? `${settings.lyricHorizontalOffset.value}px`
|
||||
: ''
|
||||
}"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="lyricManager.loading.value" class="lyric-message-container">
|
||||
<Icon name="loader" :size="32" class="spin-animation" />
|
||||
<div class="message-text">歌词加载中...</div>
|
||||
</div>
|
||||
<div v-else-if="lyricManager.error.value" class="lyric-message-container">
|
||||
<Icon name="alert-circle" :size="32" />
|
||||
<div class="message-text">{{ lyricManager.error.value }}</div>
|
||||
</div>
|
||||
<div v-else-if="!lyricLines || lyricLines.length === 0" class="lyric-message-container">
|
||||
<Icon name="music" :size="48" style="opacity: 0.5" />
|
||||
<div class="message-text">暂无歌词</div>
|
||||
</div>
|
||||
<LyricPlayer
|
||||
v-else
|
||||
ref="lyricPlayerRef"
|
||||
:lyric-lines="lyricLines"
|
||||
:current-time="currentTime * 1000"
|
||||
:playing="audioPlayer.getPlayingStatus().value"
|
||||
:enable-spring="settings.useAMSpring.value"
|
||||
:enable-scale="settings.useAMSpring.value"
|
||||
:align-position="settings.lyricsScrollOffset.value"
|
||||
:align-anchor="settings.lyricsScrollOffset.value > 0.4 ? 'center' : 'top'"
|
||||
:enable-blur="settings.lyricsBlur.value"
|
||||
:hide-passed-lines="settings.hidePassedLines.value"
|
||||
:word-fade-width="settings.wordFadeWidth.value"
|
||||
:style="{
|
||||
'--display-count-down-show': settings.countDownShow.value ? 'flex' : 'none',
|
||||
'--amll-lp-font-size': `${settings.lyricFontSize.value}px`,
|
||||
'font-weight': settings.lyricFontWeight.value,
|
||||
'font-family': settings.LyricFont.value !== 'follow' ? settings.LyricFont.value : ''
|
||||
}"
|
||||
class="am-lyric"
|
||||
@line-click="jumpSeek"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import LyricPlayer, { type LyricPlayerRef } from '~/components/AMLL/LyricPlayer.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useLyricManager } from '~/composables/useLyricManager'
|
||||
import { useLyricSettings } from '~/composables/useLyricSettings'
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
|
||||
import type { LyricLineMouseEvent } from '@applemusic-like-lyrics/core'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
const props = defineProps({
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const lyricManager = useLyricManager()
|
||||
const settings = useLyricSettings()
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const audioPlayerControl = useAudioPlayerControl()
|
||||
|
||||
const lyricPlayerRef = ref<LyricPlayerRef | null>(null)
|
||||
|
||||
const lyricLines = computed(() => {
|
||||
// 使用 cloneDeep 剥离 Vue 响应式代理,防止 structuredClone 错误
|
||||
return cloneDeep(lyricManager.lyrics.value)
|
||||
})
|
||||
|
||||
// 进度跳转
|
||||
const jumpSeek = (line: LyricLineMouseEvent) => {
|
||||
// 移除焦点,防止组件内部焦点导致键盘事件异常
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
const lineContent = line.line.getLine()
|
||||
if (!lineContent?.startTime) return
|
||||
const time = lineContent.startTime
|
||||
const offsetMs = settings.lyricOffset.value
|
||||
const targetTime = (time - offsetMs) / 1000
|
||||
|
||||
audioPlayerControl.seekAndPlay(targetTime)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lyric-am {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.am-lyric {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
padding-left: var(--amll-lyric-left-padding, 10px);
|
||||
padding-right: 80px;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 990px) {
|
||||
.am-lyric {
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lyric-am.align-right .am-lyric {
|
||||
padding-left: 80px;
|
||||
padding-right: var(--amll-lyric-right-padding, 10px);
|
||||
}
|
||||
|
||||
@media (max-width: 990px) {
|
||||
.lyric-am.align-right .am-lyric {
|
||||
padding: 0;
|
||||
margin-right: -20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高亮效果 */
|
||||
:deep(.am-lyric .current),
|
||||
:deep(.am-lyric .is-current),
|
||||
:deep(.am-lyric .active),
|
||||
:deep(.am-lyric .is-active),
|
||||
:deep(.am-lyric .lyric-line.current),
|
||||
:deep(.am-lyric .lyric-line.is-current) {
|
||||
mix-blend-mode: plus-lighter;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-shadow: 0 2px 12px rgba(255, 255, 255, 0.06);
|
||||
will-change: transform, opacity, color;
|
||||
}
|
||||
|
||||
@supports not (mix-blend-mode: plus-lighter) {
|
||||
:deep(.am-lyric .current),
|
||||
:deep(.am-lyric .is-current),
|
||||
:deep(.am-lyric .active),
|
||||
:deep(.am-lyric .is-active),
|
||||
:deep(.am-lyric .lyric-line.current),
|
||||
:deep(.am-lyric .lyric-line.is-current) {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.am-lyric div[class*='lyricMainLine'] span) {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.lyric-message-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--amll-lp-color, #efefef);
|
||||
gap: 16px;
|
||||
opacity: 0.8;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.spin-animation {
|
||||
animation: spin 1s linear infinite;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,829 @@
|
||||
<template>
|
||||
<div
|
||||
:style="{
|
||||
'--lrc-size': getFontSize(settings.lyricFontSize.value, settings.lyricFontSizeMode.value),
|
||||
'--lrc-tran-size': getFontSize(
|
||||
settings.lyricTranFontSize.value,
|
||||
settings.lyricFontSizeMode.value
|
||||
),
|
||||
'--lrc-roma-size': getFontSize(
|
||||
settings.lyricRomaFontSize.value,
|
||||
settings.lyricFontSizeMode.value
|
||||
),
|
||||
'--lrc-bold': settings.lyricFontWeight.value,
|
||||
'--lrc-left-padding': `${settings.lyricHorizontalOffset.value}px`,
|
||||
'font-family': settings.LyricFont.value !== 'follow' ? settings.LyricFont.value : '',
|
||||
cursor: 'auto'
|
||||
}"
|
||||
:class="[
|
||||
'lyric',
|
||||
'normal',
|
||||
{
|
||||
'align-right': settings.lyricAlignRight.value
|
||||
}
|
||||
]"
|
||||
@mouseleave="lrcAllLeave"
|
||||
>
|
||||
<div class="lyric-content">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="lyricManager.loading.value" class="lyric-loading">歌词正在加载中...</div>
|
||||
<div v-else-if="!currentLyricData || currentLyricData.length === 0" class="lyric-loading">
|
||||
暂无歌词
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="lyricScrollContainer"
|
||||
class="lyric-scroll-container"
|
||||
tabindex="-1"
|
||||
@wheel="handleUserScroll"
|
||||
@touchstart="handleUserScroll"
|
||||
>
|
||||
<!-- 顶部占位 -->
|
||||
<div id="lrc-placeholder" class="placeholder" />
|
||||
<!-- 预处理后的歌词列表 -->
|
||||
<template v-for="(item, index) in processedLyrics" :key="`item-${index}`">
|
||||
<!-- 倒计时行 -->
|
||||
<div
|
||||
v-if="item.type === 'countdown'"
|
||||
class="countdown-line"
|
||||
:style="{
|
||||
animationPlayState: isPlaying ? 'running' : 'paused'
|
||||
}"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="isCountdownVisible(item)" class="count-down">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
:style="{ opacity: getPointOpacity(item, i - 1) }"
|
||||
class="point"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<!-- 歌词行 -->
|
||||
<div
|
||||
v-else
|
||||
:id="`lrc-${index}`"
|
||||
:class="getLyricLineClass(item, index)"
|
||||
:style="getLyricLineStyle(item, index)"
|
||||
@click="jumpSeek(item.data.startTime)"
|
||||
>
|
||||
<!-- 逐字歌词 -->
|
||||
<template v-if="isYrcMode">
|
||||
<div class="content">
|
||||
<div
|
||||
v-for="(text, textIndex) in item.data.words"
|
||||
:key="textIndex"
|
||||
:class="{
|
||||
'content-text': true,
|
||||
'end-with-space': text.word.endsWith(' ') || text.startTime === 0
|
||||
}"
|
||||
:style="getYrcVars(text, index)"
|
||||
>
|
||||
<span class="yrc-word" :lang="getLyricLanguage(text.word)">
|
||||
{{ text.word }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 普通歌词 -->
|
||||
<template v-else>
|
||||
<span class="content" :lang="getLyricLanguage(item.data.words?.[0]?.word)">
|
||||
{{ item.data.words.map((w) => w.word).join('') }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- 翻译和音译 -->
|
||||
<template v-if="settings.swapTranRoma.value">
|
||||
<!-- 音译在前 -->
|
||||
<span v-if="item.data.romanLyric && settings.showRoma.value" class="roma" lang="en">
|
||||
{{ item.data.romanLyric }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.data.translatedLyric && settings.showTranslation.value"
|
||||
class="tran"
|
||||
lang="en"
|
||||
>
|
||||
{{ item.data.translatedLyric }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 翻译在前(默认) -->
|
||||
<span
|
||||
v-if="item.data.translatedLyric && settings.showTranslation.value"
|
||||
class="tran"
|
||||
lang="en"
|
||||
>
|
||||
{{ item.data.translatedLyric }}
|
||||
</span>
|
||||
<span v-if="item.data.romanLyric && settings.showRoma.value" class="roma" lang="en">
|
||||
{{ item.data.romanLyric }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 底部占位 -->
|
||||
<div class="placeholder" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import type { LyricWord, LyricLine } from '@applemusic-like-lyrics/lyric'
|
||||
import { useLyricManager } from '~/composables/useLyricManager'
|
||||
import { useLyricSettings } from '~/composables/useLyricSettings'
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
|
||||
|
||||
const props = defineProps({
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const lyricManager = useLyricManager()
|
||||
const settings = useLyricSettings()
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const audioPlayerControl = useAudioPlayerControl()
|
||||
|
||||
const lyricScrollContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// 是否为逐字歌词模式
|
||||
const isYrcMode = computed(() => {
|
||||
const format = lyricManager.lyricFormat.value
|
||||
return (
|
||||
settings.showYrc.value &&
|
||||
(format === 'qrc' || format === 'word-by-word' || format === 'enhanced')
|
||||
)
|
||||
})
|
||||
|
||||
// 获取当前使用的歌词数据
|
||||
const currentLyricData = computed(() => lyricManager.lyrics.value)
|
||||
|
||||
const isPlaying = computed(() => audioPlayer.getPlayingStatus().value)
|
||||
|
||||
/** 处理后的歌词项类型 */
|
||||
type ProcessedLyricItem =
|
||||
| { type: 'lyric'; originalIndex: number; data: LyricLine }
|
||||
| { type: 'countdown'; startTime: number; duration: number }
|
||||
|
||||
/** 间奏倒计时最小时长(毫秒) */
|
||||
const COUNTDOWN_THRESHOLD = 10000
|
||||
/** 开头倒计时最小时长(毫秒) */
|
||||
const INTRO_THRESHOLD = 3000
|
||||
|
||||
/** 预处理歌词数据 */
|
||||
const processedLyrics = computed<ProcessedLyricItem[]>(() => {
|
||||
const lyrics = currentLyricData.value
|
||||
if (!lyrics || lyrics.length === 0) return []
|
||||
const result: ProcessedLyricItem[] = []
|
||||
// 检查开头是否需要倒计时
|
||||
const firstLyricStart = lyrics[0]?.startTime || 0
|
||||
if (settings.countDownShow.value && firstLyricStart >= INTRO_THRESHOLD) {
|
||||
result.push({
|
||||
type: 'countdown',
|
||||
startTime: 0,
|
||||
duration: firstLyricStart
|
||||
})
|
||||
}
|
||||
// 遍历歌词
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
const item = lyrics[i]
|
||||
result.push({
|
||||
type: 'lyric',
|
||||
originalIndex: i,
|
||||
data: item
|
||||
})
|
||||
// 检查是否需要插入倒计时
|
||||
if (settings.countDownShow.value && i < lyrics.length - 1) {
|
||||
const currentEnd = item.endTime
|
||||
const nextStart = lyrics[i + 1]?.startTime || 0
|
||||
const gap = nextStart - currentEnd
|
||||
if (gap >= COUNTDOWN_THRESHOLD) {
|
||||
result.push({
|
||||
type: 'countdown',
|
||||
startTime: currentEnd,
|
||||
duration: gap
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
/** 倒计时是否可见 */
|
||||
const isCountdownVisible = (item: ProcessedLyricItem): boolean => {
|
||||
if (item.type !== 'countdown') return false
|
||||
// 计算实时时间 - 0.5s 是否小于开始 + 持续时间
|
||||
// currentTime 是秒,转换为毫秒
|
||||
return props.currentTime * 1000 + 500 < item.startTime + item.duration
|
||||
}
|
||||
|
||||
/** 计算当前活跃的歌词行索引列表 */
|
||||
const activeLineIndices = computed<number[]>(() => {
|
||||
const lyrics = processedLyrics.value
|
||||
if (!lyrics || lyrics.length === 0) return []
|
||||
const currentSeek = props.currentTime * 1000 // 秒转毫秒
|
||||
const activeCandidates: number[] = []
|
||||
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
const item = lyrics[i]
|
||||
let start = 0
|
||||
let end = Infinity
|
||||
if (item.type === 'lyric') {
|
||||
start = item.data.startTime || 0
|
||||
end = item.data.endTime ?? Infinity
|
||||
} else {
|
||||
start = item.startTime
|
||||
end = item.startTime + item.duration
|
||||
}
|
||||
|
||||
if (currentSeek >= start && currentSeek < end) {
|
||||
activeCandidates.push(i)
|
||||
}
|
||||
}
|
||||
// 如果没有活跃行,找最近的上一行
|
||||
if (activeCandidates.length === 0 && currentSeek > 0) {
|
||||
// 找到第一个开始时间大于当前时间的行
|
||||
const nextIndex = lyrics.findIndex((item) => {
|
||||
const start = item.type === 'lyric' ? item.data.startTime : item.startTime
|
||||
return (start || 0) > currentSeek
|
||||
})
|
||||
if (nextIndex === -1) return [lyrics.length - 1] // 都在后面,取最后一行
|
||||
if (nextIndex > 0) return [nextIndex - 1] // 取前一行
|
||||
}
|
||||
return activeCandidates
|
||||
})
|
||||
|
||||
/** 首个高亮行索引 */
|
||||
const firstActiveIndex = computed(() => {
|
||||
return activeLineIndices.value[0] ?? -1
|
||||
})
|
||||
|
||||
/** 判断某行是否高亮 */
|
||||
const isLineActive = (index: number): boolean => {
|
||||
return activeLineIndices.value.includes(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算倒计时圆点透明度
|
||||
*/
|
||||
const getPointOpacity = (item: ProcessedLyricItem, index: number): number => {
|
||||
if (item.type !== 'countdown') return 0
|
||||
const perPointTime = item.duration / 3
|
||||
const currentTime = props.currentTime * 1000 - item.startTime
|
||||
if (currentTime <= 0) return 0
|
||||
if (currentTime < perPointTime * (index + 1)) {
|
||||
const percentage = (currentTime - perPointTime * index) / perPointTime
|
||||
return 0.1 + 0.7 * (1 - percentage)
|
||||
}
|
||||
return 0.1
|
||||
}
|
||||
|
||||
/** 当前滚动动画 ID */
|
||||
let scrollAnimationId: number | null = null
|
||||
/** 用户是否正在滚动 */
|
||||
const userScrolling = ref(false)
|
||||
/** 用户滚动恢复超时 ID */
|
||||
let userScrollTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
/** 用户滚动恢复超时时间(毫秒) */
|
||||
const USER_SCROLL_TIMEOUT = 3000
|
||||
|
||||
/**
|
||||
* 自主滚动
|
||||
*/
|
||||
const handleUserScroll = () => {
|
||||
userScrolling.value = true
|
||||
if (userScrollTimeoutId !== null) {
|
||||
clearTimeout(userScrollTimeoutId)
|
||||
}
|
||||
// 超时后恢复
|
||||
userScrollTimeoutId = setTimeout(() => {
|
||||
userScrolling.value = false
|
||||
userScrollTimeoutId = null
|
||||
lyricsScroll(firstActiveIndex.value)
|
||||
}, USER_SCROLL_TIMEOUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* 平滑滚动
|
||||
*/
|
||||
const smoothScrollTo = (container: HTMLElement, targetY: number, duration = 300) => {
|
||||
// 取消之前的动画
|
||||
if (scrollAnimationId !== null) {
|
||||
cancelAnimationFrame(scrollAnimationId)
|
||||
scrollAnimationId = null
|
||||
}
|
||||
// 计算起始位置和目标位置
|
||||
const startY = container.scrollTop
|
||||
const diff = targetY - startY
|
||||
// 如果差值很小,直接设置
|
||||
if (Math.abs(diff) < 0.5) {
|
||||
container.scrollTop = targetY
|
||||
return
|
||||
}
|
||||
const startTime = performance.now()
|
||||
const step = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
// easeInOutQuad 缓动
|
||||
const easedProgress =
|
||||
progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2
|
||||
container.scrollTop = startY + diff * easedProgress
|
||||
if (progress < 1) {
|
||||
scrollAnimationId = requestAnimationFrame(step)
|
||||
} else {
|
||||
scrollAnimationId = null
|
||||
}
|
||||
}
|
||||
scrollAnimationId = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌词滚动
|
||||
*/
|
||||
const lyricsScroll = (index: number) => {
|
||||
const container = lyricScrollContainer.value
|
||||
if (!container) return
|
||||
// 用户滚动时不自动滚动
|
||||
if (userScrolling.value) return
|
||||
const lrcItemDom = document.getElementById(index >= 0 ? `lrc-${index}` : 'lrc-placeholder')
|
||||
if (!lrcItemDom) return
|
||||
// 计算目标滚动位置
|
||||
const containerHeight = container.clientHeight
|
||||
const elementTop = lrcItemDom.offsetTop
|
||||
const elementHeight = lrcItemDom.offsetHeight
|
||||
// 居中偏移滚动
|
||||
let targetY = elementTop - (containerHeight - elementHeight) * settings.lyricsScrollOffset.value
|
||||
// 确保不超出边界
|
||||
targetY = Math.max(0, Math.min(targetY, container.scrollHeight - container.clientHeight))
|
||||
// 执行平滑滚动
|
||||
smoothScrollTo(container, targetY, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标移出歌词区域恢复自动滚动
|
||||
*/
|
||||
const lrcAllLeave = () => {
|
||||
userScrolling.value = false
|
||||
if (userScrollTimeoutId !== null) {
|
||||
clearTimeout(userScrollTimeoutId)
|
||||
userScrollTimeoutId = null
|
||||
}
|
||||
lyricsScroll(firstActiveIndex.value)
|
||||
}
|
||||
|
||||
type CssVars = Record<`--${string}`, string>
|
||||
|
||||
/** 逐字歌词淡入淡出因子 */
|
||||
const YRC_DIM_ALPHA = 0.3
|
||||
/** 逐字歌词淡入淡出时间 */
|
||||
const YRC_LINE_FADE_MS = 250
|
||||
/** 淡入淡出行索引 */
|
||||
const yrcFadingLineIndex = ref<number | null>(null)
|
||||
/** 淡入淡出结束时间 */
|
||||
const yrcFadingUntilAt = ref<number>(0)
|
||||
|
||||
const getYrcFadeFactor = (index: number): number => {
|
||||
if (yrcFadingLineIndex.value !== index) return 1
|
||||
const now = Date.now()
|
||||
if (now >= yrcFadingUntilAt.value) return 1
|
||||
const remain = yrcFadingUntilAt.value - now
|
||||
return Math.min(Math.max(remain / YRC_LINE_FADE_MS, 0), 1)
|
||||
}
|
||||
|
||||
const getYrcVars = (wordData: LyricWord, lyricIndex: number): CssVars => {
|
||||
const currentSeek = props.currentTime * 1000
|
||||
const fadeFactor = getYrcFadeFactor(lyricIndex)
|
||||
// 判断是否显示
|
||||
const currentLineItem = processedLyrics.value[lyricIndex]
|
||||
if (!currentLineItem || currentLineItem.type !== 'lyric') return {}
|
||||
|
||||
if (!isYrcLineOn(lyricIndex)) return {}
|
||||
// 计算进度
|
||||
const duration = wordData.endTime - wordData.startTime
|
||||
const safeDuration = Math.max(duration, 1)
|
||||
const rawProgress = (currentSeek - wordData.startTime) / safeDuration
|
||||
const progress = Math.min(Math.max(rawProgress, 0), 1)
|
||||
const maskX = `${(1 - Math.min(progress, 1)) * 100}%`
|
||||
// 计算透明度
|
||||
const hasStarted = currentSeek >= wordData.startTime
|
||||
const brightAlpha = hasStarted ? YRC_DIM_ALPHA + (1 - YRC_DIM_ALPHA) * fadeFactor : YRC_DIM_ALPHA
|
||||
const darkAlpha = YRC_DIM_ALPHA
|
||||
|
||||
let transform = 'scale(1)'
|
||||
if (progress > 0) {
|
||||
const scale = 1 + 0.08 * progress
|
||||
const y = -2 * progress
|
||||
transform = `scale(${scale}) translateY(${y}px)`
|
||||
}
|
||||
|
||||
return {
|
||||
'--yrc-mask-x': maskX,
|
||||
'--yrc-opacity': '1',
|
||||
'--yrc-bright-alpha': `${brightAlpha}`,
|
||||
'--yrc-dark-alpha': `${darkAlpha}`,
|
||||
'--yrc-transform': transform
|
||||
}
|
||||
}
|
||||
|
||||
const isYrcLineOn = (index: number): boolean => {
|
||||
const isActive = isLineActive(index)
|
||||
const isFading = yrcFadingLineIndex.value === index && Date.now() < yrcFadingUntilAt.value
|
||||
return isActive || isFading
|
||||
}
|
||||
|
||||
const getLyricLineClass = (item: ProcessedLyricItem, index: number) => {
|
||||
if (item.type !== 'lyric') return []
|
||||
const lyricData = item.data
|
||||
const isOn = isYrcMode.value ? isYrcLineOn(index) : isLineActive(index)
|
||||
return [
|
||||
'lrc-line',
|
||||
isYrcMode.value ? 'is-yrc' : 'is-lrc',
|
||||
{
|
||||
on: isOn,
|
||||
'is-bg': lyricData.isBG,
|
||||
'is-duet': lyricData.isDuet
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const getLyricLineStyle = (item: ProcessedLyricItem, index: number) => {
|
||||
if (item.type !== 'lyric') return {}
|
||||
|
||||
// 如果用户正在滚动,或者模糊设置关闭,则不模糊
|
||||
if (!settings.lyricsBlur.value || userScrolling.value) return { filter: 'blur(0)' }
|
||||
|
||||
const activeIdx = firstActiveIndex.value
|
||||
const isOn = isLineActive(index)
|
||||
return {
|
||||
filter: isOn ? 'blur(0)' : `blur(${Math.min(Math.abs(activeIdx - index) * 1.8, 10)}px)`
|
||||
}
|
||||
}
|
||||
|
||||
const jumpSeek = (time: number) => {
|
||||
if (!time) return
|
||||
userScrolling.value = false
|
||||
if (userScrollTimeoutId !== null) {
|
||||
clearTimeout(userScrollTimeoutId)
|
||||
userScrollTimeoutId = null
|
||||
}
|
||||
|
||||
const offsetMs = settings.lyricOffset.value
|
||||
const targetTime = (time - offsetMs) / 1000
|
||||
|
||||
audioPlayerControl.seekAndPlay(targetTime)
|
||||
}
|
||||
|
||||
// Utils
|
||||
const getFontSize = (size: number, mode: string) => {
|
||||
if (mode === 'adaptive') {
|
||||
return `calc(${size} / 1080 * 100vh)`
|
||||
}
|
||||
return `${size}px`
|
||||
}
|
||||
|
||||
const getLyricLanguage = (lyric: string): 'ja' | 'ko' | 'zh-CN' | 'en' => {
|
||||
if (!lyric || typeof lyric !== 'string') return 'en'
|
||||
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(lyric)) return 'ja'
|
||||
if (/[\uAC00-\uD7AF]/.test(lyric)) return 'ko'
|
||||
if (/[\u4E00-\u9FFF]/.test(lyric)) return 'zh-CN'
|
||||
return 'en'
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(firstActiveIndex, (val, oldVal) => {
|
||||
lyricsScroll(val)
|
||||
if (typeof oldVal === 'number' && oldVal >= 0 && oldVal !== val) {
|
||||
yrcFadingLineIndex.value = oldVal
|
||||
yrcFadingUntilAt.value = Date.now() + YRC_LINE_FADE_MS
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
nextTick().then(() => {
|
||||
lyricsScroll(firstActiveIndex.value)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (scrollAnimationId !== null) {
|
||||
cancelAnimationFrame(scrollAnimationId)
|
||||
scrollAnimationId = null
|
||||
}
|
||||
if (userScrollTimeoutId !== null) {
|
||||
clearTimeout(userScrollTimeoutId)
|
||||
userScrollTimeoutId = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lyric {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
.lyric-scroll-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-left: var(--lrc-left-padding, 10px);
|
||||
padding-right: 80px;
|
||||
box-sizing: border-box;
|
||||
/* 隐藏滚动条 */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 990px) {
|
||||
padding-right: 20px;
|
||||
}
|
||||
}
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
&:first-child {
|
||||
height: 300px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
&:last-child {
|
||||
height: 0;
|
||||
padding-top: 100%;
|
||||
}
|
||||
}
|
||||
.lyric-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 倒计时行 */
|
||||
.countdown-line {
|
||||
margin: 6px 0;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
.count-down {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
animation: breathe 4s ease-in-out infinite;
|
||||
.point {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(var(--main-cover-color));
|
||||
transition: opacity 0.5s;
|
||||
@media (max-width: 900px) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lrc-line {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 6px 0;
|
||||
padding: 10px 16px;
|
||||
transform: scale(0.95);
|
||||
transform-origin: left center;
|
||||
will-change: filter, opacity, transform;
|
||||
transition:
|
||||
filter 0.35s,
|
||||
opacity 0.35s,
|
||||
transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
.content {
|
||||
display: block;
|
||||
font-size: var(--lrc-size);
|
||||
font-weight: var(--lrc-bold);
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
hyphens: auto;
|
||||
.content-text {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
overflow: visible;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
.yrc-word {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
padding-block: 0.2em;
|
||||
margin-block: -0.2em;
|
||||
opacity: var(--yrc-opacity, 0.3);
|
||||
}
|
||||
&.end-with-space {
|
||||
margin-right: 12px;
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tran {
|
||||
margin-top: 8px;
|
||||
opacity: 0.6;
|
||||
font-size: var(--lrc-tran-size);
|
||||
transition: opacity 0.35s;
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
hyphens: auto;
|
||||
}
|
||||
.roma {
|
||||
margin-top: 4px;
|
||||
opacity: 0.5;
|
||||
font-size: var(--lrc-roma-size);
|
||||
transition: opacity 0.35s;
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
hyphens: auto;
|
||||
}
|
||||
&.is-lrc {
|
||||
opacity: 0.3;
|
||||
}
|
||||
&.is-yrc {
|
||||
.content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
.tran,
|
||||
.roma {
|
||||
opacity: 0.3;
|
||||
}
|
||||
&.is-bg {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.7);
|
||||
padding: 0px 20px;
|
||||
}
|
||||
&.is-duet {
|
||||
transform-origin: right;
|
||||
.content,
|
||||
.tran,
|
||||
.roma {
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.on {
|
||||
opacity: 1 !important;
|
||||
transform: scale(1);
|
||||
.tran,
|
||||
.roma {
|
||||
opacity: 0.6;
|
||||
}
|
||||
&.is-bg {
|
||||
opacity: 0.85 !important;
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--main-cover-color), 0.14);
|
||||
opacity: 0;
|
||||
z-index: 0;
|
||||
transform: scale(1.05);
|
||||
transition:
|
||||
transform 0.35s ease,
|
||||
opacity 0.35s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.lrc-line:hover {
|
||||
opacity: 1;
|
||||
&::before {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.lrc-line:active {
|
||||
&::before {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.flex-end,
|
||||
&.align-right {
|
||||
span {
|
||||
text-align: right;
|
||||
}
|
||||
.placeholder {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.lrc-line {
|
||||
transform-origin: right;
|
||||
.content {
|
||||
text-align: right;
|
||||
}
|
||||
&.is-duet {
|
||||
transform-origin: left;
|
||||
.content,
|
||||
.tran,
|
||||
.roma {
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
.countdown-line {
|
||||
justify-content: flex-end;
|
||||
.count-down {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.center,
|
||||
&.pure {
|
||||
span {
|
||||
text-align: center !important;
|
||||
}
|
||||
.placeholder {
|
||||
justify-content: center !important;
|
||||
}
|
||||
.lrc-line {
|
||||
transform-origin: center !important;
|
||||
.content {
|
||||
text-align: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
}
|
||||
.countdown-line {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 逐字歌词:激活行启用 mask 动画 */
|
||||
.lrc-line.is-yrc.on {
|
||||
.content-text {
|
||||
.yrc-word {
|
||||
will-change: -webkit-mask-position-x;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, var(--yrc-bright-alpha, 1)) 45.4545454545%,
|
||||
rgba(0, 0, 0, var(--yrc-dark-alpha, 0.3)) 54.5454545455%
|
||||
);
|
||||
mask-size: 220% 100%;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, var(--yrc-bright-alpha, 1)) 45.4545454545%,
|
||||
rgba(0, 0, 0, var(--yrc-dark-alpha, 0.3)) 54.5454545455%
|
||||
);
|
||||
-webkit-mask-size: 220% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position-x: var(--yrc-mask-x, 0%);
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyric-loading {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="site-footer">
|
||||
<div class="footer-info">
|
||||
<span v-if="icpNumber" class="footer-item">
|
||||
<a
|
||||
:href="`https://beian.miit.gov.cn/`"
|
||||
class="icp-link"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ icpNumber }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="gonganNumber" class="footer-item">
|
||||
<a
|
||||
:href="gonganLink || 'https://beian.mps.gov.cn/'"
|
||||
class="icp-link"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ gonganNumber }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="siteTitle" class="footer-item">© {{ currentYear }} {{ siteTitle }}</span>
|
||||
<span v-else class="footer-item">© {{ currentYear }} {{ copyrightOwner }}</span>
|
||||
<span class="footer-item">Worker in {{ responseTime }}ms</span>
|
||||
<span class="footer-item">
|
||||
<a class="voicehub-link" :href="repoUrl" rel="noopener noreferrer" target="_blank">
|
||||
{{ systemName }} v{{ systemVersion }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="isNetlify" class="footer-item">
|
||||
<a
|
||||
href="https://www.netlify.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="netlify-badge"
|
||||
>
|
||||
<img
|
||||
src="https://www.netlify.com/img/global/badges/netlify-color-accent.svg"
|
||||
alt="Deploys by Netlify"
|
||||
>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getCopyrightOwner, getSystemName, getRepoUrl } from '@/utils/core/security'
|
||||
import packageJson from '~~/package.json'
|
||||
|
||||
// 使用 useSiteConfig composable 获取配置
|
||||
const { siteTitle, icp: icpNumber, gonganNumber } = useSiteConfig()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// 自动生成公安联网备案链接
|
||||
const gonganLink = computed(() => {
|
||||
if (!gonganNumber.value) return '#'
|
||||
// 提取数字部分
|
||||
const codeMatch = gonganNumber.value.match(/\d+/)
|
||||
const code = codeMatch ? codeMatch[0] : ''
|
||||
if (!code) return '#'
|
||||
return `https://beian.mps.gov.cn/#/query/webSearch?code=${code}`
|
||||
})
|
||||
|
||||
const isNetlify = config.public.isNetlify
|
||||
|
||||
const copyrightOwner = getCopyrightOwner()
|
||||
const systemName = getSystemName()
|
||||
const repoUrl = getRepoUrl()
|
||||
const systemVersion = packageJson.version
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const responseTime = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.performance) {
|
||||
// 使用 requestAnimationFrame 确保在渲染后计算
|
||||
requestAnimationFrame(() => {
|
||||
const loadTime = Math.round(window.performance.now())
|
||||
responseTime.value = loadTime
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.site-footer {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.footer-item {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer-item:not(:last-child)::after {
|
||||
content: ' | ';
|
||||
margin: 0 10px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.footer-item a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.footer-item a:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.icp-link,
|
||||
.voicehub-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.icp-link:hover,
|
||||
.voicehub-link:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.netlify-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.netlify-badge img {
|
||||
height: 16px;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.netlify-badge:hover img {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.site-footer {
|
||||
padding: 15px 0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.footer-item {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.footer-item:not(:last-child)::after {
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+43
-68
@@ -13,16 +13,16 @@
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
class="relative w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-8 pb-4">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<div class="w-12 h-12 rounded-2xl overflow-hidden flex-shrink-0 bg-bg-tertiary">
|
||||
<div class="w-12 h-12 rounded-2xl overflow-hidden flex-shrink-0 bg-zinc-800">
|
||||
<img
|
||||
v-if="video?.cover"
|
||||
:src="video.cover"
|
||||
@@ -32,22 +32,22 @@
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full bg-primary-hover-10 flex items-center justify-center text-primary"
|
||||
class="w-full h-full bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||||
>
|
||||
<Icon name="play" :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="min-w-0">
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight truncate">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight truncate">
|
||||
{{ video?.title }}
|
||||
</h3>
|
||||
<p class="text-xs font-bold text-text-tertiary uppercase tracking-widest mt-1">
|
||||
{{ locale.uploader }}{{ video?.artist }}
|
||||
<p class="text-xs font-bold text-zinc-500 uppercase tracking-widest mt-1">
|
||||
UP主:{{ video?.artist }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all flex-shrink-0"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-all flex-shrink-0"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
@@ -58,14 +58,14 @@
|
||||
<div class="flex-1 overflow-y-auto p-8 pt-4 custom-scrollbar">
|
||||
<div
|
||||
v-if="episodes.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-text-tertiary"
|
||||
class="flex flex-col items-center justify-center py-12 text-zinc-500"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-800/50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="play" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">{{ locale.empty }}</p>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">暂无剧集</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="program-list space-y-3">
|
||||
@@ -75,13 +75,13 @@
|
||||
class="group flex items-center p-3 sm:p-4 rounded-3xl transition-all"
|
||||
:class="[
|
||||
isCurrentEpisode(episode)
|
||||
? 'bg-primary-hover-10 border border-primary-50'
|
||||
: 'bg-bg-tertiary-30 border border-border-secondary-50 hover:bg-bg-tertiary-50 hover:border-border-tertiary'
|
||||
? 'bg-blue-600/10 border border-blue-500/50'
|
||||
: 'bg-zinc-800/30 border border-zinc-800/50 hover:bg-zinc-800/50 hover:border-zinc-700'
|
||||
]"
|
||||
>
|
||||
<!-- 剧集编号 -->
|
||||
<div
|
||||
class="w-8 h-8 sm:w-12 sm:h-12 rounded-2xl bg-bg-tertiary flex items-center justify-center text-text-tertiary font-black text-xs sm:text-sm flex-shrink-0 mr-3 sm:mr-4 group-hover:text-text-secondary transition-colors"
|
||||
class="w-8 h-8 sm:w-12 sm:h-12 rounded-2xl bg-zinc-800 flex items-center justify-center text-zinc-500 font-black text-xs sm:text-sm flex-shrink-0 mr-3 sm:mr-4 group-hover:text-zinc-300 transition-colors"
|
||||
>
|
||||
P{{ episode.page }}
|
||||
</div>
|
||||
@@ -89,12 +89,12 @@
|
||||
<!-- 剧集信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-text-primary text-sm sm:text-base whitespace-normal sm:whitespace-nowrap sm:truncate line-clamp-2 sm:line-clamp-none group-hover:text-text-primary transition-colors"
|
||||
class="font-bold text-zinc-100 text-sm sm:text-base whitespace-normal sm:whitespace-nowrap sm:truncate line-clamp-2 sm:line-clamp-none group-hover:text-white transition-colors"
|
||||
>
|
||||
{{ episode.part }}
|
||||
</h4>
|
||||
<div
|
||||
class="flex items-center gap-3 mt-1 text-[10px] text-text-tertiary font-bold uppercase tracking-wider"
|
||||
class="flex items-center gap-3 mt-1 text-[10px] text-zinc-500 font-bold uppercase tracking-wider"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
<Icon name="clock" :size="10" class="mr-1" />
|
||||
@@ -107,8 +107,8 @@
|
||||
<div class="ml-2 sm:ml-4 shrink-0 flex items-center gap-2 sm:gap-3">
|
||||
<!-- 预听/暂停按钮 -->
|
||||
<button
|
||||
class="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl bg-bg-tertiary text-text-tertiary hover:bg-bg-quaternary hover:text-text-primary transition-all active:scale-95"
|
||||
:title="isCurrentEpisode(episode) && isPlaying ? locale.pause : locale.preview"
|
||||
class="w-8 h-8 sm:w-10 sm:h-10 flex items-center justify-center rounded-xl bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-100 transition-all active:scale-95"
|
||||
:title="isCurrentEpisode(episode) && isPlaying ? '暂停' : '预听'"
|
||||
@click.stop="togglePlay(episode)"
|
||||
>
|
||||
<Icon
|
||||
@@ -118,30 +118,20 @@
|
||||
</button>
|
||||
|
||||
<template v-if="getEpisodeStatus(episode).played">
|
||||
<button
|
||||
v-if="allowPlayedResubmit"
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-[10px] sm:text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
|
||||
@click.stop="selectEpisode(episode)"
|
||||
>
|
||||
<span v-if="submitting && selectedEpisodeCid === episode.cid">{{ locale.submitting }}</span>
|
||||
<span v-else><span class="hidden sm:inline">{{ locale.select }}</span>{{ locale.submit }}</span>
|
||||
</button>
|
||||
<!-- 已播放标签 -->
|
||||
<div
|
||||
v-else
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-success-10 text-success border border-success-20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-emerald-500/10 text-emerald-500 border border-emerald-500/20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.played }}
|
||||
已播放
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<template v-else-if="getEpisodeStatus(episode).scheduled">
|
||||
<!-- 已排期标签 -->
|
||||
<div
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-warning-10 text-warning border border-warning-20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-amber-500/10 text-amber-500 border border-amber-500/20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.scheduled }}
|
||||
已排期
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -149,9 +139,9 @@
|
||||
<div class="flex flex-col gap-2 items-center">
|
||||
<!-- 已投稿标签 -->
|
||||
<div
|
||||
class="w-full text-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-bg-tertiary text-text-tertiary text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
class="w-full text-center px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-zinc-800 text-zinc-500 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.submitted }}
|
||||
已投稿
|
||||
</div>
|
||||
|
||||
<!-- 点赞按钮(非自己投稿) -->
|
||||
@@ -161,8 +151,8 @@
|
||||
:class="[
|
||||
'w-full px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest transition-all active:scale-95',
|
||||
getEpisodeStatus(episode).voted
|
||||
? 'bg-error-10 text-error border border-error-20 cursor-not-allowed'
|
||||
: 'bg-bg-tertiary hover:bg-bg-quaternary text-text-tertiary hover:text-error'
|
||||
? 'bg-red-500/10 text-red-500 border border-red-500/20 cursor-not-allowed'
|
||||
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-red-500'
|
||||
]"
|
||||
@click.stop="voteEpisode(episode)"
|
||||
>
|
||||
@@ -172,7 +162,7 @@
|
||||
:size="12"
|
||||
:class="getEpisodeStatus(episode).voted ? 'fill-current' : ''"
|
||||
/>
|
||||
<span>{{ getEpisodeStatus(episode).voted ? locale.liked : locale.like }}</span>
|
||||
<span>{{ getEpisodeStatus(episode).voted ? '已点赞' : '点赞' }}</span>
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -182,11 +172,11 @@
|
||||
<!-- 选择投稿按钮(未投稿) -->
|
||||
<button
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-[10px] sm:text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
|
||||
class="px-3 py-1.5 sm:px-4 sm:py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-[10px] sm:text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-blue-900/20"
|
||||
@click.stop="selectEpisode(episode)"
|
||||
>
|
||||
<span v-if="submitting && selectedEpisodeCid === episode.cid">{{ locale.submitting }}</span>
|
||||
<span v-else><span class="hidden sm:inline">{{ locale.select }}</span>{{ locale.submit }}</span>
|
||||
<span v-if="submitting && selectedEpisodeCid === episode.cid">提交中...</span>
|
||||
<span v-else><span class="hidden sm:inline">选择</span>投稿</span>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
@@ -204,10 +194,6 @@ import { ref, computed } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { isBilibiliSong } from '~/utils/bilibiliSource'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
const { songs } = useLocale()
|
||||
const locale = computed(() => songs.value?.bilibiliEpisodesModal || {})
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
@@ -223,10 +209,6 @@ const props = defineProps({
|
||||
currentUserId: {
|
||||
type: Number,
|
||||
default: null
|
||||
},
|
||||
allowPlayedResubmit: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
@@ -271,6 +253,14 @@ const isCurrentEpisode = (episode) => {
|
||||
return false
|
||||
}
|
||||
|
||||
const isEpisodeSubmitted = (episode) => {
|
||||
return props.submittedEpisodes.some((song) => {
|
||||
if (!song.musicId) return false
|
||||
const cid = song.musicId.includes(':') ? song.musicId.split(':')[1] : null
|
||||
return cid === String(episode.cid)
|
||||
})
|
||||
}
|
||||
|
||||
// 获取 episode 的完整状态(包括 voted)
|
||||
const getEpisodeStatus = (episode) => {
|
||||
const submittedSong = props.submittedEpisodes.find((song) => {
|
||||
@@ -324,29 +314,14 @@ const playEpisode = (episode) => {
|
||||
title: props.video?.title,
|
||||
artist: props.video?.artist
|
||||
}
|
||||
|
||||
// 构建所有分P的播放列表
|
||||
const playlist = props.episodes.map(e => ({
|
||||
...e,
|
||||
cover: props.video?.cover,
|
||||
bvid: props.video?.id,
|
||||
title: props.video?.title,
|
||||
artist: props.video?.artist,
|
||||
musicPlatform: 'bilibili',
|
||||
musicId: props.video?.id,
|
||||
bilibiliCid: e.cid
|
||||
}))
|
||||
const currentIndex = playlist.findIndex(e => e.cid === episode.cid)
|
||||
|
||||
emit('play', { song: episodeData, playlist, playlistIndex: currentIndex })
|
||||
emit('play', episodeData)
|
||||
}
|
||||
|
||||
const selectEpisode = (episode) => {
|
||||
const status = getEpisodeStatus(episode)
|
||||
if (submitting.value || (status.submitted && !(props.allowPlayedResubmit && status.played))) return
|
||||
if (submitting.value || isEpisodeSubmitted(episode)) return
|
||||
submitting.value = true
|
||||
selectedEpisodeCid.value = episode.cid
|
||||
emit('submit', { episode, status })
|
||||
emit('submit', episode)
|
||||
}
|
||||
|
||||
const voteEpisode = (episode) => {
|
||||
@@ -392,11 +367,11 @@ defineExpose({
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: var(--overlay-10);
|
||||
background-color: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--overlay-20);
|
||||
background-color: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,162 @@
|
||||
<template>
|
||||
<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-[60] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 border border-zinc-800 w-full max-w-lg rounded-3xl overflow-hidden shadow-2xl flex flex-col"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="p-8 pb-4 flex items-center justify-between">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-amber-500/10 flex items-center justify-center text-amber-500"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="20" />
|
||||
</div>
|
||||
歌曲已存在
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">这首歌曲已经在列表中了,不能重复投稿</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-8 pt-4 space-y-6">
|
||||
<p class="text-sm text-zinc-400 leading-relaxed font-medium">
|
||||
您可以为它点赞支持,或者尝试搜索其他歌曲!
|
||||
</p>
|
||||
|
||||
<!-- 歌曲卡片 -->
|
||||
<div
|
||||
class="flex gap-4 p-5 bg-zinc-950 border border-zinc-800 rounded-3xl group shadow-xl"
|
||||
>
|
||||
<div
|
||||
class="w-20 h-20 rounded-2xl overflow-hidden bg-zinc-900 flex-shrink-0 border border-zinc-800"
|
||||
>
|
||||
<img
|
||||
v-if="song.cover"
|
||||
:alt="song.title"
|
||||
:src="convertToHttps(song.cover)"
|
||||
class="w-full h-full object-cover group-hover:scale-110 transition-transform duration-500"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div v-else class="w-full h-full flex items-center justify-center text-zinc-700">
|
||||
<Icon name="music" :size="32" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0 py-1">
|
||||
<h4 class="text-base font-black text-zinc-100 truncate mb-1">{{ song.title }}</h4>
|
||||
<p
|
||||
class="text-[10px] font-black uppercase tracking-widest text-zinc-500 truncate mb-3"
|
||||
>
|
||||
{{ song.artist }}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="flex items-center gap-1.5 text-[10px] font-black text-red-500 uppercase tracking-widest"
|
||||
>
|
||||
<Icon name="heart" :size="12" class="fill-current" />
|
||||
{{ song.voteCount || 0 }} 票
|
||||
</div>
|
||||
<div
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[10px] font-black uppercase tracking-widest border',
|
||||
song.played
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
]"
|
||||
>
|
||||
{{ song.played ? '已播放' : '待播放' }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[10px] text-zinc-600 font-black uppercase tracking-widest mt-2">
|
||||
投稿者:{{ song.requester }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="p-8 pt-0 flex gap-3">
|
||||
<button
|
||||
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest active:scale-95"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
返回重选
|
||||
</button>
|
||||
<button
|
||||
:disabled="liking || song.voted"
|
||||
class="flex-[2] px-6 py-4 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-2xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50"
|
||||
@click="handleLike"
|
||||
>
|
||||
<Icon v-if="liking" name="loader" :size="16" class="animate-spin" />
|
||||
<Icon v-else name="heart" :size="16" :class="[song.voted ? 'fill-current' : '']" />
|
||||
{{ song.voted ? '已点赞' : '立即点赞' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { ref } from 'vue'
|
||||
import type { Song } from '~/types'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
song: Song
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
close: []
|
||||
like: [songId: number]
|
||||
}>()
|
||||
|
||||
const liking = ref(false)
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const handleImageError = (event: Event) => {
|
||||
const target = event.target as HTMLImageElement
|
||||
target.style.display = 'none'
|
||||
}
|
||||
|
||||
const handleLike = async () => {
|
||||
if (liking.value || props.song.voted) return
|
||||
|
||||
liking.value = true
|
||||
try {
|
||||
emit('like', props.song.id)
|
||||
} finally {
|
||||
liking.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,484 @@
|
||||
<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-[2000] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
@click.self="close"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-3xl overflow-hidden shadow-2xl flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<!-- 结果展示视图 -->
|
||||
<div
|
||||
v-if="importResult"
|
||||
class="flex flex-col flex-1 overflow-hidden animate-in fade-in duration-500"
|
||||
>
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-emerald-500/10 flex items-center justify-center text-emerald-500"
|
||||
>
|
||||
<Icon name="success" :size="20" />
|
||||
</div>
|
||||
导入结果
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">歌曲导入操作已完成</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8 space-y-8 custom-scrollbar">
|
||||
<div class="grid grid-cols-2 gap-4">
|
||||
<div
|
||||
class="p-6 bg-emerald-500/5 border border-emerald-500/20 rounded-3xl flex flex-col items-center gap-2"
|
||||
>
|
||||
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-widest"
|
||||
>成功导入</span
|
||||
>
|
||||
<span class="text-4xl font-black text-emerald-400">{{
|
||||
importResult.success
|
||||
}}</span>
|
||||
</div>
|
||||
<div
|
||||
class="p-6 bg-red-500/5 border border-red-500/20 rounded-3xl flex flex-col items-center gap-2"
|
||||
>
|
||||
<span class="text-[10px] font-black text-red-500 uppercase tracking-widest"
|
||||
>失败/跳过</span
|
||||
>
|
||||
<span class="text-4xl font-black text-red-400">{{ importResult.failed }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="importResult.details && importResult.details.length > 0" class="space-y-4">
|
||||
<div
|
||||
class="flex items-center gap-2 text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>
|
||||
<Icon name="info" :size="12" />
|
||||
详细处理记录
|
||||
</div>
|
||||
<div class="space-y-2 max-h-48 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div
|
||||
v-for="(detail, index) in importResult.details"
|
||||
:key="index"
|
||||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-xl text-xs text-zinc-400 font-medium"
|
||||
>
|
||||
{{ detail }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
class="flex flex-col items-center justify-center py-10 text-emerald-500/50"
|
||||
>
|
||||
<Icon name="success" :size="48" class="mb-4" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">全部歌曲导入成功!</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-8 pt-4 border-t border-zinc-800/50 bg-zinc-900/50">
|
||||
<button
|
||||
class="w-full px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest shadow-lg active:scale-95"
|
||||
@click="close"
|
||||
>
|
||||
完成并返回
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 正常导入视图 -->
|
||||
<div v-else class="flex flex-col flex-1 overflow-hidden">
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||||
>
|
||||
<Icon name="download" :size="20" />
|
||||
</div>
|
||||
从历史学期导入
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">选择以往学期的歌曲并导入到当前学期</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-8 py-6 space-y-6 border-b border-zinc-800/50">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>选择学期</label
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="selectedSemester"
|
||||
:options="semesterList"
|
||||
label-key="name"
|
||||
value-key="name"
|
||||
placeholder="请选择目标学期"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="flex bg-zinc-950 p-1 rounded-xl border border-zinc-800">
|
||||
<button
|
||||
v-for="type in ['unplayed', 'played', 'all']"
|
||||
:key="type"
|
||||
type="button"
|
||||
:class="[
|
||||
'flex-1 px-4 py-2 text-[10px] font-black uppercase tracking-widest rounded-lg transition-all',
|
||||
filterType === type
|
||||
? 'bg-blue-600 text-white shadow-lg'
|
||||
: 'text-zinc-500 hover:text-zinc-300'
|
||||
]"
|
||||
@click="filterType = type"
|
||||
>
|
||||
{{ getFilterLabel(type) }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar bg-zinc-950/30">
|
||||
<div
|
||||
v-if="loadingSemesters || loadingSongs"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-500"
|
||||
>
|
||||
<Icon name="refresh" :size="32" class="animate-spin mb-4 text-blue-500" />
|
||||
<div class="text-[10px] font-black uppercase tracking-widest">正在加载歌曲...</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex flex-col items-center justify-center py-20 text-center px-8"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center text-red-500 mb-4"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm text-zinc-400 mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="loadSongs"
|
||||
>
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="!selectedSemester"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-600"
|
||||
>
|
||||
<Icon name="list" :size="48" class="mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">请先选择一个学期</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="filteredSongs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-600"
|
||||
>
|
||||
<Icon name="search" :size="48" class="mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">没有找到符合条件的歌曲</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 gap-2">
|
||||
<div
|
||||
v-for="song in filteredSongs"
|
||||
:key="song.id"
|
||||
class="group flex items-center gap-4 p-3 rounded-2xl cursor-pointer transition-all border"
|
||||
:class="[
|
||||
isSelected(song.id)
|
||||
? 'bg-blue-600/10 border-blue-500/30 shadow-lg'
|
||||
: 'bg-zinc-900 border-transparent hover:border-zinc-800'
|
||||
]"
|
||||
@click="toggleSelection(song.id)"
|
||||
>
|
||||
<div
|
||||
class="w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-all"
|
||||
:class="[
|
||||
isSelected(song.id)
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-zinc-800 text-transparent'
|
||||
]"
|
||||
>
|
||||
<Icon name="check" :size="14" />
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="w-12 h-12 rounded-xl overflow-hidden bg-zinc-800 flex-shrink-0 shadow-md"
|
||||
>
|
||||
<img
|
||||
:src="convertToHttps(song.cover)"
|
||||
alt="cover"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4 class="text-sm font-black text-zinc-100 truncate mb-0.5">
|
||||
{{ song.title }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-[10px] text-zinc-500 truncate">{{ song.artist }}</span>
|
||||
<span class="text-[10px] text-zinc-700 font-bold">•</span>
|
||||
<span class="text-[10px] text-zinc-600 truncate">{{ song.requester }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-tighter border',
|
||||
song.played
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-zinc-800 text-zinc-500 border-zinc-700'
|
||||
]"
|
||||
>
|
||||
{{ song.played ? '已收录' : '未收录' }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-8 border-t border-zinc-800/50 bg-zinc-900/50 flex flex-col md:flex-row gap-4 items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-4 w-full md:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="text-[10px] font-black text-blue-500 hover:text-blue-400 uppercase tracking-widest transition-colors"
|
||||
@click="toggleSelectAll"
|
||||
>
|
||||
{{ isAllSelected ? '取消全选' : '选择当前全部' }}
|
||||
</button>
|
||||
<div class="h-4 w-px bg-zinc-800 hidden md:block" />
|
||||
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest">
|
||||
已选择 <span class="text-blue-500">{{ selectedSongIds.size }}</span> 首歌曲
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-3 w-full md:w-auto">
|
||||
<button
|
||||
type="button"
|
||||
class="flex-1 md:flex-none px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
|
||||
@click="close"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="flex-[2] md:flex-none px-8 py-4 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg shadow-blue-900/20 active:scale-95"
|
||||
:disabled="selectedSongIds.size === 0 || importing"
|
||||
@click="handleImport"
|
||||
>
|
||||
<Icon v-if="importing" name="refresh" :size="16" class="animate-spin" />
|
||||
<Icon v-else name="download" :size="16" />
|
||||
{{ importing ? '正在导入...' : '确认导入' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch, onMounted } from 'vue'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'import-success'])
|
||||
|
||||
const semesterList = ref([])
|
||||
const selectedSemester = ref('')
|
||||
const songs = ref([])
|
||||
const loadingSemesters = ref(false)
|
||||
const loadingSongs = ref(false)
|
||||
const error = ref('')
|
||||
const filterType = ref('unplayed') // '未收录', '已收录', '全部'
|
||||
const selectedSongIds = ref(new Set())
|
||||
const importing = ref(false)
|
||||
const importResult = ref(null)
|
||||
|
||||
const getFilterLabel = (type) => {
|
||||
const map = {
|
||||
unplayed: '未被收录',
|
||||
played: '被收录',
|
||||
all: '全部'
|
||||
}
|
||||
return map[type]
|
||||
}
|
||||
|
||||
const filteredSongs = computed(() => {
|
||||
if (!songs.value) return []
|
||||
return songs.value.filter((song) => {
|
||||
if (filterType.value === 'all') return true
|
||||
if (filterType.value === 'played') return song.played
|
||||
if (filterType.value === 'unplayed') return !song.played
|
||||
return true
|
||||
})
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return (
|
||||
filteredSongs.value.length > 0 &&
|
||||
filteredSongs.value.every((s) => selectedSongIds.value.has(s.id))
|
||||
)
|
||||
})
|
||||
|
||||
const isSelected = (id) => selectedSongIds.value.has(id)
|
||||
|
||||
const toggleSelection = (id) => {
|
||||
if (selectedSongIds.value.has(id)) {
|
||||
selectedSongIds.value.delete(id)
|
||||
} else {
|
||||
selectedSongIds.value.add(id)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
// 取消全选可见项
|
||||
filteredSongs.value.forEach((s) => selectedSongIds.value.delete(s.id))
|
||||
} else {
|
||||
// 全选可见项
|
||||
filteredSongs.value.forEach((s) => selectedSongIds.value.add(s.id))
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSemesters = async () => {
|
||||
loadingSemesters.value = true
|
||||
try {
|
||||
const res = await $fetch('/api/semesters/options')
|
||||
if (res.success) {
|
||||
// 彻底过滤掉当前活跃学期,只显示往期学期供导入
|
||||
semesterList.value = res.data.filter(
|
||||
(sem) => sem.isActive === false || sem.isActive === 0 || sem.isActive === '0'
|
||||
)
|
||||
// 不自动选择,强制用户手动选择
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取学期列表失败', e)
|
||||
error.value = '加载学期列表失败'
|
||||
} finally {
|
||||
loadingSemesters.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const loadSongs = async () => {
|
||||
if (!selectedSemester.value) return
|
||||
|
||||
loadingSongs.value = true
|
||||
error.value = ''
|
||||
songs.value = []
|
||||
selectedSongIds.value.clear()
|
||||
|
||||
try {
|
||||
const res = await $fetch('/api/songs', {
|
||||
params: {
|
||||
semester: selectedSemester.value,
|
||||
scope: 'mine',
|
||||
bypass_cache: 'true' // 确保获取最新数据
|
||||
}
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
songs.value = res.data.songs
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('获取歌曲失败', e)
|
||||
error.value = '加载歌曲列表失败'
|
||||
} finally {
|
||||
loadingSongs.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleImport = async () => {
|
||||
if (selectedSongIds.value.size === 0) return
|
||||
|
||||
importing.value = true
|
||||
try {
|
||||
const res = await $fetch('/api/songs/import', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
songIds: Array.from(selectedSongIds.value)
|
||||
}
|
||||
})
|
||||
|
||||
if (res.success) {
|
||||
if (res.results) {
|
||||
importResult.value = res.results
|
||||
} else {
|
||||
// 如果结果结构不同,进行降级处理
|
||||
importResult.value = {
|
||||
success: res.count,
|
||||
failed: 0,
|
||||
details: []
|
||||
}
|
||||
}
|
||||
emit('import-success')
|
||||
// 不关闭弹窗,显示结果视图
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('导入失败', e)
|
||||
if (window.$showNotification) {
|
||||
window.$showNotification(e.message || '导入失败', 'error')
|
||||
}
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
importResult.value = null
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
importResult.value = null // 重置结果
|
||||
if (semesterList.value.length === 0) {
|
||||
fetchSemesters()
|
||||
}
|
||||
// 重置状态
|
||||
selectedSongIds.value.clear()
|
||||
filterType.value = 'unplayed'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(selectedSemester, () => {
|
||||
if (selectedSemester.value) {
|
||||
loadSongs()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
+30
-38
@@ -13,27 +13,27 @@
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
@click="handleClose"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-sm bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl overflow-hidden"
|
||||
class="relative w-full max-w-sm bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-border-secondary-50">
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary"
|
||||
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||||
>
|
||||
<Icon name="music" :size="20" />
|
||||
</div>
|
||||
{{ locale.neteaseTitle }}
|
||||
网易云扫码登录
|
||||
</h3>
|
||||
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.neteaseDesc }}</p>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">扫描二维码以安全登录您的账号</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary rounded-2xl transition-all"
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||||
@click="handleClose"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
@@ -43,31 +43,27 @@
|
||||
<!-- 主体 -->
|
||||
<div class="p-8 pt-4 flex flex-col items-center">
|
||||
<div class="w-full flex flex-col items-center min-h-[250px] justify-center">
|
||||
<div v-if="loading" class="flex flex-col items-center text-text-tertiary">
|
||||
<AppSpinner :size="48" class="mb-4" />
|
||||
<p class="font-bold uppercase tracking-widest text-[10px]">{{ locale.loadingQr }}</p>
|
||||
<div v-if="loading" class="flex flex-col items-center text-zinc-500">
|
||||
<Icon name="loader" :size="48" class="mb-4 animate-spin text-zinc-400" />
|
||||
<p class="font-bold uppercase tracking-widest text-[10px]">正在获取二维码...</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="qrImg" class="relative group">
|
||||
<div
|
||||
class="p-4 bg-bg-secondary rounded-3xl shadow-inner transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
class="p-4 bg-white rounded-3xl shadow-inner transition-transform duration-500 group-hover:scale-[1.02]"
|
||||
>
|
||||
<img
|
||||
:src="qrImg"
|
||||
alt="Login QR Code"
|
||||
class="w-44 h-44 rounded-2xl object-contain"
|
||||
>
|
||||
<img :src="qrImg" alt="Login QR Code" class="w-44 h-44 object-contain" >
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="isExpired"
|
||||
class="absolute inset-0 bg-bg-secondary-90 backdrop-blur-sm rounded-3xl flex items-center justify-center cursor-pointer transition-all hover:bg-bg-secondary-80"
|
||||
class="absolute inset-0 bg-zinc-900/90 backdrop-blur-sm rounded-3xl flex items-center justify-center cursor-pointer transition-all hover:bg-zinc-900/80"
|
||||
@click="initLogin"
|
||||
>
|
||||
<div class="flex flex-col items-center text-text-primary">
|
||||
<Icon name="refresh" :size="40" class="mb-3 text-text-tertiary" />
|
||||
<span class="font-black uppercase tracking-widest text-xs">{{ locale.qrExpired }}</span>
|
||||
<span class="text-[10px] text-text-tertiary mt-1 font-bold">{{ locale.clickRefresh }}</span>
|
||||
<div class="flex flex-col items-center text-zinc-100">
|
||||
<Icon name="refresh" :size="40" class="mb-3 text-zinc-400" />
|
||||
<span class="font-black uppercase tracking-widest text-xs">二维码已失效</span>
|
||||
<span class="text-[10px] text-zinc-500 mt-1 font-bold">点击刷新</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -80,39 +76,39 @@
|
||||
>
|
||||
<p
|
||||
v-if="status === 800"
|
||||
class="text-text-tertiary text-xs font-black uppercase tracking-widest"
|
||||
class="text-zinc-400 text-xs font-black uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.expiredRefresh }}
|
||||
二维码已过期,请点击刷新
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 801"
|
||||
class="text-text-tertiary text-xs font-black uppercase tracking-widest"
|
||||
class="text-zinc-400 text-xs font-black uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.neteaseWaiting }}
|
||||
请使用网易云音乐APP扫码登录
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 802"
|
||||
class="text-primary text-xs font-black uppercase tracking-widest flex items-center justify-center"
|
||||
class="text-blue-500 text-xs font-black uppercase tracking-widest flex items-center justify-center"
|
||||
>
|
||||
<Icon name="check" :size="16" class="mr-2" />
|
||||
{{ locale.scanSuccess }}
|
||||
扫描成功,请在手机上确认
|
||||
</p>
|
||||
<p
|
||||
v-else-if="status === 803"
|
||||
class="text-success text-xs font-black uppercase tracking-widest"
|
||||
class="text-emerald-500 text-xs font-black uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.neteaseSuccess }}
|
||||
登录成功,正在跳转...
|
||||
</p>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 说明提示 -->
|
||||
<div class="mt-8 p-4 bg-bg-tertiary-30 rounded-2xl border border-border-secondary-50 w-full">
|
||||
<div class="mt-8 p-4 bg-zinc-800/30 rounded-2xl border border-zinc-800/50 w-full">
|
||||
<p
|
||||
class="text-[10px] leading-relaxed text-text-tertiary text-center uppercase tracking-[0.15em] font-black"
|
||||
class="text-[10px] leading-relaxed text-zinc-500 text-center uppercase tracking-[0.15em] font-black"
|
||||
>
|
||||
{{ locale.neteaseTip }}
|
||||
说明:登录状态将保存到您的浏览器中,用于搜索播客等功能。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -123,18 +119,14 @@
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onUnmounted, ref, watch } from 'vue'
|
||||
import { onUnmounted, ref, watch } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import AppSpinner from '~/components/UI/Common/AppSpinner.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { songs } = useLocale()
|
||||
const locale = computed(() => songs.value?.musicLoginModal || {})
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
+69
-74
@@ -2,22 +2,22 @@
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-bg-primary-60 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<Transition name="scale">
|
||||
<div
|
||||
v-if="show"
|
||||
class="bg-bg-secondary border border-border-secondary rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]"
|
||||
class="bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl w-full max-w-lg overflow-hidden flex flex-col max-h-[90vh]"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-4 border-b border-border-secondary shrink-0">
|
||||
<h3 class="text-sm font-black text-text-primary uppercase tracking-widest">
|
||||
{{ locale.title }}
|
||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800 shrink-0">
|
||||
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">
|
||||
上传到网易云音乐
|
||||
</h3>
|
||||
<button
|
||||
class="text-text-tertiary hover:text-text-secondary transition-colors"
|
||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@click="closeDialog"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
@@ -28,13 +28,13 @@
|
||||
<div class="flex-1 overflow-y-auto p-6 space-y-8 custom-scrollbar">
|
||||
<!-- 登录状态检查 -->
|
||||
<section v-if="!isLoggedIn" class="space-y-3">
|
||||
<div class="bg-warning-10 border border-warning-20 rounded-2xl p-4">
|
||||
<p class="text-xs text-warning mb-3">{{ locale.loginRequired }}</p>
|
||||
<div class="bg-yellow-500/10 border border-yellow-500/20 rounded-2xl p-4">
|
||||
<p class="text-xs text-yellow-400 mb-3">请先登录网易云音乐账号</p>
|
||||
<button
|
||||
class="w-full py-2 px-4 bg-primary-hover hover:bg-primary-hover text-text-primary text-xs font-bold rounded-xl transition-colors"
|
||||
class="w-full py-2 px-4 bg-blue-600 hover:bg-blue-700 text-white text-xs font-bold rounded-xl transition-colors"
|
||||
@click="showLoginModal"
|
||||
>
|
||||
{{ locale.loginNow }}
|
||||
立即登录
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@@ -42,8 +42,8 @@
|
||||
<template v-else>
|
||||
<!-- 音质选择 -->
|
||||
<section class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase text-text-disabled tracking-[0.2em] px-1"
|
||||
>{{ locale.quality }}</label
|
||||
<label class="text-[10px] font-black uppercase text-zinc-600 tracking-[0.2em] px-1"
|
||||
>选择音质</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<button
|
||||
@@ -52,8 +52,8 @@
|
||||
class="flex flex-col p-4 rounded-2xl border text-left transition-all relative overflow-hidden group"
|
||||
:class="[
|
||||
selectedQuality === option.value
|
||||
? 'bg-primary-hover-10 border-primary shadow-sm'
|
||||
: 'bg-bg-primary border-border-secondary hover:border-border-tertiary'
|
||||
? 'bg-blue-600/10 border-blue-500 shadow-sm'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
@click="selectedQuality = option.value"
|
||||
>
|
||||
@@ -61,16 +61,16 @@
|
||||
<span
|
||||
class="text-xs font-bold transition-colors"
|
||||
:class="
|
||||
selectedQuality === option.value ? 'text-primary' : 'text-text-primary'
|
||||
selectedQuality === option.value ? 'text-blue-400' : 'text-zinc-200'
|
||||
"
|
||||
>{{ option.label }}</span
|
||||
>
|
||||
<div
|
||||
v-if="selectedQuality === option.value"
|
||||
class="w-1.5 h-1.5 bg-primary rounded-full shadow-[0_0_8px_var(--primary-50)]"
|
||||
class="w-1.5 h-1.5 bg-blue-500 rounded-full shadow-[0_0_8px_rgba(59,130,246,0.5)]"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[10px] text-text-tertiary relative z-10">{{
|
||||
<span class="text-[10px] text-zinc-500 relative z-10">{{
|
||||
option.description
|
||||
}}</span>
|
||||
</button>
|
||||
@@ -79,20 +79,20 @@
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<section class="space-y-3">
|
||||
<label class="text-[10px] font-black uppercase text-text-disabled tracking-[0.2em] px-1"
|
||||
>{{ locale.songInfo }}</label
|
||||
<label class="text-[10px] font-black uppercase text-zinc-600 tracking-[0.2em] px-1"
|
||||
>歌曲信息</label
|
||||
>
|
||||
<div class="bg-bg-primary border border-border-secondary rounded-2xl p-4 space-y-2">
|
||||
<div class="bg-zinc-950 border border-zinc-800 rounded-2xl p-4 space-y-2">
|
||||
<div class="flex items-center gap-3">
|
||||
<img
|
||||
v-if="song?.img || song?.cover"
|
||||
:src="song.img || song.cover"
|
||||
:alt="locale.coverAlt"
|
||||
alt="封面"
|
||||
class="w-12 h-12 rounded-lg object-cover"
|
||||
>
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-sm font-bold text-text-primary truncate">{{ songName }}</p>
|
||||
<p class="text-xs text-text-tertiary truncate">{{ artistName }}</p>
|
||||
<p class="text-sm font-bold text-zinc-200 truncate">{{ songName }}</p>
|
||||
<p class="text-xs text-zinc-500 truncate">{{ artistName }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -101,27 +101,27 @@
|
||||
<!-- 上传进度 -->
|
||||
<section
|
||||
v-if="uploading || uploadProgress > 0"
|
||||
class="space-y-3 pt-4 border-t border-border-secondary-50"
|
||||
class="space-y-3 pt-4 border-t border-zinc-800/50"
|
||||
>
|
||||
<div
|
||||
class="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider"
|
||||
>
|
||||
<span class="text-text-tertiary">{{ uploadStatus }}</span>
|
||||
<span class="text-primary">{{ uploadProgress }}%</span>
|
||||
<span class="text-zinc-400">{{ uploadStatus }}</span>
|
||||
<span class="text-blue-400">{{ uploadProgress }}%</span>
|
||||
</div>
|
||||
<div
|
||||
class="h-2 bg-bg-primary rounded-full overflow-hidden border border-border-secondary-50 relative"
|
||||
class="h-2 bg-zinc-950 rounded-full overflow-hidden border border-zinc-800/50 relative"
|
||||
>
|
||||
<div
|
||||
class="h-full bg-gradient-to-r from-primary to-primary-hover transition-all duration-300 ease-out relative overflow-hidden"
|
||||
class="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-300 ease-out relative overflow-hidden"
|
||||
:style="{ width: `${uploadProgress}%` }"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-gradient-to-r from-transparent via-[var(--shimmer-overlay)] to-transparent animate-shimmer"
|
||||
class="absolute inset-0 bg-gradient-to-r from-transparent via-white/20 to-transparent animate-shimmer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<p v-if="uploadMessage" class="text-[10px] text-text-tertiary text-center">
|
||||
<p v-if="uploadMessage" class="text-[10px] text-zinc-500 text-center">
|
||||
{{ uploadMessage }}
|
||||
</p>
|
||||
</section>
|
||||
@@ -129,20 +129,20 @@
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<div v-if="isLoggedIn" class="p-4 border-t border-border-secondary shrink-0 flex gap-3">
|
||||
<div v-if="isLoggedIn" class="p-4 border-t border-zinc-800 shrink-0 flex gap-3">
|
||||
<button
|
||||
class="flex-1 py-3 px-4 bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||||
class="flex-1 py-3 px-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
|
||||
:disabled="uploading"
|
||||
@click="closeDialog"
|
||||
>
|
||||
{{ locale.cancel }}
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 py-3 px-4 bg-primary-hover hover:bg-primary-hover text-text-primary text-xs font-bold rounded-xl transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
class="flex-1 py-3 px-4 bg-blue-600 hover:bg-blue-700 text-white text-xs font-bold rounded-xl transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
:disabled="uploading"
|
||||
@click="startUpload"
|
||||
>
|
||||
{{ uploading ? locale.uploading : locale.startUpload }}
|
||||
{{ uploading ? '上传中...' : '开始上传' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -157,7 +157,6 @@ import Icon from '~/components/UI/Icon.vue'
|
||||
import { useAudioQuality, QUALITY_OPTIONS } from '~/composables/useAudioQuality'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import CryptoJS from 'crypto-js'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
|
||||
interface Props {
|
||||
show: boolean
|
||||
@@ -165,15 +164,12 @@ interface Props {
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const requestLocale = computed(() => songsLocale.value?.requestForm || {})
|
||||
const locale = computed(() => useSafeLocale(requestLocale.value?.neteaseUpload || {}))
|
||||
|
||||
const songName = computed(
|
||||
() => props.song?.name || props.song?.song || props.song?.title || locale.value.unknownSong
|
||||
() => props.song?.name || props.song?.song || props.song?.title || '未知歌曲'
|
||||
)
|
||||
const artistName = computed(() => props.song?.singer || props.song?.artist || locale.value.unknownArtist)
|
||||
const albumName = computed(() => props.song?.album || locale.value.unknownAlbum)
|
||||
const artistName = computed(() => props.song?.singer || props.song?.artist || '未知歌手')
|
||||
const albumName = computed(() => props.song?.album || '未知专辑')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close' | 'upload-success' | 'show-login'): void
|
||||
@@ -190,7 +186,6 @@ const uploadProgress = ref(0)
|
||||
const uploadStatus = ref('')
|
||||
const uploadMessage = ref('')
|
||||
const isLoggedIn = ref(false)
|
||||
const getLocaleText = (key, ...args) => formatLocaleValue(locale.value?.[key], ...args)
|
||||
|
||||
// 检查登录状态
|
||||
const checkLoginStatus = () => {
|
||||
@@ -233,10 +228,10 @@ const getNeteaseCookie = () => {
|
||||
|
||||
// 获取QQ音乐下载链接
|
||||
const getQQMusicUrl = async (strMediaMid: string, quality: number): Promise<string> => {
|
||||
uploadStatus.value = locale.value.statusGetDownloadUrl
|
||||
uploadStatus.value = '获取下载链接'
|
||||
|
||||
if (!strMediaMid) {
|
||||
throw new Error(locale.value.missingSongId)
|
||||
throw new Error('缺少歌曲ID (strMediaMid)')
|
||||
}
|
||||
|
||||
// 使用vkeys API获取QQ音乐链接
|
||||
@@ -249,7 +244,7 @@ const getQQMusicUrl = async (strMediaMid: string, quality: number): Promise<stri
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(getLocaleText('requestFailed', response.status))
|
||||
throw new Error(`获取链接失败: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
@@ -262,7 +257,7 @@ const getQQMusicUrl = async (strMediaMid: string, quality: number): Promise<stri
|
||||
return url
|
||||
}
|
||||
|
||||
throw new Error(locale.value.invalidPlayUrl)
|
||||
throw new Error('无法获取有效的播放链接')
|
||||
}
|
||||
|
||||
// 通过文件头识别音频格式
|
||||
@@ -316,11 +311,11 @@ const detectAudioType = async (blob: Blob): Promise<string | null> => {
|
||||
|
||||
// 下载音频文件
|
||||
const downloadAudio = async (url: string): Promise<{ blob: Blob; ext: string }> => {
|
||||
uploadStatus.value = locale.value.statusDownloading
|
||||
uploadStatus.value = '正在下载音频'
|
||||
|
||||
const response = await fetch(url)
|
||||
if (!response.ok) {
|
||||
throw new Error(getLocaleText('downloadFailed', response.status))
|
||||
throw new Error(`下载失败: ${response.status}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type')
|
||||
@@ -328,7 +323,7 @@ const downloadAudio = async (url: string): Promise<{ blob: Blob; ext: string }>
|
||||
const total = contentLength ? parseInt(contentLength, 10) : 0
|
||||
|
||||
if (!response.body) {
|
||||
throw new Error(locale.value.emptyResponse)
|
||||
throw new Error('响应体为空')
|
||||
}
|
||||
|
||||
const reader = response.body.getReader()
|
||||
@@ -388,7 +383,7 @@ const downloadAudio = async (url: string): Promise<{ blob: Blob; ext: string }>
|
||||
|
||||
// 上传到网易云音乐
|
||||
const uploadToNetease = async (audioBlob: Blob, filename: string) => {
|
||||
uploadStatus.value = locale.value.statusHashing
|
||||
uploadStatus.value = '正在计算文件指纹'
|
||||
const arrayBuffer = await audioBlob.arrayBuffer()
|
||||
const wordArray = CryptoJS.lib.WordArray.create(arrayBuffer)
|
||||
const md5 = CryptoJS.MD5(wordArray).toString()
|
||||
@@ -411,7 +406,7 @@ const uploadToNetease = async (audioBlob: Blob, filename: string) => {
|
||||
const contentType = contentTypeMap[ext] || 'audio/mpeg'
|
||||
|
||||
// 获取上传凭证
|
||||
uploadStatus.value = locale.value.statusToken
|
||||
uploadStatus.value = '正在获取上传凭证'
|
||||
const tokenUrl = `${baseApiUrl}/cloud/upload/token?time=${Date.now()}`
|
||||
|
||||
const tokenRes = await fetch(tokenUrl, {
|
||||
@@ -430,14 +425,14 @@ const uploadToNetease = async (audioBlob: Blob, filename: string) => {
|
||||
const tokenData = await tokenRes.json()
|
||||
|
||||
if (tokenData.code !== 200) {
|
||||
throw new Error(getLocaleText('tokenFailed', tokenData.msg || tokenData.code))
|
||||
throw new Error(`获取凭证失败: ${tokenData.msg || tokenData.code}`)
|
||||
}
|
||||
|
||||
const { needUpload, uploadUrl, uploadToken, objectKey, resourceId, songId } = tokenData.data
|
||||
|
||||
if (needUpload) {
|
||||
// 上传到对象存储
|
||||
uploadStatus.value = locale.value.statusUploading
|
||||
uploadStatus.value = '正在上传到网易云音乐'
|
||||
|
||||
// 使用上传事件获取进度
|
||||
await new Promise((resolve, reject) => {
|
||||
@@ -455,12 +450,12 @@ const uploadToNetease = async (audioBlob: Blob, filename: string) => {
|
||||
if (xhr.status >= 200 && xhr.status < 300) {
|
||||
resolve(xhr.responseText)
|
||||
} else {
|
||||
reject(new Error(getLocaleText('nosFailed', xhr.status)))
|
||||
reject(new Error(`NOS上传失败: ${xhr.status}`))
|
||||
}
|
||||
})
|
||||
|
||||
xhr.addEventListener('error', () => {
|
||||
reject(new Error(locale.value.nosNetworkError))
|
||||
reject(new Error('NOS网络错误'))
|
||||
})
|
||||
|
||||
xhr.open('POST', uploadUrl)
|
||||
@@ -471,11 +466,11 @@ const uploadToNetease = async (audioBlob: Blob, filename: string) => {
|
||||
})
|
||||
} else {
|
||||
uploadProgress.value = 95
|
||||
uploadStatus.value = locale.value.statusExists
|
||||
uploadStatus.value = '文件已存在,秒传成功'
|
||||
}
|
||||
|
||||
// 完成上传并写入云盘信息
|
||||
uploadStatus.value = locale.value.statusSaving
|
||||
uploadStatus.value = '正在保存云盘信息'
|
||||
|
||||
const completeUrl = `${baseApiUrl}/cloud/upload/complete?time=${Date.now()}`
|
||||
const completeRes = await fetch(completeUrl, {
|
||||
@@ -502,10 +497,10 @@ const uploadToNetease = async (audioBlob: Blob, filename: string) => {
|
||||
if (completeData.code === 502 || completeData.code === 526) {
|
||||
console.warn(`完成接口返回 ${completeData.code},可能为暂时性错误`, completeData)
|
||||
throw new Error(
|
||||
getLocaleText('saveMaybeFailed', completeData.code)
|
||||
`云盘信息保存可能失败 (错误码: ${completeData.code}),请检查云盘内是否成功上传歌曲。`
|
||||
)
|
||||
} else {
|
||||
throw new Error(getLocaleText('publishFailed', completeData.msg || completeData.code))
|
||||
throw new Error(`发布失败: ${completeData.msg || completeData.code}`)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -519,7 +514,7 @@ const startUpload = async () => {
|
||||
|
||||
uploading.value = true
|
||||
uploadProgress.value = 0
|
||||
uploadStatus.value = locale.value.statusPreparing
|
||||
uploadStatus.value = '准备中'
|
||||
uploadMessage.value = ''
|
||||
|
||||
try {
|
||||
@@ -534,32 +529,32 @@ const startUpload = async () => {
|
||||
|
||||
if (!musicId) {
|
||||
console.error('所有可能的ID字段都为空')
|
||||
throw new Error(locale.value.missingResolvedId)
|
||||
throw new Error('无法获取歌曲ID,请重试')
|
||||
}
|
||||
|
||||
// 获取来源歌曲下载链接
|
||||
uploadMessage.value = locale.value.fetchingQQUrl
|
||||
uploadMessage.value = '正在从QQ音乐获取音频链接...'
|
||||
|
||||
const musicUrl = await getQQMusicUrl(musicId, selectedQuality.value)
|
||||
|
||||
if (!musicUrl) {
|
||||
throw new Error(locale.value.missingMusicUrl)
|
||||
throw new Error('无法获取音乐播放链接')
|
||||
}
|
||||
|
||||
// 下载音频文件
|
||||
uploadMessage.value = locale.value.downloadingAudio
|
||||
uploadMessage.value = '正在下载音频文件...'
|
||||
const { blob: audioBlob, ext } = await downloadAudio(musicUrl)
|
||||
|
||||
// 上传到网易云音乐云盘
|
||||
uploadMessage.value = locale.value.uploadingCloud
|
||||
uploadMessage.value = '正在上传到网易云音乐云盘...'
|
||||
const filename = `${artistName.value} - ${songName.value}.${ext}`
|
||||
await uploadToNetease(audioBlob, filename)
|
||||
|
||||
uploadProgress.value = 100
|
||||
uploadStatus.value = locale.value.statusDone
|
||||
uploadMessage.value = locale.value.uploadSuccess
|
||||
uploadStatus.value = '上传完成'
|
||||
uploadMessage.value = '歌曲已成功上传到您的网易云音乐云盘'
|
||||
|
||||
showSuccess(locale.value.uploadSuccess)
|
||||
showSuccess('歌曲已成功上传到网易云音乐云盘')
|
||||
|
||||
emit('upload-success')
|
||||
|
||||
@@ -569,10 +564,10 @@ const startUpload = async () => {
|
||||
}, 1500)
|
||||
} catch (error: any) {
|
||||
console.error('上传失败:', error)
|
||||
uploadStatus.value = locale.value.statusFailed
|
||||
uploadMessage.value = getErrorMessage(error) || locale.value.unknownError
|
||||
uploadStatus.value = '上传失败'
|
||||
uploadMessage.value = error.message || '未知错误'
|
||||
|
||||
showError(getLocaleText('uploadFailed', getErrorMessage(error)))
|
||||
showError(`上传失败: ${error.message}`)
|
||||
} finally {
|
||||
uploading.value = false
|
||||
}
|
||||
@@ -610,12 +605,12 @@ const startUpload = async () => {
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--overlay-10);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--overlay-20);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
+96
-150
@@ -13,10 +13,10 @@
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
class="relative w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
@@ -24,19 +24,19 @@
|
||||
<div class="flex items-center gap-4">
|
||||
<button
|
||||
v-if="view === 'songs'"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-all"
|
||||
@click="backToPlaylists"
|
||||
>
|
||||
<Icon name="arrow-left" :size="20" />
|
||||
</button>
|
||||
<h3
|
||||
class="text-xl font-black text-text-primary tracking-tight truncate max-w-[300px] sm:max-w-md"
|
||||
class="text-xl font-black text-zinc-100 tracking-tight truncate max-w-[300px] sm:max-w-md"
|
||||
>
|
||||
{{ view === 'playlists' ? locale.selectPlaylist : selectedPlaylist?.name || locale.playlistDetails }}
|
||||
{{ view === 'playlists' ? '选择歌单' : selectedPlaylist?.name || '歌单详情' }}
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-full bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-all"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
@@ -47,10 +47,10 @@
|
||||
<div class="flex-1 overflow-y-auto p-8 pt-4 custom-scrollbar">
|
||||
<div
|
||||
v-if="loading"
|
||||
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"
|
||||
>
|
||||
<Icon name="loader" :size="48" class="mb-4 animate-spin text-text-tertiary" />
|
||||
<p class="font-medium">{{ locale.processing }}</p>
|
||||
<Icon name="loader" :size="48" class="mb-4 animate-spin text-zinc-400" />
|
||||
<p class="font-medium">处理中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -58,16 +58,16 @@
|
||||
class="flex flex-col items-center justify-center py-20 text-center px-8"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-error-10 flex items-center justify-center mb-4"
|
||||
class="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="alert-circle" :size="32" class="text-error" />
|
||||
<Icon name="alert-circle" :size="32" class="text-red-500" />
|
||||
</div>
|
||||
<p class="text-text-tertiary font-medium mb-6">{{ error }}</p>
|
||||
<p class="text-zinc-400 font-medium mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-8 py-3 rounded-xl bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-black transition-all active:scale-95 uppercase tracking-widest"
|
||||
class="px-8 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click="retry"
|
||||
>
|
||||
{{ locale.retry }}
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -75,59 +75,49 @@
|
||||
<div v-else-if="view === 'playlists'" class="playlist-list space-y-3">
|
||||
<div
|
||||
v-if="playlists.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-text-tertiary"
|
||||
class="flex flex-col items-center justify-center py-12 text-zinc-500"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-800/50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="music" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="font-medium">{{ locale.emptyPlaylists }}</p>
|
||||
<p class="font-medium">暂无歌单</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="playlist in playlists"
|
||||
v-else
|
||||
:key="playlist.id"
|
||||
class="group flex items-center p-4 bg-bg-tertiary-30 border border-border-secondary-50 rounded-3xl hover:bg-bg-tertiary-50 hover:border-border-tertiary transition-all cursor-pointer"
|
||||
class="group flex items-center p-4 bg-zinc-800/30 border border-zinc-800/50 rounded-3xl hover:bg-zinc-800/50 hover:border-zinc-700 transition-all cursor-pointer"
|
||||
@click="selectPlaylist(playlist)"
|
||||
>
|
||||
<div class="w-16 h-16 rounded-2xl overflow-hidden bg-bg-tertiary mr-4 flex-shrink-0">
|
||||
<div class="w-16 h-16 rounded-2xl overflow-hidden bg-zinc-800 mr-4 flex-shrink-0">
|
||||
<img
|
||||
v-if="playlist.coverImgUrl"
|
||||
:src="convertToHttps(playlist.coverImgUrl)"
|
||||
alt="cover"
|
||||
class="w-full h-full object-cover"
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-full h-full flex items-center justify-center bg-bg-quaternary-50"
|
||||
>
|
||||
<Icon name="heart" :size="22" class="text-text-disabled" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-text-primary truncate group-hover:text-text-primary transition-colors"
|
||||
class="font-bold text-zinc-100 truncate group-hover:text-white transition-colors"
|
||||
>
|
||||
{{ playlist.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-3 mt-1 text-xs text-text-tertiary">
|
||||
<span v-if="playlist.trackCount != null" class="flex items-center">
|
||||
<div class="flex items-center gap-3 mt-1 text-xs text-zinc-500">
|
||||
<span class="flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
{{ callLocale('songCount', `${playlist.trackCount} 首`, playlist.trackCount) }}
|
||||
{{ playlist.trackCount }}首
|
||||
</span>
|
||||
<span
|
||||
v-if="playlist.creator?.nickname"
|
||||
class="flex items-center truncate"
|
||||
>
|
||||
<span class="flex items-center truncate">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
by {{ playlist.creator?.nickname }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-4 text-text-disabled group-hover:text-text-tertiary transition-colors">
|
||||
<div class="ml-4 text-zinc-600 group-hover:text-zinc-400 transition-colors">
|
||||
<Icon name="chevron-right" :size="24" />
|
||||
</div>
|
||||
</div>
|
||||
@@ -137,25 +127,25 @@
|
||||
<div v-else class="song-list space-y-3">
|
||||
<div
|
||||
v-if="songs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-text-tertiary"
|
||||
class="flex flex-col items-center justify-center py-12 text-zinc-500"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-800/50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="music" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="font-medium">{{ locale.emptyPlaylist }}</p>
|
||||
<p class="font-medium">歌单为空</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="song in songs"
|
||||
v-else
|
||||
:key="song.id"
|
||||
class="group flex items-center p-4 bg-bg-tertiary-30 border border-border-secondary-50 rounded-3xl hover:bg-bg-tertiary-50 hover:border-border-tertiary transition-all"
|
||||
class="group flex items-center p-4 bg-zinc-800/30 border border-zinc-800/50 rounded-3xl hover:bg-zinc-800/50 hover:border-zinc-700 transition-all"
|
||||
>
|
||||
<!-- 封面与播放叠加层 -->
|
||||
<div
|
||||
class="relative w-14 h-14 rounded-2xl overflow-hidden bg-bg-tertiary mr-4 flex-shrink-0 group/cover cursor-pointer"
|
||||
class="relative w-14 h-14 rounded-2xl overflow-hidden bg-zinc-800 mr-4 flex-shrink-0 group/cover cursor-pointer"
|
||||
@click="playSong(song)"
|
||||
>
|
||||
<img
|
||||
@@ -165,12 +155,12 @@
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-bg-primary-40 flex items-center justify-center opacity-0 group-hover/cover:opacity-100 transition-opacity"
|
||||
class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover/cover:opacity-100 transition-opacity"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-bg-secondary-20 backdrop-blur-md flex items-center justify-center"
|
||||
class="w-8 h-8 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center"
|
||||
>
|
||||
<Icon name="play" :size="16" class="text-text-primary fill-current" />
|
||||
<Icon name="play" :size="16" class="text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -178,11 +168,11 @@
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-text-primary truncate group-hover:text-text-primary transition-colors"
|
||||
class="font-bold text-zinc-100 truncate group-hover:text-white transition-colors"
|
||||
>
|
||||
{{ song.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2 mt-0.5 text-xs text-text-tertiary truncate">
|
||||
<div class="flex items-center gap-2 mt-0.5 text-xs text-zinc-500 truncate">
|
||||
<span class="truncate">{{ song.ar?.map((a) => a.name).join('/') }}</span>
|
||||
<span v-if="song.al?.name" class="opacity-40 shrink-0">·</span>
|
||||
<span v-if="song.al?.name" class="truncate opacity-60">{{ song.al.name }}</span>
|
||||
@@ -193,46 +183,46 @@
|
||||
<div class="ml-4 shrink-0 flex items-center gap-3">
|
||||
<div
|
||||
v-if="songsLoadingForSimilar"
|
||||
class="text-xs font-bold text-text-disabled animate-pulse"
|
||||
class="text-xs font-bold text-zinc-600 animate-pulse"
|
||||
>
|
||||
{{ locale.processing }}
|
||||
处理中...
|
||||
</div>
|
||||
<div v-else-if="getSimilarSong(song)" class="flex flex-col items-end gap-1.5">
|
||||
<span
|
||||
v-if="getSimilarSong(song)?.played"
|
||||
class="px-2 py-0.5 rounded-md bg-error-10 text-error text-[10px] font-black uppercase tracking-wider"
|
||||
class="px-2 py-0.5 rounded-md bg-red-500/10 text-red-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ requestLocale.played }}
|
||||
已播放
|
||||
</span>
|
||||
<span
|
||||
v-else-if="getSimilarSong(song)?.scheduled"
|
||||
class="px-2 py-0.5 rounded-md bg-warning-10 text-warning text-[10px] font-black uppercase tracking-wider"
|
||||
class="px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ requestLocale.scheduled }}
|
||||
已排期
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-0.5 rounded-md bg-bg-quaternary-50 text-text-tertiary text-[10px] font-black uppercase tracking-wider"
|
||||
class="px-2 py-0.5 rounded-md bg-zinc-700/50 text-zinc-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ locale.exists }}
|
||||
已存在
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="getSimilarSong(song)?.played && isSuperAdmin"
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-xs font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest"
|
||||
class="px-3 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click.stop="selectSong(song)"
|
||||
>
|
||||
{{ submitting && selectedSongId === song.id ? '...' : requestLocale.continueSubmit }}
|
||||
{{ submitting && selectedSongId === song.id ? '...' : '继续投稿' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-3 py-1.5 rounded-xl text-[10px] font-black transition-all active:scale-95 disabled:cursor-not-allowed uppercase tracking-widest"
|
||||
:class="[
|
||||
getSimilarSong(song)?.voted
|
||||
? 'bg-error-10 text-error border border-error-20'
|
||||
: 'bg-bg-tertiary text-text-tertiary hover:bg-bg-quaternary hover:text-text-primary border border-border-tertiary-50 hover:border-border-tertiary'
|
||||
? 'bg-red-500/10 text-red-500 border border-red-500/20'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-100 border border-zinc-700/50 hover:border-zinc-600'
|
||||
]"
|
||||
:disabled="
|
||||
getSimilarSong(song)?.played ||
|
||||
@@ -242,17 +232,17 @@
|
||||
"
|
||||
@click.stop="handleLike(getSimilarSong(song))"
|
||||
>
|
||||
{{ getSimilarSong(song)?.voted ? requestLocale.liked : requestLocale.like }}
|
||||
{{ getSimilarSong(song)?.voted ? '已点赞' : '点赞' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitting || songsLoadingForSimilar"
|
||||
class="px-4 py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
|
||||
class="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-blue-900/20"
|
||||
@click.stop="selectSong(song)"
|
||||
>
|
||||
{{ submitting && selectedSongId === song.id ? requestLocale.submitting : requestLocale.chooseSubmit }}
|
||||
{{ submitting && selectedSongId === song.id ? '提交中...' : '选择投稿' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -261,11 +251,11 @@
|
||||
<div v-if="hasMore" class="pt-6 pb-2 flex justify-center">
|
||||
<button
|
||||
:disabled="moreLoading"
|
||||
class="px-8 py-3 rounded-xl bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black disabled:opacity-50 transition-all flex items-center gap-2 uppercase tracking-widest"
|
||||
class="px-8 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black disabled:opacity-50 transition-all flex items-center gap-2 uppercase tracking-widest"
|
||||
@click="loadMore"
|
||||
>
|
||||
<Icon v-if="moreLoading" name="loader" :size="16" class="animate-spin" />
|
||||
{{ moreLoading ? locale.loadingMore : locale.loadMore }}
|
||||
{{ moreLoading ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -279,35 +269,20 @@
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { getPlaylistTracks, getUserPlaylists } from '~/utils/neteaseApi'
|
||||
import {
|
||||
getQqPlaylistSongs,
|
||||
getQqUserPlaylists,
|
||||
QQ_FAV_PLAYLIST_ID
|
||||
} from '~/utils/qqUserLibrary'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import Icon from '../UI/Icon.vue'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useSemesters } from '~/composables/useSemesters'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { normalizeForMatch as normalizeString } from '~/utils/song-name-normalize'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
cookie: String,
|
||||
uid: [String, Number],
|
||||
platform: { type: String, default: 'netease' }
|
||||
uid: [String, Number]
|
||||
})
|
||||
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const requestLocale = computed(() => songsLocale.value?.requestForm || {})
|
||||
const locale = computed(() => requestLocale.value?.playlistModal || {})
|
||||
const { t: callLocale } = useLocaleText(locale)
|
||||
|
||||
const emit = defineEmits(['close', 'submit', 'play'])
|
||||
|
||||
const isTencentPlatform = computed(() => props.platform === 'tencent')
|
||||
|
||||
const view = ref('playlists') // 'playlists' | 'songs'
|
||||
const playlists = ref([])
|
||||
const songs = ref([])
|
||||
@@ -329,6 +304,17 @@ const auth = useAuth()
|
||||
const { currentSemester, fetchCurrentSemester } = useSemesters()
|
||||
const isSuperAdmin = computed(() => auth.user.value?.role === 'SUPER_ADMIN')
|
||||
|
||||
// 标准化字符串
|
||||
const normalizeString = (str) => {
|
||||
if (!str) return ''
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
|
||||
.replace(/[&&]/g, 'and')
|
||||
.replace(/[feat\.?|ft\.?]/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// 检查是否已存在相似歌曲
|
||||
const getSimilarSong = (songData) => {
|
||||
if (!songData) return null
|
||||
@@ -356,42 +342,20 @@ const getSimilarSong = (songData) => {
|
||||
}
|
||||
|
||||
const fetchUserPlaylists = async () => {
|
||||
if (isTencentPlatform.value ? !props.cookie : (!props.cookie || !props.uid)) return
|
||||
if (!props.cookie || !props.uid) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
if (isTencentPlatform.value) {
|
||||
const list = await getQqUserPlaylists(props.cookie)
|
||||
// 首部固定注入"我喜欢"入口,歌曲经 playlist-songs 专用链路获取
|
||||
list.unshift({
|
||||
id: QQ_FAV_PLAYLIST_ID,
|
||||
name: locale.value.favSongs || 'Liked Songs',
|
||||
cover: '',
|
||||
count: null,
|
||||
origin: 'liked'
|
||||
})
|
||||
if (list.length > 0) {
|
||||
// 补齐歌单卡片模板所需字段
|
||||
playlists.value = list.map((item) => ({
|
||||
...item,
|
||||
coverImgUrl: item.cover,
|
||||
trackCount: item.count
|
||||
}))
|
||||
} else {
|
||||
error.value = locale.value.fetchPlaylistsFailed
|
||||
}
|
||||
const { code, body, message } = await getUserPlaylists(props.uid, props.cookie)
|
||||
if (code === 200 && body && body.playlist) {
|
||||
playlists.value = body.playlist
|
||||
} else {
|
||||
const { code, body, message } = await getUserPlaylists(props.uid, props.cookie)
|
||||
if (code === 200 && body && body.playlist) {
|
||||
playlists.value = body.playlist
|
||||
} else {
|
||||
error.value = message || locale.value.fetchPlaylistsFailed
|
||||
}
|
||||
error.value = message || '获取歌单列表失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = locale.value.networkFailed
|
||||
error.value = '网络请求失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -399,7 +363,7 @@ const fetchUserPlaylists = async () => {
|
||||
}
|
||||
|
||||
const fetchPlaylistSongs = async (playlistId, isLoadMore = false) => {
|
||||
if (isTencentPlatform.value ? !props.cookie : !props.cookie) return
|
||||
if (!props.cookie) return
|
||||
|
||||
if (isLoadMore) {
|
||||
moreLoading.value = true
|
||||
@@ -419,43 +383,31 @@ const fetchPlaylistSongs = async (playlistId, isLoadMore = false) => {
|
||||
// "传递 limit=50&offset=0 得到 1-50。传递 limit=50&offset=50 得到 51-100"
|
||||
// 所以 offset 变量应该跟踪目前为止加载的歌曲数量。
|
||||
|
||||
if (isTencentPlatform.value) {
|
||||
const result = await getQqPlaylistSongs(playlistId, props.cookie, limit.value, offset.value)
|
||||
const { code, body, message } = await getPlaylistTracks(
|
||||
playlistId,
|
||||
limit.value,
|
||||
offset.value,
|
||||
props.cookie
|
||||
)
|
||||
if (code === 200 && body && body.songs) {
|
||||
if (isLoadMore) {
|
||||
songs.value = [...songs.value, ...result.songs]
|
||||
songs.value = [...songs.value, ...body.songs]
|
||||
} else {
|
||||
songs.value = result.songs
|
||||
songs.value = body.songs
|
||||
}
|
||||
|
||||
offset.value += result.songs.length
|
||||
hasMore.value = result.hasMore
|
||||
// 为下次调用更新偏移
|
||||
offset.value += body.songs.length
|
||||
|
||||
// 检查是否还有更多歌曲
|
||||
if (body.songs.length < limit.value) {
|
||||
hasMore.value = false
|
||||
}
|
||||
} else {
|
||||
const { code, body, message } = await getPlaylistTracks(
|
||||
playlistId,
|
||||
limit.value,
|
||||
offset.value,
|
||||
props.cookie
|
||||
)
|
||||
if (code === 200 && body && body.songs) {
|
||||
if (isLoadMore) {
|
||||
songs.value = [...songs.value, ...body.songs]
|
||||
} else {
|
||||
songs.value = body.songs
|
||||
}
|
||||
|
||||
// 为下次调用更新偏移
|
||||
offset.value += body.songs.length
|
||||
|
||||
// 检查是否还有更多歌曲
|
||||
if (body.songs.length < limit.value) {
|
||||
hasMore.value = false
|
||||
}
|
||||
} else {
|
||||
error.value = message || locale.value.fetchSongsFailed
|
||||
}
|
||||
error.value = message || '获取歌单歌曲失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = locale.value.networkFailed
|
||||
error.value = '网络请求失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -549,9 +501,7 @@ const handleLike = async (song) => {
|
||||
|
||||
if (song.played || song.scheduled) {
|
||||
if (window.$showNotification) {
|
||||
const message = song.played
|
||||
? (requestLocale.value.playedCannotLike || '已播放的歌曲不能点赞')
|
||||
: (requestLocale.value.scheduledCannotLike || '已排期的歌曲不能点赞')
|
||||
const message = song.played ? '已播放的歌曲不能点赞' : '已排期的歌曲不能点赞'
|
||||
window.$showNotification(message, 'warning')
|
||||
}
|
||||
return
|
||||
@@ -570,12 +520,10 @@ const playSong = (songData) => {
|
||||
title: songData.name,
|
||||
artist: songData.ar?.map((a) => a.name).join('/'),
|
||||
cover: songData.al?.picUrl,
|
||||
albumId: songData.al?.id,
|
||||
musicPlatform: isTencentPlatform.value ? 'tencent' : 'netease',
|
||||
musicPlatform: 'netease',
|
||||
musicId: songData.id.toString(),
|
||||
sourceInfo: {
|
||||
source: isTencentPlatform.value ? 'tencent-library' : 'netease-backup',
|
||||
playlistId: selectedPlaylist.value?.id,
|
||||
source: 'netease-backup',
|
||||
type: 'song'
|
||||
}
|
||||
})
|
||||
@@ -591,13 +539,11 @@ const selectSong = (songData) => {
|
||||
artist: songData.ar?.map((a) => a.name).join('/'),
|
||||
cover: songData.al?.picUrl,
|
||||
album: songData.al?.name,
|
||||
albumId: songData.al?.id,
|
||||
duration: songData.dt,
|
||||
musicPlatform: isTencentPlatform.value ? 'tencent' : 'netease',
|
||||
musicPlatform: 'netease',
|
||||
musicId: songData.id.toString(),
|
||||
sourceInfo: {
|
||||
source: isTencentPlatform.value ? 'tencent-library' : 'netease-backup',
|
||||
playlistId: selectedPlaylist.value?.id,
|
||||
source: 'netease-backup',
|
||||
type: 'song'
|
||||
}
|
||||
}
|
||||
@@ -627,11 +573,11 @@ defineExpose({
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--overlay-10);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--overlay-20);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
+58
-53
@@ -13,26 +13,26 @@
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-bg-primary-60 backdrop-blur-sm" />
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-2xl bg-bg-secondary border border-border-secondary rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
class="relative w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-8 pb-4">
|
||||
<div class="flex items-center gap-4 min-w-0">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary flex-shrink-0"
|
||||
class="w-12 h-12 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500 flex-shrink-0"
|
||||
>
|
||||
<Icon name="mic" :size="24" />
|
||||
</div>
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight truncate">
|
||||
{{ formatLocale(locale.programListTitle, radioName) }}
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight truncate">
|
||||
{{ radioName }} - 节目列表
|
||||
</h3>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-bg-tertiary-50 text-text-tertiary hover:bg-bg-tertiary hover:text-text-primary transition-all flex-shrink-0"
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-all flex-shrink-0"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
@@ -43,10 +43,10 @@
|
||||
<div class="flex-1 overflow-y-auto p-8 pt-4 custom-scrollbar">
|
||||
<div
|
||||
v-if="loading"
|
||||
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"
|
||||
>
|
||||
<Icon name="refresh" :size="48" class="animate-spin mb-4 text-primary" />
|
||||
<p class="font-black uppercase tracking-widest text-[10px]">{{ locale.loadingPrograms }}</p>
|
||||
<Icon name="refresh" :size="48" class="animate-spin mb-4 text-blue-500" />
|
||||
<p class="font-black uppercase tracking-widest text-[10px]">加载节目中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -54,40 +54,40 @@
|
||||
class="flex flex-col items-center justify-center py-20 text-center px-8"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-error-10 flex items-center justify-center text-error mb-4"
|
||||
class="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center text-red-500 mb-4"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm text-text-tertiary mb-6">{{ error }}</p>
|
||||
<p class="text-sm text-zinc-400 mb-6">{{ error }}</p>
|
||||
<button
|
||||
class="px-8 py-3 bg-bg-tertiary hover:bg-bg-quaternary text-text-primary text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
class="px-8 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="fetchPrograms(false)"
|
||||
>
|
||||
{{ locale.retry }}
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="programs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-12 text-text-tertiary"
|
||||
class="flex flex-col items-center justify-center py-12 text-zinc-500"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-bg-tertiary-50 flex items-center justify-center mb-4"
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-800/50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="mic" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">{{ locale.noPrograms }}</p>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">暂无节目</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="program-list space-y-3">
|
||||
<div
|
||||
v-for="program in programs"
|
||||
:key="program.id"
|
||||
class="group flex items-center p-4 bg-bg-tertiary-30 border border-border-secondary-50 rounded-3xl hover:bg-bg-tertiary-50 hover:border-border-tertiary transition-all"
|
||||
class="group flex items-center p-4 bg-zinc-800/30 border border-zinc-800/50 rounded-3xl hover:bg-zinc-800/50 hover:border-zinc-700 transition-all"
|
||||
>
|
||||
<!-- 封面与播放叠加层 -->
|
||||
<div
|
||||
class="relative w-14 h-14 rounded-2xl overflow-hidden bg-bg-tertiary mr-4 flex-shrink-0 group/cover cursor-pointer"
|
||||
class="relative w-14 h-14 rounded-2xl overflow-hidden bg-zinc-800 mr-4 flex-shrink-0 group/cover cursor-pointer"
|
||||
@click.stop="playProgram(program)"
|
||||
>
|
||||
<img
|
||||
@@ -97,12 +97,12 @@
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-bg-primary-40 flex items-center justify-center opacity-0 group-hover/cover:opacity-100 transition-opacity"
|
||||
class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover/cover:opacity-100 transition-opacity"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-bg-secondary-20 backdrop-blur-md flex items-center justify-center"
|
||||
class="w-8 h-8 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center"
|
||||
>
|
||||
<Icon name="play" :size="16" class="text-text-primary fill-current" />
|
||||
<Icon name="play" :size="16" class="text-white fill-current" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -110,12 +110,12 @@
|
||||
<!-- 节目信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
class="font-bold text-text-primary truncate group-hover:text-text-primary transition-colors"
|
||||
class="font-bold text-zinc-100 truncate group-hover:text-white transition-colors"
|
||||
>
|
||||
{{ program.name }}
|
||||
</h4>
|
||||
<div
|
||||
class="flex items-center gap-3 mt-1 text-[10px] text-text-tertiary font-bold uppercase tracking-wider"
|
||||
class="flex items-center gap-3 mt-1 text-[10px] text-zinc-500 font-bold uppercase tracking-wider"
|
||||
>
|
||||
<span class="flex items-center">
|
||||
{{ formatDate(program.createTime) }}
|
||||
@@ -135,46 +135,46 @@
|
||||
<div class="ml-4 shrink-0 flex items-center gap-3">
|
||||
<div
|
||||
v-if="songsLoadingForSimilar"
|
||||
class="text-[10px] font-black text-text-disabled animate-pulse uppercase tracking-widest"
|
||||
class="text-[10px] font-black text-zinc-600 animate-pulse uppercase tracking-widest"
|
||||
>
|
||||
{{ locale.processing }}
|
||||
处理中...
|
||||
</div>
|
||||
<div v-else-if="getSimilarSong(program)" class="flex flex-col items-end gap-1.5">
|
||||
<span
|
||||
v-if="getSimilarSong(program)?.played"
|
||||
class="px-2 py-0.5 rounded-md bg-error-10 text-error text-[10px] font-black uppercase tracking-wider"
|
||||
class="px-2 py-0.5 rounded-md bg-red-500/10 text-red-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ locale.played }}
|
||||
已播放
|
||||
</span>
|
||||
<span
|
||||
v-else-if="getSimilarSong(program)?.scheduled"
|
||||
class="px-2 py-0.5 rounded-md bg-warning-10 text-warning text-[10px] font-black uppercase tracking-wider"
|
||||
class="px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ locale.scheduled }}
|
||||
已排期
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-0.5 rounded-md bg-bg-quaternary-50 text-text-tertiary text-[10px] font-black uppercase tracking-wider"
|
||||
class="px-2 py-0.5 rounded-md bg-zinc-700/50 text-zinc-500 text-[10px] font-black uppercase tracking-wider"
|
||||
>
|
||||
{{ locale.existing }}
|
||||
已存在
|
||||
</span>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-if="getSimilarSong(program)?.played && isSuperAdmin"
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-[10px] font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest"
|
||||
class="px-3 py-1.5 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click.stop="selectProgram(program)"
|
||||
>
|
||||
{{ submitting && selectedProgramId === program.id ? '...' : locale.continueSubmit }}
|
||||
{{ submitting && selectedProgramId === program.id ? '...' : '继续投稿' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-3 py-1.5 rounded-xl text-[10px] font-black transition-all active:scale-95 disabled:cursor-not-allowed uppercase tracking-widest"
|
||||
:class="[
|
||||
getSimilarSong(program)?.voted
|
||||
? 'bg-error-10 text-error border border-error-20'
|
||||
: 'bg-bg-tertiary text-text-tertiary hover:bg-bg-quaternary hover:text-text-primary border border-border-tertiary-50 hover:border-border-tertiary'
|
||||
? 'bg-red-500/10 text-red-500 border border-red-500/20'
|
||||
: 'bg-zinc-800 text-zinc-400 hover:bg-zinc-700 hover:text-zinc-100 border border-zinc-700/50 hover:border-zinc-600'
|
||||
]"
|
||||
:disabled="
|
||||
getSimilarSong(program)?.played ||
|
||||
@@ -184,17 +184,17 @@
|
||||
"
|
||||
@click.stop="handleLikeFromProgram(getSimilarSong(program))"
|
||||
>
|
||||
{{ getSimilarSong(program)?.voted ? locale.liked : locale.like }}
|
||||
{{ getSimilarSong(program)?.voted ? '已点赞' : '点赞' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitting"
|
||||
class="px-4 py-2 rounded-xl bg-primary-hover hover:bg-primary text-text-primary text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-[var(--primary-glow)]"
|
||||
class="px-4 py-2 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-black disabled:opacity-50 transition-all active:scale-95 shrink-0 uppercase tracking-widest shadow-lg shadow-blue-900/20"
|
||||
@click="selectProgram(program)"
|
||||
>
|
||||
{{ submitting && selectedProgramId === program.id ? locale.submitLoading : locale.selectSubmit }}
|
||||
{{ submitting && selectedProgramId === program.id ? '提交中...' : '选择投稿' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -202,11 +202,11 @@
|
||||
<div v-if="hasMore" class="pt-6 pb-2 flex justify-center">
|
||||
<button
|
||||
:disabled="loadingMore"
|
||||
class="px-8 py-3 rounded-xl bg-bg-tertiary hover:bg-bg-quaternary text-text-secondary text-xs font-black disabled:opacity-50 transition-all flex items-center gap-2 uppercase tracking-widest"
|
||||
class="px-8 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black disabled:opacity-50 transition-all flex items-center gap-2 uppercase tracking-widest"
|
||||
@click="loadMore"
|
||||
>
|
||||
<Icon v-if="loadingMore" name="loader" :size="16" class="animate-spin" />
|
||||
{{ loadingMore ? locale.loadingMore : locale.loadMore }}
|
||||
{{ loadingMore ? '加载中...' : '加载更多' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -223,12 +223,8 @@ import { useMusicSources } from '~/composables/useMusicSources'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useSemesters } from '~/composables/useSemesters'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const locale = computed(() => songsLocale.value?.mediaModals || {})
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { normalizeForMatch as normalizeString } from '~/utils/song-name-normalize'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
@@ -255,6 +251,17 @@ const submitting = ref(false)
|
||||
const selectedProgramId = ref(null)
|
||||
const songsLoadingForSimilar = ref(false)
|
||||
|
||||
// 标准化字符串
|
||||
const normalizeString = (str) => {
|
||||
if (!str) return ''
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
|
||||
.replace(/[&&]/g, 'and')
|
||||
.replace(/[feat\.?|ft\.?]/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// 检查是否已存在相似歌曲
|
||||
const getSimilarSong = (program) => {
|
||||
if (!program) return null
|
||||
@@ -308,10 +315,10 @@ const fetchPrograms = async (isLoadMore = false) => {
|
||||
hasMore.value = result.more
|
||||
offset.value += result.programs.length
|
||||
} else {
|
||||
error.value = result.error || locale.value.fetchProgramsFailed
|
||||
error.value = result.error || '获取节目失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = locale.value.networkFailed
|
||||
error.value = '网络请求失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -338,7 +345,7 @@ const createSongObject = (program) => {
|
||||
return {
|
||||
id: songId,
|
||||
title: program.name,
|
||||
artist: program.dj?.nickname || locale.value.unknownDj,
|
||||
artist: program.dj?.nickname || '未知主播',
|
||||
cover: program.coverUrl || program.cover,
|
||||
album: props.radioName, // 使用电台名作为专辑名
|
||||
duration: program.duration,
|
||||
@@ -372,9 +379,7 @@ const handleLikeFromProgram = async (song) => {
|
||||
|
||||
if (song.played || song.scheduled) {
|
||||
if (window.$showNotification) {
|
||||
const message = song.played
|
||||
? (locale.value.playedCannotLike || '已播放的歌曲不能点赞')
|
||||
: (locale.value.scheduledCannotLike || '已排期的歌曲不能点赞')
|
||||
const message = song.played ? '已播放的歌曲不能点赞' : '已排期的歌曲不能点赞'
|
||||
window.$showNotification(message, 'warning')
|
||||
}
|
||||
return
|
||||
@@ -448,7 +453,7 @@ const formatDuration = (ms) => {
|
||||
const formatCount = (count) => {
|
||||
if (!count) return '0'
|
||||
if (count > 10000) {
|
||||
return (count / 10000).toFixed(1) + locale.value.tenThousand
|
||||
return (count / 10000).toFixed(1) + '万'
|
||||
}
|
||||
return count
|
||||
}
|
||||
@@ -471,11 +476,11 @@ defineExpose({
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--overlay-10);
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--overlay-20);
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
+74
-71
@@ -10,27 +10,27 @@
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4 bg-bg-primary-80 backdrop-blur-sm"
|
||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
@click.self="close"
|
||||
>
|
||||
<div
|
||||
class="bg-bg-secondary border border-border-secondary w-full max-w-2xl rounded-3xl overflow-hidden shadow-2xl flex flex-col max-h-[85vh]"
|
||||
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-3xl overflow-hidden shadow-2xl flex flex-col max-h-[85vh]"
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-border-secondary-50">
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-text-primary tracking-tight flex items-center gap-3">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-primary-hover-10 flex items-center justify-center text-primary"
|
||||
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||||
>
|
||||
<Icon name="history" :size="20" />
|
||||
</div>
|
||||
{{ locale.recentTitle }}
|
||||
最近播放 - 歌曲
|
||||
</h3>
|
||||
<p class="text-xs text-text-tertiary mt-1 ml-13">{{ locale.recentDesc }}</p>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">查看并快速投稿您最近在网易云播放的歌曲</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-bg-tertiary-50 hover:bg-bg-tertiary text-text-tertiary hover:text-text-primary rounded-2xl transition-all"
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-2xl transition-all"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
@@ -41,11 +41,11 @@
|
||||
<div class="flex-1 overflow-y-auto p-4 custom-scrollbar">
|
||||
<div
|
||||
v-if="loading"
|
||||
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"
|
||||
>
|
||||
<Icon name="refresh" :size="32" class="animate-spin mb-4 text-primary" />
|
||||
<Icon name="refresh" :size="32" class="animate-spin mb-4 text-blue-500" />
|
||||
<div class="text-[10px] font-black uppercase tracking-widest">
|
||||
{{ locale.loadingRecent }}
|
||||
正在获取播放记录...
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -54,35 +54,35 @@
|
||||
class="flex flex-col items-center justify-center py-20 text-center px-8"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-2xl bg-error-10 flex items-center justify-center text-error mb-4"
|
||||
class="w-16 h-16 rounded-2xl bg-red-500/10 flex items-center justify-center text-red-500 mb-4"
|
||||
>
|
||||
<Icon name="alert-triangle" :size="32" />
|
||||
</div>
|
||||
<p class="text-sm text-text-tertiary mb-6">{{ error }}</p>
|
||||
<p class="text-sm text-zinc-400 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"
|
||||
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="fetchRecentSongs"
|
||||
>
|
||||
{{ locale.retryLoad }}
|
||||
重试加载
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="songs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 text-text-disabled"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-600"
|
||||
>
|
||||
<Icon name="music" :size="48" class="mb-4 opacity-20" />
|
||||
<p class="text-sm font-bold uppercase tracking-widest">{{ locale.noRecent }}</p>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">暂无最近播放记录</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="recent-song-list space-y-2">
|
||||
<div
|
||||
v-for="item in songs"
|
||||
:key="item.resourceId"
|
||||
class="group flex items-center gap-4 p-3 rounded-2xl hover:bg-bg-tertiary-50 transition-all border border-transparent hover:border-border-secondary"
|
||||
class="group flex items-center gap-4 p-3 rounded-2xl hover:bg-zinc-800/50 transition-all border border-transparent hover:border-zinc-800"
|
||||
>
|
||||
<div
|
||||
class="relative w-14 h-14 rounded-xl overflow-hidden bg-bg-tertiary flex-shrink-0 shadow-lg group-hover:scale-105 transition-transform duration-300"
|
||||
class="relative w-14 h-14 rounded-xl overflow-hidden bg-zinc-800 flex-shrink-0 shadow-lg group-hover:scale-105 transition-transform duration-300"
|
||||
>
|
||||
<img
|
||||
:src="convertToHttps(item.data?.al?.picUrl)"
|
||||
@@ -91,11 +91,11 @@
|
||||
loading="lazy"
|
||||
>
|
||||
<div
|
||||
class="absolute inset-0 bg-bg-primary-40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
class="absolute inset-0 bg-black/40 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
@click.stop="playSong(item.data)"
|
||||
>
|
||||
<div
|
||||
class="w-8 h-8 rounded-full bg-bg-secondary-20 backdrop-blur-md flex items-center justify-center text-text-primary border border-primary-30"
|
||||
class="w-8 h-8 rounded-full bg-white/20 backdrop-blur-md flex items-center justify-center text-white border border-white/30"
|
||||
>
|
||||
<Icon name="play" :size="14" class="fill-current" />
|
||||
</div>
|
||||
@@ -105,16 +105,16 @@
|
||||
<div class="flex-1 min-w-0">
|
||||
<h4
|
||||
:title="item.data?.name"
|
||||
class="text-sm font-black text-text-primary truncate mb-1"
|
||||
class="text-sm font-black text-zinc-100 truncate mb-1"
|
||||
>
|
||||
{{ item.data?.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-3">
|
||||
<span class="text-[10px] text-text-tertiary font-medium whitespace-nowrap">{{
|
||||
<span class="text-[10px] text-zinc-500 font-medium whitespace-nowrap">{{
|
||||
formatTime(item.playTime)
|
||||
}}</span>
|
||||
<span
|
||||
class="text-[10px] text-text-disabled truncate font-bold uppercase tracking-tighter"
|
||||
class="text-[10px] text-zinc-600 truncate font-bold uppercase tracking-tighter"
|
||||
>{{ item.data?.ar?.map((a) => a.name).join('/') }}</span
|
||||
>
|
||||
</div>
|
||||
@@ -123,28 +123,28 @@
|
||||
<div class="flex flex-col items-end gap-2">
|
||||
<div
|
||||
v-if="songsLoadingForSimilar"
|
||||
class="flex items-center gap-2 px-3 py-1 bg-bg-tertiary-50 rounded-lg"
|
||||
class="flex items-center gap-2 px-3 py-1 bg-zinc-800/50 rounded-lg"
|
||||
>
|
||||
<Icon name="refresh" :size="10" class="animate-spin text-text-tertiary" />
|
||||
<span class="text-[10px] text-text-tertiary font-black uppercase">{{ locale.checking }}</span>
|
||||
<Icon name="refresh" :size="10" class="animate-spin text-zinc-500" />
|
||||
<span class="text-[10px] text-zinc-500 font-black uppercase">检查中</span>
|
||||
</div>
|
||||
<template v-else-if="getSimilarSong(item.data)">
|
||||
<div
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-tighter border',
|
||||
getSimilarSong(item.data)?.played
|
||||
? 'bg-success-10 text-success border-success-20'
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: getSimilarSong(item.data)?.scheduled
|
||||
? 'bg-warning-10 text-warning border-warning-20'
|
||||
: 'bg-bg-tertiary text-text-tertiary border-border-tertiary'
|
||||
? 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
: 'bg-zinc-800 text-zinc-500 border-zinc-700'
|
||||
]"
|
||||
>
|
||||
{{
|
||||
getSimilarSong(item.data)?.played
|
||||
? locale.played
|
||||
? '已播放'
|
||||
: getSimilarSong(item.data)?.scheduled
|
||||
? locale.scheduled
|
||||
: locale.inList
|
||||
? '已排期'
|
||||
: '已在列表中'
|
||||
}}
|
||||
</div>
|
||||
|
||||
@@ -152,11 +152,11 @@
|
||||
<button
|
||||
v-if="getSimilarSong(item.data)?.played && isSuperAdmin"
|
||||
:disabled="submitting"
|
||||
class="px-3 py-1.5 bg-primary-hover hover:bg-primary text-text-primary text-[10px] font-black rounded-lg transition-all uppercase tracking-widest disabled:opacity-50"
|
||||
class="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black rounded-lg transition-all uppercase tracking-widest disabled:opacity-50"
|
||||
@click="selectSong(item.data)"
|
||||
>
|
||||
{{
|
||||
submitting && selectedSongId === item.data?.id ? locale.processing : locale.continueSubmit
|
||||
submitting && selectedSongId === item.data?.id ? '处理中...' : '继续投稿'
|
||||
}}
|
||||
</button>
|
||||
<button
|
||||
@@ -167,7 +167,7 @@
|
||||
getSimilarSong(item.data)?.voted ||
|
||||
submitting
|
||||
"
|
||||
class="px-3 py-1.5 bg-bg-tertiary hover:bg-bg-quaternary disabled:bg-bg-secondary-50 text-text-tertiary disabled:text-text-secondary text-[10px] font-black rounded-lg transition-all uppercase tracking-widest flex items-center gap-1.5"
|
||||
class="px-3 py-1.5 bg-zinc-800 hover:bg-zinc-700 disabled:bg-zinc-900/50 text-zinc-400 disabled:text-zinc-700 text-[10px] font-black rounded-lg transition-all uppercase tracking-widest flex items-center gap-1.5"
|
||||
@click="
|
||||
getSimilarSong(item.data)?.played || getSimilarSong(item.data)?.scheduled
|
||||
? null
|
||||
@@ -178,21 +178,21 @@
|
||||
name="heart"
|
||||
:size="10"
|
||||
:class="[
|
||||
getSimilarSong(item.data)?.voted ? 'text-error fill-current' : ''
|
||||
getSimilarSong(item.data)?.voted ? 'text-red-500 fill-current' : ''
|
||||
]"
|
||||
/>
|
||||
{{ getSimilarSong(item.data)?.voted ? locale.liked : locale.like }}
|
||||
{{ getSimilarSong(item.data)?.voted ? '已点赞' : '点赞' }}
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
:disabled="submitting || songsLoadingForSimilar"
|
||||
class="px-4 py-2 bg-primary-hover hover:bg-primary text-text-primary text-[10px] font-black rounded-xl transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 shadow-lg shadow-[var(--primary-glow)]"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[10px] font-black rounded-xl transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50 shadow-lg shadow-blue-900/20"
|
||||
@click="selectSong(item.data)"
|
||||
>
|
||||
{{
|
||||
submitting && selectedSongId === item.data?.id ? locale.submitting : locale.selectSubmit
|
||||
submitting && selectedSongId === item.data?.id ? '正在提交...' : '选择投稿'
|
||||
}}
|
||||
</button>
|
||||
</div>
|
||||
@@ -202,13 +202,19 @@
|
||||
|
||||
<!-- 底部栏 -->
|
||||
<div
|
||||
class="p-6 border-t border-border-secondary-50 bg-bg-secondary-50 flex items-center justify-end"
|
||||
class="p-6 border-t border-zinc-800/50 bg-zinc-900/50 flex items-center justify-between"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>
|
||||
<Icon name="info" :size="12" />
|
||||
数据来自网易云音乐播放记录
|
||||
</div>
|
||||
<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"
|
||||
class="px-6 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="close"
|
||||
>
|
||||
{{ locale.close }}
|
||||
关闭窗口
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -222,24 +228,10 @@ import { computed, ref, watch } from 'vue'
|
||||
import { getRecentSongs } from '~/utils/neteaseApi'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useLocale } from '~/utils/locale'
|
||||
import { normalizeForMatch as normalizeString } from '~/utils/song-name-normalize'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useSemesters } from '~/composables/useSemesters'
|
||||
|
||||
const { songs: songsLocale } = useLocale()
|
||||
const locale = computed(() => {
|
||||
const base = songsLocale.value?.mediaModals || {}
|
||||
const emptyText = () => ''
|
||||
return useSafeLocale({
|
||||
...base,
|
||||
minutesAgo: base.minutesAgo || emptyText,
|
||||
hoursAgo: base.hoursAgo || emptyText,
|
||||
monthDay: base.monthDay || emptyText
|
||||
})
|
||||
})
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
cookie: String
|
||||
@@ -259,6 +251,17 @@ const auth = useAuth()
|
||||
const { currentSemester, fetchCurrentSemester } = useSemesters()
|
||||
const isSuperAdmin = computed(() => auth.user.value?.role === 'SUPER_ADMIN')
|
||||
|
||||
// 标准化字符串
|
||||
const normalizeString = (str) => {
|
||||
if (!str) return ''
|
||||
return str
|
||||
.toLowerCase()
|
||||
.replace(/[\s\-_\(\)\[\]【】()「」『』《》〈〉""''""''、,。!?:;~·]/g, '')
|
||||
.replace(/[&&]/g, 'and')
|
||||
.replace(/[feat\.?|ft\.?]/gi, '')
|
||||
.trim()
|
||||
}
|
||||
|
||||
// 检查是否已存在相似歌曲
|
||||
const getSimilarSong = (songData) => {
|
||||
if (!songData) return null
|
||||
@@ -288,14 +291,14 @@ const getSimilarSong = (songData) => {
|
||||
const formatTime = (timestamp) => {
|
||||
if (!timestamp) return ''
|
||||
const date = new Date(timestamp)
|
||||
const now = getSyncedDate()
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return locale.value.justNow
|
||||
if (diff < 3600000) return formatLocaleValue(locale.value.minutesAgo, Math.floor(diff / 60000))
|
||||
if (diff < 86400000) return formatLocaleValue(locale.value.hoursAgo, Math.floor(diff / 3600000))
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
|
||||
return formatLocaleValue(locale.value.monthDay, date.getMonth() + 1, date.getDate())
|
||||
return `${date.getMonth() + 1}月${date.getDate()}日`
|
||||
}
|
||||
|
||||
const fetchRecentSongs = async () => {
|
||||
@@ -309,10 +312,10 @@ const fetchRecentSongs = async () => {
|
||||
if (code === 200 && body && body.list) {
|
||||
songs.value = body.list.filter((item) => item.resourceType === 'SONG' && item.data)
|
||||
} else {
|
||||
error.value = message || locale.value.fetchRecentFailed
|
||||
error.value = message || '获取最近播放失败'
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = locale.value.networkFailed
|
||||
error.value = '网络请求失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
@@ -360,9 +363,7 @@ const handleLike = async (song) => {
|
||||
|
||||
if (song.played || song.scheduled) {
|
||||
if (window.$showNotification) {
|
||||
const message = song.played
|
||||
? (locale.value.playedCannotLike || '已播放的歌曲不能点赞')
|
||||
: (locale.value.scheduledCannotLike || '已排期的歌曲不能点赞')
|
||||
const message = song.played ? '已播放的歌曲不能点赞' : '已排期的歌曲不能点赞'
|
||||
window.$showNotification(message, 'warning')
|
||||
}
|
||||
return
|
||||
@@ -376,12 +377,15 @@ const handleLike = async (song) => {
|
||||
}
|
||||
|
||||
const playSong = (songData) => {
|
||||
// 简单的播放预览,如果需要完整播放器支持可能需要更多字段
|
||||
// 这里暂时只发射事件,由父组件决定如何播放
|
||||
// 实际上 RequestForm 可能会调用全局播放器
|
||||
// 但这里为了简化,我们暂时构造一个简单的对象
|
||||
emit('play', {
|
||||
id: songData.id,
|
||||
title: songData.name,
|
||||
artist: songData.ar?.map((a) => a.name).join('/'),
|
||||
cover: songData.al?.picUrl,
|
||||
albumId: songData.al?.id,
|
||||
musicPlatform: 'netease',
|
||||
musicId: songData.id.toString(),
|
||||
sourceInfo: {
|
||||
@@ -401,7 +405,6 @@ const selectSong = (songData) => {
|
||||
artist: songData.ar?.map((a) => a.name).join('/'),
|
||||
cover: songData.al?.picUrl,
|
||||
album: songData.al?.name,
|
||||
albumId: songData.al?.id,
|
||||
duration: songData.dt,
|
||||
musicPlatform: 'netease',
|
||||
musicId: songData.id.toString(),
|
||||
@@ -438,11 +441,11 @@ defineExpose({
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--text-muted);
|
||||
background: #4a5568;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--text-muted);
|
||||
background: #718096;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user