初次提交
Build VoiceHub FPK / build-fpk (push) Failing after 56m5s
Build and Push Docker Image / build-and-push (push) Canceled after 1h9m35s

This commit is contained in:
2026-09-06 08:19:02 +08:00
commit 4ce4649aaf
1061 changed files with 337258 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
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*
app/node_modules/
VoiceHub-main/
app/pages/app/
+110
View File
@@ -0,0 +1,110 @@
# 数据库连接地址(必填)
# 格式: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"
# 受信的 CDN/反向代理真实 IP 头(可选)
# 多个头用逗号分隔;未配置时只使用 TCP 连接地址,避免客户端伪造转发头。
# 示例:EdgeOne 使用 eo-connecting-ip,Cloudflare 使用 cf-connecting-ip
# TRUSTED_CLIENT_IP_HEADERS=eo-connecting-ip
# 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
# Sentry 配置(可选)
# 错误监控与性能追踪,配置后系统将自动上报运行时异常
#
# 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 缓存服务(可选,暂不推荐)
# 如果设置了 REDIS_URL,系统将自动启用 Redis 缓存功能
# 格式:redis://用户名:密码@主机:端口
# REDIS_URL="redis://default:password@host:port"
# Redis 键命名空间,多个 VoiceHub 环境共用 Redis 时应分别设置
# REDIS_KEY_PREFIX="voicehub:v2:"
# OAuth 配置(可选)
# 启用相应的 OAuth 提供商需要填写对应的 Client ID 和 Client Secret
#
# 注意:OAuth 运行时配置已经迁移到管理员后台;以下变量仅用于兼容旧部署和后台一键导入。
# 导入完成后,系统以数据库中保存的配置为准。
# 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=
# 聚合登陆 OAuth
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
+124
View File
@@ -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
View File
@@ -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: 如果您需要技术支持或有其他问题,可以直接联系我们
+139
View File
@@ -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
View File
@@ -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
+149
View File
@@ -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
+90
View File
@@ -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
View File
@@ -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
+84
View File
@@ -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
+42
View File
@@ -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/*
+19
View File
@@ -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/
+7
View File
@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 100
}
+8
View File
@@ -0,0 +1,8 @@
.git
node_modules
.nuxt
.output
.env
.env.*
!.env.example
README.md
+76
View File
@@ -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
+76
View File
@@ -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
+64
View File
@@ -0,0 +1,64 @@
# 支持: linux/amd64, linux/arm64, linux/arm/v7
# ==========================================
# 第一阶段:依赖安装与构建阶段
# ==========================================
FROM node:22-alpine 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
# 配置 npm 镜像加速并安装依赖,严格内存优化
RUN set -eux; \
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 . .
# 构建应用,使用内存优化脚本适应4GB总内存
RUN chmod +x scripts/memory-build.js && \
node scripts/memory-build.js
RUN npm prune --omit=dev
# ==========================================
# 第二阶段:运行阶段
# ==========================================
FROM node:22-alpine AS runtime
USER root
WORKDIR /app
# 从构建阶段复制必要文件,剔除无关的开发依赖
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 \
NODE_OPTIONS="--max-old-space-size=512"
# 暴露端口
EXPOSE $PORT
# 启动命令:先执行数据库迁移,再启动应用
CMD ["sh", "-c", "node scripts/deploy.js && node .output/server/index.mjs"]
+674
View File
@@ -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>.
+1745
View File
File diff suppressed because it is too large Load Diff
+29
View File
@@ -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 的图片并提供下载。
+206
View File
@@ -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 &
```
+17
View File
@@ -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*
+64
View File
@@ -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
View File
@@ -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
View File
@@ -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: 如果您需要技术支持或有其他问题,可以直接联系我们
+139
View File
@@ -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
View File
@@ -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
+149
View File
@@ -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
+90
View File
@@ -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
View File
@@ -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
+84
View File
@@ -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
+42
View File
@@ -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/*
+19
View File
@@ -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/
+7
View File
@@ -0,0 +1,7 @@
{
"semi": false,
"singleQuote": true,
"tabWidth": 2,
"trailingComma": "none",
"printWidth": 100
}
+8
View File
@@ -0,0 +1,8 @@
.git
node_modules
.nuxt
.output
.env
.env.*
!.env.example
README.md
+76
View File
@@ -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
+76
View File
@@ -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
+77
View File
@@ -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"]
+674
View File
@@ -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
+206
View File
@@ -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 &
```
+228
View File
@@ -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>
+410
View File
@@ -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;
}
+474
View File
@@ -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));
}
}
}
+273
View File
@@ -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;
}
}
+361
View File
@@ -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>
+174
View File
@@ -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>
@@ -0,0 +1,377 @@
<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-[100] 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-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-zinc-800">
<img
v-if="video?.cover"
:src="video.cover"
:alt="video.title"
class="w-full h-full object-cover"
referrerpolicy="no-referrer"
>
<div
v-else
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-zinc-100 tracking-tight truncate">
{{ video?.title }}
</h3>
<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-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" />
</button>
</div>
<!-- 主体 -->
<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-zinc-500"
>
<div
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">暂无剧集</p>
</div>
<div v-else class="program-list space-y-3">
<div
v-for="episode in episodes"
:key="episode.cid"
class="group flex items-center p-3 sm:p-4 rounded-3xl transition-all"
:class="[
isCurrentEpisode(episode)
? '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-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>
<!-- 剧集信息 -->
<div class="flex-1 min-w-0">
<h4
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-zinc-500 font-bold uppercase tracking-wider"
>
<span class="flex items-center">
<Icon name="clock" :size="10" class="mr-1" />
{{ formatDuration(episode.duration) }}
</span>
</div>
</div>
<!-- 操作按钮 -->
<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-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
:name="isCurrentEpisode(episode) && isPlaying ? 'pause' : 'play'"
:size="16"
/>
</button>
<template v-if="getEpisodeStatus(episode).played">
<!-- 已播放标签 -->
<div
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"
>
已播放
</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-amber-500/10 text-amber-500 border border-amber-500/20 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
>
已排期
</div>
</template>
<template v-else-if="getEpisodeStatus(episode).submitted">
<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-zinc-800 text-zinc-500 text-[10px] sm:text-xs font-black shrink-0 uppercase tracking-widest"
>
已投稿
</div>
<!-- 点赞按钮(非自己投稿) -->
<button
v-if="!isMyEpisode(episode)"
:disabled="getEpisodeStatus(episode).voted || submitting"
: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-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)"
>
<span class="flex items-center justify-center gap-1">
<Icon
name="heart"
:size="12"
:class="getEpisodeStatus(episode).voted ? 'fill-current' : ''"
/>
<span>{{ getEpisodeStatus(episode).voted ? '已点赞' : '点赞' }}</span>
</span>
</button>
</div>
</template>
<template v-else>
<!-- 选择投稿按钮(未投稿) -->
<button
:disabled="submitting"
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">提交中...</span>
<span v-else><span class="hidden sm:inline">选择</span>投稿</span>
</button>
</template>
</div>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { ref, computed } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import { useAudioPlayer } from '~/composables/useAudioPlayer'
import { isBilibiliSong } from '~/utils/bilibiliSource'
const props = defineProps({
show: Boolean,
video: Object,
episodes: {
type: Array,
default: () => []
},
submittedEpisodes: {
type: Array,
default: () => []
},
currentUserId: {
type: Number,
default: null
}
})
const emit = defineEmits(['close', 'submit', 'play', 'vote'])
const { getCurrentSong, getPlayingStatus, pauseSong } = useAudioPlayer()
const currentSong = getCurrentSong()
const isPlaying = getPlayingStatus()
const submitting = ref(false)
const selectedEpisodeCid = ref(null)
// 计算已投稿状态
const submittedCount = computed(() => props.submittedEpisodes.length)
const totalEpisodes = computed(() => props.episodes.length)
const formatDuration = (seconds) => {
const minutes = Math.floor(seconds / 60)
const secs = seconds % 60
return `${minutes}:${secs.toString().padStart(2, '0')}`
}
const isCurrentEpisode = (episode) => {
if (!currentSong.value) return false
if (!isBilibiliSong(currentSong.value)) return false
// 检查 CID
if (
currentSong.value.bilibiliCid &&
String(currentSong.value.bilibiliCid) === String(episode.cid)
) {
return true
}
// 兼容逻辑:检查 musicId 格式是否为 bvid:cid
if (currentSong.value.musicId && currentSong.value.musicId.includes(':')) {
const [, cid] = currentSong.value.musicId.split(':')
return cid === String(episode.cid)
}
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) => {
if (!song.musicId) return false
const cid = song.musicId.includes(':') ? song.musicId.split(':')[1] : null
return cid === String(episode.cid)
})
if (submittedSong) {
return {
submitted: true,
voted: submittedSong.voted || false,
songId: submittedSong.id,
requesterId: submittedSong.requesterId,
played: submittedSong.played || false,
scheduled: submittedSong.scheduled || false
}
}
return {
submitted: false,
voted: false,
songId: null,
requesterId: null,
played: episode.played || false,
scheduled: episode.scheduled || false
}
}
const isMyEpisode = (episode) => {
if (!props.currentUserId) return false
// 使用 getEpisodeStatus 获取统一的状态信息,避免直接修改 props
const status = getEpisodeStatus(episode)
return status.requesterId === props.currentUserId
}
const togglePlay = (episode) => {
if (isCurrentEpisode(episode) && isPlaying.value) {
pauseSong()
} else {
playEpisode(episode)
}
}
const playEpisode = (episode) => {
const episodeData = {
...episode,
cover: props.video?.cover,
bvid: props.video?.id,
title: props.video?.title,
artist: props.video?.artist
}
emit('play', episodeData)
}
const selectEpisode = (episode) => {
if (submitting.value || isEpisodeSubmitted(episode)) return
submitting.value = true
selectedEpisodeCid.value = episode.cid
emit('submit', episode)
}
const voteEpisode = (episode) => {
if (submitting.value) {
return
}
// 使用 getEpisodeStatus 获取统一的状态信息,避免重复查找
const { songId, voted } = getEpisodeStatus(episode)
if (!songId || voted) {
return
}
emit('vote', { ...episode, songId })
}
const close = () => {
emit('close')
// 重置状态
submitting.value = false
selectedEpisodeCid.value = null
}
// 暴露方法给父组件,用于在提交失败时重置状态
const resetSubmissionState = () => {
submitting.value = false
selectedEpisodeCid.value = null
}
defineExpose({
resetSubmissionState
})
</script>
<style scoped>
.custom-scrollbar::-webkit-scrollbar {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.1);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
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>
@@ -0,0 +1,261 @@
<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-[100] flex items-center justify-center p-4"
@click="handleClose"
>
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
<div
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-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="music" :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="handleClose"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 主体 -->
<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-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-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 object-contain" >
</div>
<div
v-if="isExpired"
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-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>
<div class="mt-8 text-center h-6">
<Transition
enter-active-class="transition duration-300 ease-out"
enter-from-class="opacity-0 translate-y-2"
enter-to-class="opacity-100 translate-y-0"
>
<p
v-if="status === 800"
class="text-zinc-400 text-xs font-black uppercase tracking-widest"
>
二维码已过期,请点击刷新
</p>
<p
v-else-if="status === 801"
class="text-zinc-400 text-xs font-black uppercase tracking-widest"
>
请使用网易云音乐APP扫码登录
</p>
<p
v-else-if="status === 802"
class="text-blue-500 text-xs font-black uppercase tracking-widest flex items-center justify-center"
>
<Icon name="check" :size="16" class="mr-2" />
扫描成功,请在手机上确认
</p>
<p
v-else-if="status === 803"
class="text-emerald-500 text-xs font-black uppercase tracking-widest"
>
登录成功,正在跳转...
</p>
</Transition>
</div>
</div>
<!-- 说明提示 -->
<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-zinc-500 text-center uppercase tracking-[0.15em] font-black"
>
说明:登录状态将保存到您的浏览器中,用于搜索播客等功能。
</p>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script lang="ts" setup>
import { onUnmounted, ref, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
interface Props {
show: boolean
}
const props = defineProps<Props>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'login-success', data: { cookie: string; user: any }): void
}>()
const BASE_URL = '/api/api-enhanced/netease'
const qrImg = ref('')
const loading = ref(false)
const status = ref(0) // 800已过期、801待扫码、802已扫码、803登录成功
const isExpired = ref(false)
let timer: any = null
let unikey = ''
const handleClose = () => {
stopPolling()
emit('close')
}
const stopPolling = () => {
if (timer) {
clearInterval(timer)
timer = null
}
}
const initLogin = async () => {
stopPolling()
loading.value = true
isExpired.value = false
status.value = 0
try {
// 获取二维码登录密钥
const keyRes = await fetch(`${BASE_URL}/login/qr/key?timestamp=${Date.now()}`)
const keyData = await keyRes.json()
unikey = keyData.data.unikey
// 生成二维码
const qrRes = await fetch(
`${BASE_URL}/login/qr/create?key=${unikey}&qrimg=true&timestamp=${Date.now()}&ua=pc`
)
const qrData = await qrRes.json()
qrImg.value = qrData.data.qrimg
status.value = 801
// 启动轮询
timer = setInterval(checkStatus, 3000)
} catch (err) {
console.error('初始化登录失败:', err)
status.value = 0
} finally {
loading.value = false
}
}
const checkStatus = async () => {
if (!unikey) return
try {
const res = await fetch(
`${BASE_URL}/login/qr/check?key=${unikey}&timestamp=${Date.now()}&ua=pc`
)
const data = await res.json()
status.value = data.code
if (data.code === 800) {
// 已过期
isExpired.value = true
stopPolling()
} else if (data.code === 803) {
// 登录成功
stopPolling()
const cookie = data.cookie
await handleLoginSuccess(cookie)
}
} catch (err) {
console.error('检查二维码状态失败:', err)
}
}
const handleLoginSuccess = async (cookie: string) => {
try {
// 通过登录凭证获取用户信息
const userRes = await fetch(`${BASE_URL}/login/status?timestamp=${Date.now()}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ cookie })
})
const res = await userRes.json()
const userInfo = {
cookie,
user: {
userId: res.data?.profile?.userId,
id: res.data?.profile?.userId,
nickname: res.data?.profile?.nickname,
avatarUrl: res.data?.profile?.avatarUrl,
userName: res.data?.profile?.nickname
}
}
emit('login-success', userInfo)
handleClose()
} catch (err) {
console.error('获取用户信息失败:', err)
emit('login-success', { cookie, user: null })
handleClose()
}
}
// 监听显示属性以初始化/停止
watch(
() => props.show,
(newVal) => {
if (newVal) {
initLogin()
} else {
stopPolling()
}
}
)
onUnmounted(() => {
stopPolling()
})
</script>
<style scoped></style>
@@ -0,0 +1,628 @@
<template>
<Transition name="fade">
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
@click="closeDialog"
>
<Transition name="scale">
<div
v-if="show"
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-zinc-800 shrink-0">
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">
上传到网易云音乐
</h3>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
@click="closeDialog"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 内容区域 -->
<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-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-blue-600 hover:bg-blue-700 text-white text-xs font-bold rounded-xl transition-colors"
@click="showLoginModal"
>
立即登录
</button>
</div>
</section>
<template v-else>
<!-- 音质选择 -->
<section class="space-y-3">
<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
v-for="option in qualityOptions"
:key="option.value"
class="flex flex-col p-4 rounded-2xl border text-left transition-all relative overflow-hidden group"
:class="[
selectedQuality === option.value
? 'bg-blue-600/10 border-blue-500 shadow-sm'
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
]"
@click="selectedQuality = option.value"
>
<div class="flex items-center justify-between mb-1 relative z-10">
<span
class="text-xs font-bold transition-colors"
:class="
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-blue-500 rounded-full shadow-[0_0_8px_rgba(59,130,246,0.5)]"
/>
</div>
<span class="text-[10px] text-zinc-500 relative z-10">{{
option.description
}}</span>
</button>
</div>
</section>
<!-- 歌曲信息 -->
<section class="space-y-3">
<label class="text-[10px] font-black uppercase text-zinc-600 tracking-[0.2em] px-1"
>歌曲信息</label
>
<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="封面"
class="w-12 h-12 rounded-lg object-cover"
>
<div class="flex-1 min-w-0">
<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>
</section>
<!-- 上传进度 -->
<section
v-if="uploading || uploadProgress > 0"
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-zinc-400">{{ uploadStatus }}</span>
<span class="text-blue-400">{{ uploadProgress }}%</span>
</div>
<div
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-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-white/20 to-transparent animate-shimmer"
/>
</div>
</div>
<p v-if="uploadMessage" class="text-[10px] text-zinc-500 text-center">
{{ uploadMessage }}
</p>
</section>
</template>
</div>
<!-- Footer -->
<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-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
:disabled="uploading"
@click="closeDialog"
>
取消
</button>
<button
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 ? '上传中...' : '开始上传' }}
</button>
</div>
</div>
</Transition>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, watch, onMounted, computed } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import { useAudioQuality, QUALITY_OPTIONS } from '~/composables/useAudioQuality'
import { useToast } from '~/composables/useToast'
import CryptoJS from 'crypto-js'
interface Props {
show: boolean
song: any // 来源歌曲对象
}
const props = defineProps<Props>()
const songName = computed(
() => props.song?.name || props.song?.song || props.song?.title || '未知歌曲'
)
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
}>()
const { success: showSuccess, error: showError } = useToast()
// 音质选项
const qualityOptions = QUALITY_OPTIONS.tencent
const selectedQuality = ref(8) // 默认高音质
const uploading = ref(false)
const uploadProgress = ref(0)
const uploadStatus = ref('')
const uploadMessage = ref('')
const isLoggedIn = ref(false)
// 检查登录状态
const checkLoginStatus = () => {
if (import.meta.client) {
const cookie = localStorage.getItem('netease_cookie')
isLoggedIn.value = !!cookie
}
}
onMounted(() => {
checkLoginStatus()
})
watch(
() => props.show,
(newVal) => {
if (newVal) {
checkLoginStatus()
}
}
)
const closeDialog = () => {
if (!uploading.value) {
emit('close')
}
}
const showLoginModal = () => {
emit('show-login')
}
// 获取网易云音乐Cookie
const getNeteaseCookie = () => {
if (import.meta.client) {
return localStorage.getItem('netease_cookie') || ''
}
return ''
}
// 获取QQ音乐下载链接
const getQQMusicUrl = async (strMediaMid: string, quality: number): Promise<string> => {
uploadStatus.value = '获取下载链接'
if (!strMediaMid) {
throw new Error('缺少歌曲ID (strMediaMid)')
}
// 使用vkeys API获取QQ音乐链接
const apiUrl = `https://api.vkeys.cn/v2/music/tencent?id=${strMediaMid}&quality=${quality}`
const response = await fetch(apiUrl, {
headers: {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
}
})
if (!response.ok) {
throw new Error(`获取链接失败: ${response.status}`)
}
const data = await response.json()
if (data.code === 200 && data.data && data.data.url) {
let url = data.data.url
// 统一改为安全协议链接
if (url.startsWith('http://')) {
url = url.replace('http://', 'https://')
}
return url
}
throw new Error('无法获取有效的播放链接')
}
// 通过文件头识别音频格式
const detectAudioType = async (blob: Blob): Promise<string | null> => {
const arr = new Uint8Array(await blob.slice(0, 12).arrayBuffer())
if (arr[0] === 0x66 && arr[1] === 0x4c && arr[2] === 0x61 && arr[3] === 0x43) {
return 'flac'
}
if (arr[0] === 0x49 && arr[1] === 0x44 && arr[2] === 0x33) {
return 'mp3'
}
if (arr[0] === 0xff && (arr[1] & 0xe0) === 0xe0) {
return 'mp3'
}
if (arr[0] === 0x4f && arr[1] === 0x67 && arr[2] === 0x67 && arr[3] === 0x53) {
return 'ogg'
}
if (
arr[0] === 0x52 &&
arr[1] === 0x49 &&
arr[2] === 0x46 &&
arr[3] === 0x46 &&
arr[8] === 0x57 &&
arr[9] === 0x41 &&
arr[10] === 0x56 &&
arr[11] === 0x45
) {
return 'wav'
}
if (
arr[4] === 0x66 &&
arr[5] === 0x74 &&
arr[6] === 0x79 &&
arr[7] === 0x70 &&
arr[8] === 0x4d &&
arr[9] === 0x34 &&
arr[10] === 0x41 &&
arr[11] === 0x20
) {
return 'm4a'
}
return null
}
// 下载音频文件
const downloadAudio = async (url: string): Promise<{ blob: Blob; ext: string }> => {
uploadStatus.value = '正在下载音频'
const response = await fetch(url)
if (!response.ok) {
throw new Error(`下载失败: ${response.status}`)
}
const contentType = response.headers.get('content-type')
const contentLength = response.headers.get('content-length')
const total = contentLength ? parseInt(contentLength, 10) : 0
if (!response.body) {
throw new Error('响应体为空')
}
const reader = response.body.getReader()
const chunks: Uint8Array[] = []
let receivedLength = 0
while (true) {
const { done, value } = await reader.read()
if (done) break
chunks.push(value)
receivedLength += value.length
if (total > 0) {
// 下载进度占总进度的50%
uploadProgress.value = Math.floor((receivedLength / total) * 50)
}
}
const blob = new Blob(chunks as BlobPart[], { type: contentType || 'audio/mpeg' })
// 尝试通过文件头检测真实格式
let ext = await detectAudioType(blob)
if (!ext) {
// 回退:按响应类型判断
if (contentType) {
switch (true) {
case contentType.includes('audio/flac') || contentType.includes('application/x-flac'):
ext = 'flac'
break
case contentType.includes('audio/wav') || contentType.includes('audio/x-wav'):
ext = 'wav'
break
case contentType.includes('audio/ogg'):
ext = 'ogg'
break
case contentType.includes('audio/aac') || contentType.includes('audio/mp4'):
ext = 'm4a'
break
}
}
}
// 回退:按链接后缀判断
if (!ext) {
if (url.includes('.flac')) {
ext = 'flac'
} else {
ext = 'mp3' // 默认使用常规格式
}
}
uploadProgress.value = 50
return { blob, ext }
}
// 上传到网易云音乐
const uploadToNetease = async (audioBlob: Blob, filename: string) => {
uploadStatus.value = '正在计算文件指纹'
const arrayBuffer = await audioBlob.arrayBuffer()
const wordArray = CryptoJS.lib.WordArray.create(arrayBuffer)
const md5 = CryptoJS.MD5(wordArray).toString()
const fileSize = audioBlob.size
const cookie = getNeteaseCookie()
const baseApiUrl = '/api/api-enhanced/netease'
// 获取文件扩展名
const ext = filename.split('.').pop()?.toLowerCase() || 'mp3'
const contentTypeMap: Record<string, string> = {
flac: 'audio/flac',
wav: 'audio/wav',
ogg: 'audio/ogg',
m4a: 'audio/mp4',
mp3: 'audio/mpeg'
}
const contentType = contentTypeMap[ext] || 'audio/mpeg'
// 获取上传凭证
uploadStatus.value = '正在获取上传凭证'
const tokenUrl = `${baseApiUrl}/cloud/upload/token?time=${Date.now()}`
const tokenRes = await fetch(tokenUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
cookie,
md5,
fileSize,
filename
})
})
const tokenData = await tokenRes.json()
if (tokenData.code !== 200) {
throw new Error(`获取凭证失败: ${tokenData.msg || tokenData.code}`)
}
const { needUpload, uploadUrl, uploadToken, objectKey, resourceId, songId } = tokenData.data
if (needUpload) {
// 上传到对象存储
uploadStatus.value = '正在上传到网易云音乐'
// 使用上传事件获取进度
await new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest()
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = Math.floor((e.loaded / e.total) * 100)
// 上传阶段进度映射到 50%~95%
uploadProgress.value = 50 + Math.floor(percent * 0.45)
}
})
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve(xhr.responseText)
} else {
reject(new Error(`NOS上传失败: ${xhr.status}`))
}
})
xhr.addEventListener('error', () => {
reject(new Error('NOS网络错误'))
})
xhr.open('POST', uploadUrl)
xhr.setRequestHeader('x-nos-token', uploadToken)
xhr.setRequestHeader('Content-MD5', md5)
xhr.setRequestHeader('Content-Type', contentType)
xhr.send(audioBlob)
})
} else {
uploadProgress.value = 95
uploadStatus.value = '文件已存在,秒传成功'
}
// 完成上传并写入云盘信息
uploadStatus.value = '正在保存云盘信息'
const completeUrl = `${baseApiUrl}/cloud/upload/complete?time=${Date.now()}`
const completeRes = await fetch(completeUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
cookie,
md5,
songId,
resourceId,
filename,
song: songName.value,
artist: artistName.value,
album: albumName.value,
bitrate: 999000
})
})
const completeData = await completeRes.json()
if (completeData.code !== 200) {
if (completeData.code === 502 || completeData.code === 526) {
console.warn(`完成接口返回 ${completeData.code},可能为暂时性错误`, completeData)
throw new Error(
`云盘信息保存可能失败 (错误码: ${completeData.code}),请检查云盘内是否成功上传歌曲。`
)
} else {
throw new Error(`发布失败: ${completeData.msg || completeData.code}`)
}
}
uploadProgress.value = 100
return completeData
}
// 开始上传流程
const startUpload = async () => {
if (uploading.value) return
uploading.value = true
uploadProgress.value = 0
uploadStatus.value = '准备中'
uploadMessage.value = ''
try {
// 兼容不同来源的歌曲标识字段
const musicId =
props.song.strMediaMid ||
props.song.songmid ||
props.song.songId ||
props.song.musicId ||
props.song.id ||
props.song.mid
if (!musicId) {
console.error('所有可能的ID字段都为空')
throw new Error('无法获取歌曲ID,请重试')
}
// 获取来源歌曲下载链接
uploadMessage.value = '正在从QQ音乐获取音频链接...'
const musicUrl = await getQQMusicUrl(musicId, selectedQuality.value)
if (!musicUrl) {
throw new Error('无法获取音乐播放链接')
}
// 下载音频文件
uploadMessage.value = '正在下载音频文件...'
const { blob: audioBlob, ext } = await downloadAudio(musicUrl)
// 上传到网易云音乐云盘
uploadMessage.value = '正在上传到网易云音乐云盘...'
const filename = `${artistName.value} - ${songName.value}.${ext}`
await uploadToNetease(audioBlob, filename)
uploadProgress.value = 100
uploadStatus.value = '上传完成'
uploadMessage.value = '歌曲已成功上传到您的网易云音乐云盘'
showSuccess('歌曲已成功上传到网易云音乐云盘')
emit('upload-success')
// 成功后延迟关闭对话框
setTimeout(() => {
closeDialog()
}, 1500)
} catch (error: any) {
console.error('上传失败:', error)
uploadStatus.value = '上传失败'
uploadMessage.value = error.message || '未知错误'
showError(`上传失败: ${error.message}`)
} finally {
uploading.value = false
}
}
</script>
<style scoped>
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
.scale-enter-active,
.scale-leave-active {
transition: all 0.3s ease;
}
.scale-enter-from,
.scale-leave-to {
opacity: 0;
transform: scale(0.95);
}
.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: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
@keyframes shimmer {
0% {
transform: translateX(-100%);
}
100% {
transform: translateX(100%);
}
}
.animate-shimmer {
animation: shimmer 2s infinite;
}
</style>
@@ -0,0 +1,583 @@
<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-[100] 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-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">
<button
v-if="view === 'songs'"
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-zinc-100 tracking-tight truncate max-w-[300px] sm:max-w-md"
>
{{ view === 'playlists' ? '选择歌单' : selectedPlaylist?.name || '歌单详情' }}
</h3>
</div>
<button
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" />
</button>
</div>
<!-- 主体 -->
<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-zinc-500"
>
<Icon name="loader" :size="48" class="mb-4 animate-spin text-zinc-400" />
<p class="font-medium">处理中...</p>
</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 mb-4"
>
<Icon name="alert-circle" :size="32" class="text-red-500" />
</div>
<p class="text-zinc-400 font-medium mb-6">{{ error }}</p>
<button
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"
>
重试
</button>
</div>
<!-- 歌单列表视图 -->
<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-zinc-500"
>
<div
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">暂无歌单</p>
</div>
<div
v-for="playlist in playlists"
v-else
:key="playlist.id"
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-zinc-800 mr-4 flex-shrink-0">
<img
:src="convertToHttps(playlist.coverImgUrl)"
alt="cover"
class="w-full h-full object-cover"
loading="lazy"
>
</div>
<div class="flex-1 min-w-0">
<h4
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-zinc-500">
<span class="flex items-center">
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
{{ playlist.trackCount }}首
</span>
<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-zinc-600 group-hover:text-zinc-400 transition-colors">
<Icon name="chevron-right" :size="24" />
</div>
</div>
</div>
<!-- 歌曲列表视图 -->
<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-zinc-500"
>
<div
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">歌单为空</p>
</div>
<div
v-for="song in songs"
v-else
:key="song.id"
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-zinc-800 mr-4 flex-shrink-0 group/cover cursor-pointer"
@click="playSong(song)"
>
<img
:src="convertToHttps(song.al?.picUrl)"
alt="cover"
class="w-full h-full object-cover"
loading="lazy"
>
<div
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-white/20 backdrop-blur-md flex items-center justify-center"
>
<Icon name="play" :size="16" class="text-white fill-current" />
</div>
</div>
</div>
<!-- 歌曲信息 -->
<div class="flex-1 min-w-0">
<h4
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-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>
</div>
</div>
<!-- 操作按钮 -->
<div class="ml-4 shrink-0 flex items-center gap-3">
<div
v-if="songsLoadingForSimilar"
class="text-xs font-bold text-zinc-600 animate-pulse"
>
处理中...
</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-red-500/10 text-red-500 text-[10px] font-black uppercase tracking-wider"
>
已播放
</span>
<span
v-else-if="getSimilarSong(song)?.scheduled"
class="px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-500 text-[10px] font-black uppercase tracking-wider"
>
已排期
</span>
<span
v-else
class="px-2 py-0.5 rounded-md bg-zinc-700/50 text-zinc-500 text-[10px] font-black uppercase tracking-wider"
>
已存在
</span>
<div class="flex gap-2">
<button
v-if="getSimilarSong(song)?.played && isSuperAdmin"
:disabled="submitting"
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 ? '...' : '继续投稿' }}
</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-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 ||
getSimilarSong(song)?.scheduled ||
getSimilarSong(song)?.voted ||
submitting
"
@click.stop="handleLike(getSimilarSong(song))"
>
{{ getSimilarSong(song)?.voted ? '已点赞' : '点赞' }}
</button>
</div>
</div>
<button
v-else
:disabled="submitting || songsLoadingForSimilar"
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 ? '提交中...' : '选择投稿' }}
</button>
</div>
</div>
<!-- 加载更多按钮 -->
<div v-if="hasMore" class="pt-6 pb-2 flex justify-center">
<button
:disabled="moreLoading"
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 ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { getPlaylistTracks, getUserPlaylists } from '~/utils/neteaseApi'
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'
const props = defineProps({
show: Boolean,
cookie: String,
uid: [String, Number]
})
const emit = defineEmits(['close', 'submit', 'play'])
const view = ref('playlists') // 'playlists' | 'songs'
const playlists = ref([])
const songs = ref([])
const selectedPlaylist = ref(null)
const loading = ref(false)
const songsLoadingForSimilar = ref(false)
const error = ref('')
const submitting = ref(false)
const selectedSongId = ref(null)
// 分页状态
const limit = ref(100)
const offset = ref(0)
const hasMore = ref(true)
const moreLoading = ref(false)
const songService = useSongs()
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
const title = songData.name
const artist = songData.ar?.map((a) => a.name).join('/')
if (!title || !artist) return null
const normalizedTitle = normalizeString(title)
const normalizedArtist = normalizeString(artist)
const currentSemesterName = currentSemester.value?.name
// 如果没有获取到当前学期信息,暂时不进行检查,避免误报
if (!currentSemesterName) return null
return songService.songs.value.find((song) => {
const songTitle = normalizeString(song.title)
const songArtist = normalizeString(song.artist)
const titleMatch = songTitle === normalizedTitle && songArtist === normalizedArtist
// 必须匹配当前学期
return titleMatch && song.semester === currentSemesterName
})
}
const fetchUserPlaylists = async () => {
if (!props.cookie || !props.uid) return
loading.value = true
error.value = ''
try {
const { code, body, message } = await getUserPlaylists(props.uid, props.cookie)
if (code === 200 && body && body.playlist) {
playlists.value = body.playlist
} else {
error.value = message || '获取歌单列表失败'
}
} catch (err) {
error.value = '网络请求失败'
console.error(err)
} finally {
loading.value = false
}
}
const fetchPlaylistSongs = async (playlistId, isLoadMore = false) => {
if (!props.cookie) return
if (isLoadMore) {
moreLoading.value = true
} else {
loading.value = true
songs.value = []
offset.value = 0
hasMore.value = true
}
error.value = ''
try {
// 当前偏移逻辑:
// 首次加载: offset=0, limit=100. 下次加载: offset=100, limit=100
// 注意: API 偏移参数如果遵循文档逻辑,应作为页面偏移?
// "传递 limit=50&offset=0 得到 1-50。传递 limit=50&offset=50 得到 51-100"
// 所以 offset 变量应该跟踪目前为止加载的歌曲数量。
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 || '获取歌单歌曲失败'
}
} catch (err) {
error.value = '网络请求失败'
console.error(err)
} finally {
loading.value = false
moreLoading.value = false
}
}
const loadMore = () => {
if (selectedPlaylist.value) {
fetchPlaylistSongs(selectedPlaylist.value.id, true)
}
}
const selectPlaylist = (playlist) => {
selectedPlaylist.value = playlist
view.value = 'songs'
fetchPlaylistSongs(playlist.id)
}
const backToPlaylists = () => {
view.value = 'playlists'
selectedPlaylist.value = null
songs.value = []
offset.value = 0
hasMore.value = true
error.value = ''
}
const retry = () => {
if (view.value === 'playlists') {
fetchUserPlaylists()
} else if (selectedPlaylist.value) {
// 重新从头加载歌曲
fetchPlaylistSongs(selectedPlaylist.value.id)
}
}
watch(
() => props.show,
async (newVal) => {
if (newVal) {
view.value = 'playlists'
playlists.value = []
songs.value = []
selectedPlaylist.value = null
offset.value = 0
hasMore.value = true
// 确保当前学期已加载,用于正确检查歌曲状态
if (!currentSemester.value) {
await fetchCurrentSemester()
}
fetchUserPlaylists()
// 加载歌曲列表以便检查相似歌曲
if (
auth.isAuthenticated.value &&
(!songService.songs.value || songService.songs.value.length === 0)
) {
songsLoadingForSimilar.value = true
const currentSemesterName = currentSemester.value?.name
songService
.fetchSongs(true, currentSemesterName)
.catch((err) => {
console.error('加载歌曲列表失败:', err)
})
.finally(() => {
songsLoadingForSimilar.value = false
})
}
} else {
// 重置状态
submitting.value = false
selectedSongId.value = null
}
}
)
watch(
() => props.uid,
(newVal) => {
if (props.show && newVal) {
fetchUserPlaylists()
}
}
)
const handleLike = async (song) => {
if (!song || song.voted) return
if (song.played || song.scheduled) {
if (window.$showNotification) {
const message = song.played ? '已播放的歌曲不能点赞' : '已排期的歌曲不能点赞'
window.$showNotification(message, 'warning')
}
return
}
try {
await songService.voteSong(song.id)
} catch (error) {
console.error('点赞失败:', error)
}
}
const playSong = (songData) => {
emit('play', {
id: songData.id,
title: songData.name,
artist: songData.ar?.map((a) => a.name).join('/'),
cover: songData.al?.picUrl,
musicPlatform: 'netease',
musicId: songData.id.toString(),
sourceInfo: {
source: 'netease-backup',
type: 'song'
}
})
}
const selectSong = (songData) => {
selectedSongId.value = songData.id
submitting.value = true
const song = {
id: songData.id,
title: songData.name,
artist: songData.ar?.map((a) => a.name).join('/'),
cover: songData.al?.picUrl,
album: songData.al?.name,
duration: songData.dt,
musicPlatform: 'netease',
musicId: songData.id.toString(),
sourceInfo: {
source: 'netease-backup',
type: 'song'
}
}
emit('submit', song)
}
const close = () => {
emit('close')
}
defineExpose({
resetSubmissionState: () => {
submitting.value = false
selectedSongId.value = null
}
})
</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,486 @@
<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-[100] 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-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-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-zinc-100 tracking-tight truncate">
{{ radioName }} - 节目列表
</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 flex-shrink-0"
@click="close"
>
<Icon name="x" :size="20" />
</button>
</div>
<!-- 主体 -->
<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-zinc-500"
>
<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
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-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)"
>
重试
</button>
</div>
<div
v-else-if="programs.length === 0"
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="mic" :size="32" class="opacity-20" />
</div>
<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-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-zinc-800 mr-4 flex-shrink-0 group/cover cursor-pointer"
@click.stop="playProgram(program)"
>
<img
:src="convertToHttps(program.coverUrl || program.mainSong?.album?.picUrl)"
alt="cover"
class="w-full h-full object-cover"
loading="lazy"
>
<div
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-white/20 backdrop-blur-md flex items-center justify-center"
>
<Icon name="play" :size="16" class="text-white fill-current" />
</div>
</div>
</div>
<!-- 节目信息 -->
<div class="flex-1 min-w-0">
<h4
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-zinc-500 font-bold uppercase tracking-wider"
>
<span class="flex items-center">
{{ formatDate(program.createTime) }}
</span>
<span class="flex items-center">
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
{{ formatDuration(program.duration) }}
</span>
<span class="flex items-center">
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
🎧 {{ formatCount(program.listenerCount) }}
</span>
</div>
</div>
<!-- 操作按钮 -->
<div class="ml-4 shrink-0 flex items-center gap-3">
<div
v-if="songsLoadingForSimilar"
class="text-[10px] font-black text-zinc-600 animate-pulse uppercase tracking-widest"
>
处理中...
</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-red-500/10 text-red-500 text-[10px] font-black uppercase tracking-wider"
>
已播放
</span>
<span
v-else-if="getSimilarSong(program)?.scheduled"
class="px-2 py-0.5 rounded-md bg-amber-500/10 text-amber-500 text-[10px] font-black uppercase tracking-wider"
>
已排期
</span>
<span
v-else
class="px-2 py-0.5 rounded-md bg-zinc-700/50 text-zinc-500 text-[10px] font-black uppercase tracking-wider"
>
已存在
</span>
<div class="flex gap-2">
<button
v-if="getSimilarSong(program)?.played && isSuperAdmin"
:disabled="submitting"
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 ? '...' : '继续投稿' }}
</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-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 ||
getSimilarSong(program)?.scheduled ||
getSimilarSong(program)?.voted ||
submitting
"
@click.stop="handleLikeFromProgram(getSimilarSong(program))"
>
{{ getSimilarSong(program)?.voted ? '已点赞' : '点赞' }}
</button>
</div>
</div>
<button
v-else
:disabled="submitting"
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 ? '提交中...' : '选择投稿' }}
</button>
</div>
</div>
<div v-if="hasMore" class="pt-6 pb-2 flex justify-center">
<button
:disabled="loadingMore"
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 ? '加载中...' : '加载更多' }}
</button>
</div>
</div>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { useMusicSources } from '~/composables/useMusicSources'
import { useSongs } from '~/composables/useSongs'
import { useAuth } from '~/composables/useAuth'
import { useSemesters } from '~/composables/useSemesters'
import { convertToHttps } from '~/utils/url'
import Icon from '~/components/UI/Icon.vue'
const props = defineProps({
show: Boolean,
radioId: [String, Number],
radioName: String,
cookie: String
})
const emit = defineEmits(['close', 'submit', 'play'])
const musicSources = useMusicSources()
const songService = useSongs()
const auth = useAuth()
const isSuperAdmin = computed(() => auth.user.value?.role === 'SUPER_ADMIN')
const { currentSemester, fetchCurrentSemester } = useSemesters()
const programs = ref([])
const loading = ref(false)
const error = ref('')
const hasMore = ref(false)
const offset = ref(0)
const loadingMore = ref(false)
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
const tempSong = createSongObject(program)
const title = tempSong.title
const artist = tempSong.artist
if (!title || !artist) return null
const normalizedTitle = normalizeString(title)
const normalizedArtist = normalizeString(artist)
const currentSemesterName = currentSemester.value?.name
// 如果没有获取到当前学期信息,暂时不进行检查,避免误报
if (!currentSemesterName) return null
return songService.songs.value.find((song) => {
const songTitle = normalizeString(song.title)
const songArtist = normalizeString(song.artist)
const titleMatch = songTitle === normalizedTitle && songArtist === normalizedArtist
// 必须匹配当前学期
return titleMatch && song.semester === currentSemesterName
})
}
const fetchPrograms = async (isLoadMore = false) => {
if (!props.radioId) return
if (isLoadMore) {
loadingMore.value = true
} else {
loading.value = true
programs.value = []
offset.value = 0
}
error.value = ''
try {
const result = await musicSources.getDjPrograms(props.radioId, 20, offset.value, props.cookie)
if (result.success) {
if (isLoadMore) {
programs.value = [...programs.value, ...result.programs]
} else {
programs.value = result.programs
}
hasMore.value = result.more
offset.value += result.programs.length
} else {
error.value = result.error || '获取节目失败'
}
} catch (err) {
error.value = '网络请求失败'
console.error(err)
} finally {
loading.value = false
loadingMore.value = false
}
}
const loadMore = () => {
fetchPrograms(true)
}
const close = () => {
emit('close')
// 重置状态
submitting.value = false
selectedProgramId.value = null
}
const createSongObject = (program) => {
// 构造提交数据
// 使用 mainSong.id 作为唯一标识,因为 getSongUrl 需要它
const songId = program.mainSong?.id || program.mainTrackId || program.id
return {
id: songId,
title: program.name,
artist: program.dj?.nickname || '未知主播',
cover: program.coverUrl || program.cover,
album: props.radioName, // 使用电台名作为专辑名
duration: program.duration,
musicPlatform: 'netease-podcast',
musicId: songId.toString(),
sourceInfo: {
source: 'netease-backup',
originalId: songId.toString(),
fetchedAt: new Date(),
type: 'voice',
programId: program.id
}
}
}
const selectProgram = async (program) => {
selectedProgramId.value = program.id
submitting.value = true
const song = createSongObject(program)
emit('submit', song)
// 不立即关闭,等待父组件处理
// 父组件处理成功后应该关闭此弹窗
}
const handleLikeFromProgram = async (song) => {
if (!song || song.voted) {
return
}
if (song.played || song.scheduled) {
if (window.$showNotification) {
const message = song.played ? '已播放的歌曲不能点赞' : '已排期的歌曲不能点赞'
window.$showNotification(message, 'warning')
}
return
}
try {
await songService.voteSong(song.id)
} catch (error) {
console.error('点赞失败:', error)
}
}
const playProgram = (program) => {
const song = createSongObject(program)
// 添加播放需要的额外字段
song.musicUrl = '' // 将由 RequestForm 处理获取
emit('play', song)
}
watch(
() => props.show,
async (val) => {
if (val) {
// 确保当前学期已加载,用于正确检查歌曲状态
if (!currentSemester.value) {
await fetchCurrentSemester()
}
submitting.value = false
selectedProgramId.value = null
if (
auth.isAuthenticated.value &&
(!songService.songs.value || songService.songs.value.length === 0)
) {
const currentSemesterName = currentSemester.value?.name
songsLoadingForSimilar.value = true
songService
.fetchSongs(true, currentSemesterName)
.catch((err) => {
console.error('加载歌曲列表失败:', err)
})
.finally(() => {
songsLoadingForSimilar.value = false
})
} else {
songsLoadingForSimilar.value = false
}
fetchPrograms()
} else {
programs.value = []
offset.value = 0
submitting.value = false
selectedProgramId.value = null
songsLoadingForSimilar.value = false
}
}
)
const formatDate = (timestamp) => {
if (!timestamp) return ''
return new Date(timestamp).toLocaleDateString()
}
const formatDuration = (ms) => {
if (!ms) return '00:00'
const minutes = Math.floor(ms / 60000)
const seconds = Math.floor((ms % 60000) / 1000)
return `${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`
}
const formatCount = (count) => {
if (!count) return '0'
if (count > 10000) {
return (count / 10000).toFixed(1) + '万'
}
return count
}
defineExpose({
resetSubmissionState: () => {
submitting.value = false
selectedProgramId.value = null
}
})
</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,451 @@
<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 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="history" :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-4 custom-scrollbar">
<div
v-if="loading"
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="fetchRecentSongs"
>
重试加载
</button>
</div>
<div
v-else-if="songs.length === 0"
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">暂无最近播放记录</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-zinc-800/50 transition-all border border-transparent hover:border-zinc-800"
>
<div
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)"
alt="cover"
class="w-full h-full object-cover"
loading="lazy"
>
<div
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-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>
</div>
</div>
<div class="flex-1 min-w-0">
<h4
:title="item.data?.name"
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-zinc-500 font-medium whitespace-nowrap">{{
formatTime(item.playTime)
}}</span>
<span
class="text-[10px] text-zinc-600 truncate font-bold uppercase tracking-tighter"
>{{ item.data?.ar?.map((a) => a.name).join('/') }}</span
>
</div>
</div>
<div class="flex flex-col items-end gap-2">
<div
v-if="songsLoadingForSimilar"
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-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-emerald-500/10 text-emerald-500 border-emerald-500/20'
: getSimilarSong(item.data)?.scheduled
? 'bg-amber-500/10 text-amber-500 border-amber-500/20'
: 'bg-zinc-800 text-zinc-500 border-zinc-700'
]"
>
{{
getSimilarSong(item.data)?.played
? '已播放'
: getSimilarSong(item.data)?.scheduled
? '已排期'
: '已在列表中'
}}
</div>
<div class="flex items-center gap-2">
<button
v-if="getSimilarSong(item.data)?.played && isSuperAdmin"
:disabled="submitting"
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 ? '处理中...' : '继续投稿'
}}
</button>
<button
v-else
:disabled="
getSimilarSong(item.data)?.played ||
getSimilarSong(item.data)?.scheduled ||
getSimilarSong(item.data)?.voted ||
submitting
"
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
: handleLike(getSimilarSong(item.data))
"
>
<Icon
name="heart"
:size="10"
:class="[
getSimilarSong(item.data)?.voted ? 'text-red-500 fill-current' : ''
]"
/>
{{ getSimilarSong(item.data)?.voted ? '已点赞' : '点赞' }}
</button>
</div>
</template>
<button
v-else
:disabled="submitting || songsLoadingForSimilar"
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 ? '正在提交...' : '选择投稿'
}}
</button>
</div>
</div>
</div>
</div>
<!-- 底部栏 -->
<div
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-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
@click="close"
>
关闭窗口
</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup>
import { computed, ref, watch } from 'vue'
import { getRecentSongs } from '~/utils/neteaseApi'
import { convertToHttps } from '~/utils/url'
import Icon from '~/components/UI/Icon.vue'
import { useSongs } from '~/composables/useSongs'
import { useAuth } from '~/composables/useAuth'
import { useSemesters } from '~/composables/useSemesters'
const props = defineProps({
show: Boolean,
cookie: String
})
const emit = defineEmits(['close', 'submit', 'play'])
const songs = ref([])
const loading = ref(false)
const songsLoadingForSimilar = ref(false)
const error = ref('')
const submitting = ref(false)
const selectedSongId = ref(null)
const songService = useSongs()
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
const title = songData.name
const artist = songData.ar?.map((a) => a.name).join('/')
if (!title || !artist) return null
const normalizedTitle = normalizeString(title)
const normalizedArtist = normalizeString(artist)
const currentSemesterName = currentSemester.value?.name
// 如果没有获取到当前学期信息,暂时不进行检查,避免误报
if (!currentSemesterName) return null
return songService.songs.value.find((song) => {
const songTitle = normalizeString(song.title)
const songArtist = normalizeString(song.artist)
const titleMatch = songTitle === normalizedTitle && songArtist === normalizedArtist
// 必须匹配当前学期
return titleMatch && song.semester === currentSemesterName
})
}
const formatTime = (timestamp) => {
if (!timestamp) return ''
const date = new Date(timestamp)
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 `${date.getMonth() + 1}月${date.getDate()}日`
}
const fetchRecentSongs = async () => {
if (!props.cookie) return
loading.value = true
error.value = ''
try {
const { code, body, message } = await getRecentSongs(100, props.cookie)
if (code === 200 && body && body.list) {
songs.value = body.list.filter((item) => item.resourceType === 'SONG' && item.data)
} else {
error.value = message || '获取最近播放失败'
}
} catch (err) {
error.value = '网络请求失败'
console.error(err)
} finally {
loading.value = false
}
}
watch(
() => props.show,
async (newVal) => {
if (newVal) {
// 确保当前学期已加载,用于正确检查歌曲状态
if (!currentSemester.value) {
await fetchCurrentSemester()
}
fetchRecentSongs()
// 加载歌曲列表以便检查相似歌曲
if (
auth.isAuthenticated.value &&
(!songService.songs.value || songService.songs.value.length === 0)
) {
songsLoadingForSimilar.value = true
const currentSemesterName = currentSemester.value?.name
songService
.fetchSongs(true, currentSemesterName)
.catch((err) => {
console.error('加载歌曲列表失败:', err)
})
.finally(() => {
songsLoadingForSimilar.value = false
})
}
} else {
songs.value = []
submitting.value = false
selectedSongId.value = null
}
}
)
const handleLike = async (song) => {
if (!song || song.voted) {
return
}
if (song.played || song.scheduled) {
if (window.$showNotification) {
const message = song.played ? '已播放的歌曲不能点赞' : '已排期的歌曲不能点赞'
window.$showNotification(message, 'warning')
}
return
}
try {
await songService.voteSong(song.id)
} catch (error) {
console.error('点赞失败:', error)
}
}
const playSong = (songData) => {
// 简单的播放预览,如果需要完整播放器支持可能需要更多字段
// 这里暂时只发射事件,由父组件决定如何播放
// 实际上 RequestForm 可能会调用全局播放器
// 但这里为了简化,我们暂时构造一个简单的对象
emit('play', {
id: songData.id,
title: songData.name,
artist: songData.ar?.map((a) => a.name).join('/'),
cover: songData.al?.picUrl,
musicPlatform: 'netease',
musicId: songData.id.toString(),
sourceInfo: {
source: 'netease-backup',
type: 'song'
}
})
}
const selectSong = (songData) => {
selectedSongId.value = songData.id
submitting.value = true
const song = {
id: songData.id,
title: songData.name,
artist: songData.ar?.map((a) => a.name).join('/'),
cover: songData.al?.picUrl,
album: songData.al?.name,
duration: songData.dt,
musicPlatform: 'netease',
musicId: songData.id.toString(),
sourceInfo: {
source: 'netease-backup',
type: 'song'
}
}
emit('submit', song)
// 不立即关闭,由父组件控制
}
const close = () => {
emit('close')
}
defineExpose({
resetSubmissionState: () => {
submitting.value = false
selectedSongId.value = null
}
})
</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>

Some files were not shown because too many files have changed in this diff Show More