初次提交
This commit is contained in:
@@ -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
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,14 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 📚 使用文档
|
||||
url: https://docs.voicehub.lao-shui.top
|
||||
about: 查看VoiceHub的使用文档和常见问题解答
|
||||
- name: 💬 讨论区
|
||||
url: https://github.com/laoshuikaixue/VoiceHub/discussions
|
||||
about: 在讨论区提问、分享经验或参与社区讨论
|
||||
- name: 🔒 安全问题私密报告
|
||||
url: mailto:contact@lao-shui.top
|
||||
about: 对于严重的安全漏洞,请通过邮件私密报告
|
||||
- name: 📧 联系我们
|
||||
url: mailto:contact@lao-shui.top
|
||||
about: 如果您需要技术支持或有其他问题,可以直接联系我们
|
||||
@@ -0,0 +1,139 @@
|
||||
name: ✨ 功能建议
|
||||
description: 为VoiceHub系统提出新功能或改进建议
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement', '功能建议']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您为VoiceHub提出功能建议!您的想法将帮助我们改进系统,为用户提供更好的体验。
|
||||
|
||||
- type: dropdown
|
||||
id: feature-category
|
||||
attributes:
|
||||
label: 功能类别
|
||||
description: 请选择建议功能所属的类别
|
||||
options:
|
||||
- 音乐播放功能
|
||||
- 点歌系统
|
||||
- 用户管理
|
||||
- 通知系统
|
||||
- 管理员工具
|
||||
- 用户界面
|
||||
- 数据统计
|
||||
- 安全功能
|
||||
- API接口
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: feature-summary
|
||||
attributes:
|
||||
label: 功能概述
|
||||
description: 简要描述您建议的功能
|
||||
placeholder: 用一两句话概括您的功能建议...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-statement
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 描述当前存在的问题或不便之处
|
||||
placeholder: 目前在使用VoiceHub时遇到了什么问题或不便?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: 建议方案
|
||||
description: 详细描述您建议的解决方案
|
||||
placeholder: |
|
||||
请详细描述您的功能建议:
|
||||
- 功能如何工作
|
||||
- 用户如何使用
|
||||
- 预期效果
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: user-stories
|
||||
attributes:
|
||||
label: 用户场景
|
||||
description: 描述用户在什么情况下会使用这个功能
|
||||
placeholder: |
|
||||
作为一个[用户角色],我希望[功能描述],这样我就可以[预期收益]。
|
||||
|
||||
例如:
|
||||
- 作为学生,我希望能够预约点歌时间,这样我就可以在特定时间播放我的歌曲
|
||||
- 作为管理员,我希望能够批量管理歌曲,这样我就可以更高效地维护歌曲库
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 替代方案
|
||||
description: 是否考虑过其他解决方案?
|
||||
placeholder: 描述您考虑过的其他可能的解决方案...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级
|
||||
description: 您认为这个功能的重要程度如何?
|
||||
options:
|
||||
- 高 - 非常重要,急需实现
|
||||
- 中 - 重要,希望尽快实现
|
||||
- 低 - 有了更好,不急
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-type
|
||||
attributes:
|
||||
label: 目标用户
|
||||
description: 这个功能主要面向哪类用户?
|
||||
options:
|
||||
- 所有用户
|
||||
- 普通学生用户
|
||||
- 管理员
|
||||
- 特定用户群体
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: technical-considerations
|
||||
attributes:
|
||||
label: 技术考虑
|
||||
description: 如果您有技术背景,可以提供一些技术实现的想法
|
||||
placeholder: 实现思路、技术栈建议、性能考虑等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 任何其他有助于理解功能需求的信息
|
||||
placeholder: 参考资料、类似功能的例子、设计草图等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: 确认事项
|
||||
description: 请确认以下事项
|
||||
options:
|
||||
- label: 我已经搜索了现有的Issues,确认这不是重复建议
|
||||
required: true
|
||||
- label: 我愿意参与功能的讨论和测试
|
||||
required: false
|
||||
- label: 如果有能力,我愿意协助开发这个功能
|
||||
required: false
|
||||
@@ -0,0 +1,112 @@
|
||||
name: '❓ 提问 / Question'
|
||||
description: '遇到问题需要帮助?在这里提问,我们会尽快为您解答!'
|
||||
title: '[Question]: '
|
||||
labels: ['question', '需要帮助']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您使用 VoiceHub 校园广播点歌系统!如果您在使用过程中遇到任何问题,请填写以下信息,我们会尽快为您提供帮助。
|
||||
|
||||
💡 **提示**:提问前建议先查看已有的 Issues。
|
||||
|
||||
- type: dropdown
|
||||
id: question-type
|
||||
attributes:
|
||||
label: '问题类型'
|
||||
description: '请选择您遇到的问题类型'
|
||||
options:
|
||||
- '使用问题 - 不知道如何操作某个功能'
|
||||
- '技术问题 - 功能无法正常工作'
|
||||
- '配置问题 - 系统设置相关'
|
||||
- '账户问题 - 登录、权限等'
|
||||
- '音频问题 - 播放、上传相关'
|
||||
- '界面问题 - 显示异常或操作困难'
|
||||
- '其他问题'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: question-description
|
||||
attributes:
|
||||
label: '问题描述'
|
||||
description: '请详细描述您遇到的问题,包括具体的操作步骤和现象'
|
||||
placeholder: |
|
||||
例如:
|
||||
1. 我在尝试点歌时...
|
||||
2. 点击"添加到播放列表"按钮后...
|
||||
3. 期望的结果是...,但实际发生的是...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-role
|
||||
attributes:
|
||||
label: '用户角色'
|
||||
description: '您在系统中的角色是什么?'
|
||||
options:
|
||||
- '学生用户'
|
||||
- '教师用户'
|
||||
- '管理员'
|
||||
- '超级管理员'
|
||||
- '访客'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: '使用环境'
|
||||
description: '请提供您的使用环境信息'
|
||||
placeholder: '例如:Chrome 120, Windows 11, 移动端Safari等'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: attempted-solutions
|
||||
attributes:
|
||||
label: '已尝试的解决方法'
|
||||
description: '请描述您已经尝试过的解决方法(如果有的话)'
|
||||
placeholder: |
|
||||
例如:
|
||||
- 刷新了页面
|
||||
- 清除了浏览器缓存
|
||||
- 尝试了不同的浏览器
|
||||
- 重新登录了账户
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: urgency
|
||||
attributes:
|
||||
label: '紧急程度'
|
||||
description: '这个问题对您的使用影响有多大?'
|
||||
options:
|
||||
- '低 - 不影响正常使用,有时间时解决即可'
|
||||
- '中 - 影响部分功能,希望尽快解决'
|
||||
- '高 - 严重影响使用,需要紧急处理'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: '补充信息'
|
||||
description: '任何其他可能有帮助的信息,如截图、错误信息等'
|
||||
placeholder: '您可以在这里添加截图、错误截图或其他相关信息'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: '确认事项'
|
||||
description: '请确认以下事项'
|
||||
options:
|
||||
- label: '我已经搜索了现有的 Issues,确认这不是重复问题'
|
||||
required: true
|
||||
# - label: "我已经查看了项目文档和常见问题"
|
||||
# required: false
|
||||
- label: '我愿意提供更多信息来帮助解决这个问题'
|
||||
required: false
|
||||
@@ -0,0 +1,149 @@
|
||||
name: 🔒 安全问题报告
|
||||
description: 报告VoiceHub系统中的安全漏洞或安全相关问题
|
||||
title: '[Security]: '
|
||||
labels: ['security', '高优先级']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
⚠️ **重要提醒**:如果您发现了严重的安全漏洞,请不要在公开的Issue中详细描述。
|
||||
请发送邮件至 `contact@lao-shui.top` 。
|
||||
|
||||
感谢您帮助我们保护VoiceHub系统的安全!
|
||||
|
||||
- type: dropdown
|
||||
id: security-type
|
||||
attributes:
|
||||
label: 安全问题类型
|
||||
description: 请选择最符合的安全问题类型
|
||||
options:
|
||||
- 身份认证问题
|
||||
- 权限控制问题
|
||||
- 数据泄露风险
|
||||
- 跨站脚本攻击(XSS)
|
||||
- SQL注入
|
||||
- 跨站请求伪造(CSRF)
|
||||
- 文件上传安全
|
||||
- 会话管理问题
|
||||
- 密码安全
|
||||
- API安全
|
||||
- 其他安全问题
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: 严重程度
|
||||
description: 请评估安全问题的严重程度
|
||||
options:
|
||||
- 严重 - 可能导致系统完全被攻破
|
||||
- 高 - 可能导致重要数据泄露
|
||||
- 中 - 可能影响部分用户或功能
|
||||
- 低 - 轻微的安全改进建议
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: security-summary
|
||||
attributes:
|
||||
label: 安全问题概述
|
||||
description: 简要描述发现的安全问题(避免详细的攻击步骤)
|
||||
placeholder: 简要说明发现的安全问题类型和影响范围...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: affected-components
|
||||
attributes:
|
||||
label: 受影响的组件
|
||||
description: 列出可能受到影响的系统组件或功能模块
|
||||
placeholder: |
|
||||
例如:
|
||||
- 用户登录模块
|
||||
- 文件上传功能
|
||||
- 管理员面板
|
||||
- API接口
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: potential-impact
|
||||
attributes:
|
||||
label: 潜在影响
|
||||
description: 描述这个安全问题可能造成的影响
|
||||
placeholder: |
|
||||
可能的影响包括:
|
||||
- 用户数据泄露
|
||||
- 未授权访问
|
||||
- 系统功能被滥用
|
||||
- 其他安全风险
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction-info
|
||||
attributes:
|
||||
label: 复现信息
|
||||
description: 提供必要的复现信息(请避免详细的攻击步骤)
|
||||
placeholder: |
|
||||
请提供:
|
||||
- 发现问题的环境
|
||||
- 触发条件
|
||||
- 观察到的异常行为
|
||||
|
||||
注意:请不要提供完整的攻击代码或详细攻击步骤
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: mitigation-suggestions
|
||||
attributes:
|
||||
label: 缓解建议
|
||||
description: 如果您有安全方面的建议,请提供
|
||||
placeholder: |
|
||||
可能的解决方案:
|
||||
- 输入验证改进
|
||||
- 权限检查加强
|
||||
- 安全配置调整
|
||||
- 其他安全措施
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: disclosure-timeline
|
||||
attributes:
|
||||
label: 披露时间线
|
||||
description: 您希望的问题处理时间线
|
||||
options:
|
||||
- 立即处理 - 严重安全问题
|
||||
- 1周内 - 重要安全问题
|
||||
- 2周内 - 一般安全问题
|
||||
- 1个月内 - 安全改进建议
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 其他有助于理解和解决安全问题的信息
|
||||
placeholder: 相关文档、参考资料、类似案例等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: security-terms
|
||||
attributes:
|
||||
label: 安全报告确认
|
||||
description: 请确认以下事项
|
||||
options:
|
||||
- label: 我理解这是一个安全相关的问题报告
|
||||
required: true
|
||||
- label: 我已经避免在公开Issue中透露详细的攻击方法
|
||||
required: true
|
||||
- label: 我愿意配合安全团队进行问题验证和修复
|
||||
required: true
|
||||
- label: 我同意遵循负责任的安全披露原则
|
||||
required: true
|
||||
@@ -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:
|
||||
- '*'
|
||||
@@ -0,0 +1,107 @@
|
||||
# .github/workflows/build-fpk.yml
|
||||
# VoiceHub 飞牛 fnOS FPK 安装包自动构建
|
||||
# 手动触发,版本号自动从 package.json 读取
|
||||
|
||||
name: Build VoiceHub FPK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-fpk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 读取版本号 & 安装 fnpack
|
||||
run: |
|
||||
VER=$(node -p "require('./package.json').version")
|
||||
echo "APP_VER=$VER" >> "$GITHUB_ENV"
|
||||
echo "📦 版本: $VER"
|
||||
|
||||
wget -q https://static2.fnnas.com/fnpack/fnpack-1.2.1-linux-amd64 -O /usr/local/bin/fnpack
|
||||
chmod +x /usr/local/bin/fnpack
|
||||
|
||||
- name: 创建 FPK 目录
|
||||
run: |
|
||||
fnpack create voicehub --template docker
|
||||
echo "📁 模板结构:"
|
||||
find voicehub -type f | sort
|
||||
|
||||
- name: 写入 manifest & 准备图标
|
||||
run: |
|
||||
# 复制仓库中的 fnos 模板
|
||||
cp -r fnos/* voicehub/
|
||||
|
||||
# 从 public 目录复制图标到构建目录
|
||||
mkdir -p voicehub/app/ui/images
|
||||
cp public/images/logo.png voicehub/app/ui/images/icon-64.png
|
||||
cp public/images/logo.png voicehub/app/ui/images/icon-256.png
|
||||
cp public/images/logo.png voicehub/ICON.PNG
|
||||
cp public/images/logo.png voicehub/ICON_256.PNG
|
||||
|
||||
# 更新版本号
|
||||
sed -i "s/^version=.*/version=${APP_VER}/" voicehub/manifest
|
||||
chmod +x voicehub/cmd/main
|
||||
echo "✅ manifest, cmd/main & icons 已就绪"
|
||||
|
||||
- name: 从仓库读取 docker-compose-prebuild-nju.yml 并写入
|
||||
run: |
|
||||
# 从项目根目录读取仓库内的 compose 文件
|
||||
SRC_COMPOSE="docker-compose/docker-compose-prebuild-nju.yml"
|
||||
# 目标路径
|
||||
DST_COMPOSE="voicehub/app/docker/docker-compose.yaml"
|
||||
|
||||
# 创建目录
|
||||
mkdir -p voicehub/app/docker
|
||||
|
||||
# 复制内容并进行修改:
|
||||
# 1. 移除 postgres 服务的端口映射,以避免 fnOS 5432 端口冲突
|
||||
# 2. 移除 voicehub 服务的代码挂载 (.:/app),避免覆盖镜像内的预构建代码导致 MODULE_NOT_FOUND
|
||||
# 3. 确保容器间通过 service name 互联
|
||||
# 直接使用 awk 工具更精确地过滤掉整个 volumes 块和 ports 块中我们不想要的行,或者删除空块
|
||||
awk '
|
||||
/^[[:space:]]+volumes:/ { in_volumes=1; print; next }
|
||||
/^[[:space:]]+ports:/ { in_ports=1; print; next }
|
||||
/^[[:space:]]+restart:/ { in_volumes=0; in_ports=0; print; next }
|
||||
/^[[:space:]]+[a-zA-Z0-9_]+:/ { in_volumes=0; in_ports=0; print; next }
|
||||
|
||||
in_volumes && /^[[:space:]]+-[[:space:]]+.:\/app/ { next }
|
||||
in_volumes && /^[[:space:]]+-[[:space:]]+\/app\/node_modules/ { next }
|
||||
in_ports && /^[[:space:]]+-[[:space:]]+.5432:5432./ { next }
|
||||
|
||||
{ print }
|
||||
' "$SRC_COMPOSE" > "$DST_COMPOSE.tmp"
|
||||
|
||||
# 清理由于上述删除可能产生的空的 volumes: 或 ports: 标签
|
||||
# 使用 grep 检查下一行是否是配置项或空行
|
||||
sed -E '/^[[:space:]]+volumes:[[:space:]]*$/{N;/\n[[:space:]]+-/!d}' "$DST_COMPOSE.tmp" > "$DST_COMPOSE.tmp2"
|
||||
sed -E '/^[[:space:]]+ports:[[:space:]]*$/{N;/\n[[:space:]]+-/!d}' "$DST_COMPOSE.tmp2" > "$DST_COMPOSE"
|
||||
rm "$DST_COMPOSE.tmp" "$DST_COMPOSE.tmp2"
|
||||
|
||||
echo "✅ 已处理 compose 文件:移除数据库端口暴露 & 移除应用代码挂载"
|
||||
cat "$DST_COMPOSE"
|
||||
|
||||
- name: 最终目录 & 打包
|
||||
run: |
|
||||
echo "📦 FPK 结构:"
|
||||
find voicehub -type f | sort
|
||||
echo "---"
|
||||
cd voicehub
|
||||
fnpack build
|
||||
ls -lh *.fpk 2>/dev/null || { echo "❌ 打包失败"; exit 1; }
|
||||
mv *.fpk ../
|
||||
|
||||
- name: 上传 Artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: voicehub-fpk-${{ env.APP_VER }}
|
||||
path: "*.fpk"
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: 'Reason for manual trigger'
|
||||
required: false
|
||||
default: 'Manual trigger'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# 提取版本号并处理成不同格式
|
||||
- name: Extract version from package.json
|
||||
id: extract-version
|
||||
run: |
|
||||
# 提取完整版本号
|
||||
FULL_VERSION=$(node -p "require('./package.json').version")
|
||||
# 提取主版本.次版本 (如 1.2 从 1.2.3)
|
||||
MAJOR_MINOR_VERSION=$(echo "$FULL_VERSION" | sed -E 's/^([0-9]+\.[0-9]+)\.[0-9]+$/\1/')
|
||||
|
||||
echo "full-version=$FULL_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "major-minor-version=$MAJOR_MINOR_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted full version: $FULL_VERSION"
|
||||
echo "Extracted major.minor version: $MAJOR_MINOR_VERSION"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm,arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
# 使用从package.json提取的完整版本号
|
||||
type=raw,value=${{ steps.extract-version.outputs.full-version }}
|
||||
# 使用主版本.次版本格式
|
||||
type=raw,value=${{ steps.extract-version.outputs.major-minor-version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
+42
@@ -0,0 +1,42 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
docs
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
*.backup
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
.kiro
|
||||
.cursor
|
||||
.vercel
|
||||
.netlify
|
||||
.vscode
|
||||
.trae
|
||||
.qoder
|
||||
.figma
|
||||
scripts/package-lock.json
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Tencent Cloud EdgeOne
|
||||
.edgeone/*
|
||||
.tef_dist/*
|
||||
@@ -0,0 +1,19 @@
|
||||
.nuxt/
|
||||
.output/
|
||||
dist/
|
||||
node_modules/
|
||||
public/
|
||||
coverage/
|
||||
*.min.js
|
||||
*.min.css
|
||||
.DS_Store
|
||||
*.log
|
||||
vendor/
|
||||
oh_modules/
|
||||
**/oh_modules/
|
||||
VoiceHub-hmos/oh_modules/
|
||||
VoiceHub-hmos/oh-package-lock.json5
|
||||
app/drizzle/
|
||||
**/build/
|
||||
**/.preview/
|
||||
VoiceHub-hmos/
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
README.md
|
||||
@@ -0,0 +1,76 @@
|
||||
# VoiceHub Code of Conduct
|
||||
|
||||
## Our Commitment
|
||||
|
||||
VoiceHub is an open source, community-driven campus broadcasting and song request system.
|
||||
|
||||
We are committed to providing a friendly, respectful, and harassment-free environment for everyone who participates in this project — including users, contributors, maintainers, and community members — regardless of age, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual orientation.
|
||||
|
||||
We aim to foster an open, inclusive, and constructive community where collaboration and learning are encouraged.
|
||||
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
We encourage all participants to:
|
||||
|
||||
- Be respectful and considerate in discussions, reviews, and feedback
|
||||
- Communicate thoughtfully, especially when opinions differ
|
||||
- Welcome newcomers and help them learn
|
||||
- Provide constructive criticism focused on improving the project
|
||||
- Take responsibility for mistakes and be willing to correct them
|
||||
- Respect the campus-oriented and community-serving nature of this project
|
||||
|
||||
---
|
||||
|
||||
## Unacceptable Behavior
|
||||
|
||||
The following behaviors are not tolerated within the VoiceHub community:
|
||||
|
||||
- Harassment, discrimination, or exclusion of any kind
|
||||
- Insulting, demeaning, or hostile language
|
||||
- Personal attacks, threats, or intimidation
|
||||
- Sexualized language, imagery, or unwanted attention
|
||||
- Publishing others’ private information without explicit permission
|
||||
- Deliberate disruption of discussions, development, or community activities
|
||||
- Any behavior that would be considered inappropriate in an educational or professional environment
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies to all VoiceHub-related spaces, including but not limited to:
|
||||
|
||||
- GitHub repositories, issues, pull requests, and discussions
|
||||
- Official communication channels and community platforms
|
||||
- Public spaces where individuals represent VoiceHub, including events, documentation, or social media
|
||||
|
||||
---
|
||||
|
||||
## Enforcement
|
||||
|
||||
If you experience or witness behavior that violates this Code of Conduct, please report it to the project maintainers.
|
||||
|
||||
All reports will be reviewed fairly and promptly. Maintainers will handle reports with discretion and respect the privacy of all parties involved.
|
||||
|
||||
Project maintainers have the responsibility and authority to take appropriate action in response to violations.
|
||||
|
||||
---
|
||||
|
||||
## Enforcement Actions
|
||||
|
||||
Depending on the severity and frequency of the behavior, maintainers may take one or more of the following actions:
|
||||
|
||||
1. **Private Reminder** – A private message explaining the issue and expected behavior.
|
||||
2. **Formal Warning** – A clear warning with expectations for future conduct.
|
||||
3. **Temporary Restriction** – Temporary limitation or suspension from community interaction.
|
||||
4. **Permanent Ban** – Permanent removal from participation in the project or community spaces.
|
||||
|
||||
Actions will be taken in a manner proportional to the impact on the community.
|
||||
|
||||
---
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is inspired by the Contributor Covenant, version 2.1.
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct
|
||||
@@ -0,0 +1,76 @@
|
||||
# VoiceHub 行为准则
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
VoiceHub 是一个面向校园与社区的开源广播与点歌系统项目。
|
||||
|
||||
我们致力于为所有参与本项目的人 —— 包括用户、贡献者、维护者以及社区成员 —— 提供一个友善、尊重、免于骚扰的环境,无论其年龄、残疾状况、族裔、性别认同与表达、经验水平、国籍、外表、宗教或性取向如何。
|
||||
|
||||
我们希望构建一个开放、包容、理性且富有建设性的社区氛围,鼓励协作与学习。
|
||||
|
||||
---
|
||||
|
||||
## 鼓励的行为
|
||||
|
||||
我们鼓励所有参与者:
|
||||
|
||||
- 在讨论、评审和反馈中保持尊重与克制
|
||||
- 在观点不一致时进行理性、善意的交流
|
||||
- 友好对待新参与者,帮助他们熟悉项目
|
||||
- 提供以改进项目为目标的建设性意见
|
||||
- 勇于承认并纠正错误
|
||||
- 尊重 VoiceHub 作为校园与社区服务项目的定位
|
||||
|
||||
---
|
||||
|
||||
## 不可接受的行为
|
||||
|
||||
在 VoiceHub 社区中,以下行为是不被允许的:
|
||||
|
||||
- 任何形式的骚扰、歧视或排斥行为
|
||||
- 侮辱性、贬损性或带有敌意的言辞
|
||||
- 人身攻击、威胁或恐吓
|
||||
- 带有性暗示的语言、图像或不受欢迎的关注
|
||||
- 未经许可公开他人的私人信息
|
||||
- 蓄意干扰社区讨论、开发流程或正常协作
|
||||
- 任何在教育或专业环境中被认为不恰当的行为
|
||||
|
||||
---
|
||||
|
||||
## 适用范围
|
||||
|
||||
本行为准则适用于所有与 VoiceHub 相关的空间,包括但不限于:
|
||||
|
||||
- GitHub 仓库、Issue、Pull Request 与讨论区
|
||||
- 官方社区交流渠道
|
||||
- 任何以 VoiceHub 名义进行的公开活动、文档或社交媒体发言
|
||||
|
||||
---
|
||||
|
||||
## 执行与举报
|
||||
|
||||
如果你遭遇或目睹了违反本行为准则的情况,请向项目维护者进行反馈。
|
||||
|
||||
所有举报都会被认真、公正地对待。维护者将尊重相关人员的隐私,并谨慎处理相关信息。
|
||||
|
||||
项目维护者有责任并有权对违规行为采取适当措施。
|
||||
|
||||
---
|
||||
|
||||
## 处理措施
|
||||
|
||||
根据违规行为的严重程度和影响,维护者可能采取以下一种或多种措施:
|
||||
|
||||
1. **私下提醒** —— 通过私下沟通说明问题并明确期望行为
|
||||
2. **正式警告** —— 明确指出违规行为及后续要求
|
||||
3. **临时限制** —— 暂时限制或暂停其参与社区互动
|
||||
4. **永久封禁** —— 永久取消其参与项目或社区的资格
|
||||
|
||||
所有处理措施都会遵循“与社区影响相匹配”的原则。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
本行为准则参考并改编自 Contributor Covenant 2.1 版本:
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct
|
||||
+64
@@ -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"]
|
||||
@@ -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>.
|
||||
@@ -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
@@ -0,0 +1,206 @@
|
||||
# VoiceHub 升级指南
|
||||
|
||||
本文档介绍如何将 VoiceHub 项目从旧版本升级到最新版本。请根据您的部署方式选择相应的升级步骤。
|
||||
|
||||
> ⚠️ **升级前请务必备份数据库和配置文件!**
|
||||
|
||||
## 1. 备份数据
|
||||
|
||||
无论使用何种部署方式,升级前请先备份数据库。
|
||||
|
||||
### 1.1 使用管理后台备份(推荐)
|
||||
|
||||
1. 登录管理员后台
|
||||
2. 进入"数据库管理"页面
|
||||
3. 点击"创建备份" -> 选择"完整备份"
|
||||
4. 待备份完成后,下载备份文件到本地保存
|
||||
|
||||
### 1.2 使用命令行备份
|
||||
|
||||
也可以使用 PostgreSQL 命令行工具:
|
||||
|
||||
```bash
|
||||
# Docker 部署用户
|
||||
docker exec voicehub-postgres-1 pg_dump -U user voicehub > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# 源码部署用户
|
||||
pg_dump -h localhost -U your_username -d voicehub > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Docker 部署升级
|
||||
|
||||
### 2.1 使用 Docker Compose(推荐)
|
||||
|
||||
如果您使用 `docker-compose.yml` 部署:
|
||||
|
||||
1. **拉取最新代码**
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
2. **重建并重启服务**
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
> **注意**:由于默认配置使用本地构建,单纯使用 `pull` 命令无效。必须添加 `--build` 参数以确保应用代码变更。
|
||||
|
||||
3. **验证升级**
|
||||
```bash
|
||||
docker-compose logs -f voicehub
|
||||
```
|
||||
查看日志确认没有报错,且显示 "部署完成"。
|
||||
|
||||
### 2.2 使用 Docker Run 命令
|
||||
|
||||
如果您直接使用 `docker run` 命令:
|
||||
|
||||
1. **停止并删除旧容器**
|
||||
|
||||
```bash
|
||||
docker stop voicehub
|
||||
docker rm voicehub
|
||||
```
|
||||
|
||||
2. **拉取最新镜像**
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/laoshuikaixue/voicehub:latest
|
||||
# 或
|
||||
docker pull ghcr.nju.edu.cn/laoshuikaixue/voicehub:latest
|
||||
```
|
||||
|
||||
3. **启动新容器**
|
||||
使用与之前相同的环境变量启动:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL="..." \
|
||||
-e JWT_SECRET="..." \
|
||||
--name voicehub \
|
||||
ghcr.io/laoshuikaixue/voicehub:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 源码/本地部署升级
|
||||
|
||||
### 3.1 停止运行中的服务
|
||||
|
||||
```bash
|
||||
# 如果使用 PM2
|
||||
pm2 stop voicehub
|
||||
|
||||
# 如果使用 systemd
|
||||
sudo systemctl stop voicehub
|
||||
|
||||
# 如果直接运行(Ctrl+C 停止进程)
|
||||
```
|
||||
|
||||
### 3.2 获取最新代码
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git pull origin main
|
||||
|
||||
# 或指定分支
|
||||
# git pull origin <branch-name>
|
||||
```
|
||||
|
||||
### 3.3 更新依赖
|
||||
|
||||
```bash
|
||||
# 安装最新依赖
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
### 3.4 数据库迁移
|
||||
|
||||
**重要**:新版本通常包含数据库结构变更,必须执行迁移。
|
||||
|
||||
```bash
|
||||
# 方式 1:使用自动同步脚本(推荐)
|
||||
node scripts/db-sync.js
|
||||
|
||||
# 方式 2:手动执行迁移
|
||||
pnpm run db:migrate
|
||||
|
||||
# 方式 3:如果迁移失败,且确信可以覆盖(仅开发环境)
|
||||
# pnpm run db:push
|
||||
```
|
||||
|
||||
**迁移说明**:
|
||||
|
||||
- `db-sync.js` 脚本会自动检测数据库状态并选择合适的迁移方式
|
||||
- 空数据库会执行完整迁移
|
||||
- 已有数据的数据库会使用 push 方式同步结构
|
||||
|
||||
### 3.5 重新构建应用
|
||||
|
||||
```bash
|
||||
# 清理旧的构建文件(可选)
|
||||
rm -rf .nuxt .output
|
||||
|
||||
# 构建生产版本
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
构建完成后,会在 `.output` 目录生成生产环境文件。
|
||||
|
||||
### 3.6 重启服务
|
||||
|
||||
根据您的部署方式选择:
|
||||
|
||||
#### 使用 PM2(推荐)
|
||||
|
||||
```bash
|
||||
# 重启服务
|
||||
pm2 restart voicehub
|
||||
|
||||
# 查看日志
|
||||
pm2 logs voicehub
|
||||
|
||||
# 查看状态
|
||||
pm2 status
|
||||
```
|
||||
|
||||
如果是首次使用 PM2:
|
||||
|
||||
```bash
|
||||
# 安装 PM2
|
||||
pnpm add -g pm2
|
||||
|
||||
# 启动应用
|
||||
pm2 start pnpm --name voicehub -- run start
|
||||
|
||||
# 设置开机自启
|
||||
pm2 startup
|
||||
pm2 save
|
||||
```
|
||||
|
||||
#### 使用 systemd
|
||||
|
||||
```bash
|
||||
# 重启服务
|
||||
sudo systemctl restart voicehub
|
||||
|
||||
# 查看状态
|
||||
sudo systemctl status voicehub
|
||||
|
||||
# 查看日志
|
||||
sudo journalctl -u voicehub -f
|
||||
```
|
||||
|
||||
#### 直接运行
|
||||
|
||||
```bash
|
||||
# 生产模式启动
|
||||
pnpm run start
|
||||
|
||||
# 或使用 nohup 后台运行
|
||||
nohup pnpm run start > voicehub.log 2>&1 &
|
||||
```
|
||||
@@ -0,0 +1,17 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.git
|
||||
.gitignore
|
||||
.env
|
||||
.env.local
|
||||
.env.production
|
||||
.env.development
|
||||
dist
|
||||
coverage
|
||||
.vscode
|
||||
.idea
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
@@ -0,0 +1,64 @@
|
||||
# 数据库连接地址(必填)
|
||||
# 格式:postgresql://用户名:密码@主机:端口/数据库名?sslmode=require
|
||||
# 示例:postgresql://myuser:mypassword@localhost:5432/voicehub?sslmode=require
|
||||
DATABASE_URL="postgresql://username:password@host:port/database?sslmode=require"
|
||||
|
||||
# JWT 认证密钥(必填)
|
||||
# 请使用强随机字符串,建议至少 32 位
|
||||
JWT_SECRET="your-very-secure-jwt-secret-key"
|
||||
|
||||
# WebAuthn / Passkey 配置(可选)
|
||||
# 依赖方 ID (RP ID),必须是你域名的一个有效域名
|
||||
# 规则:必须是请求域名的精确匹配或父域名
|
||||
#
|
||||
# 示例:
|
||||
# - 请求域名为 voicehub.example.com,RP ID 可设为 voicehub.example.com 或 example.com
|
||||
# - 不配置时,默认使用当前请求的 hostname(适合开发/预览环境)
|
||||
# - 生产环境建议配置为你的主域名,如:voicehub.example.com
|
||||
WEBAUTHN_RP_ID=
|
||||
|
||||
# 允许的 Origin 列表(可选)
|
||||
# WebAuthn 要求精确匹配 Origin,此配置用于支持多域名部署
|
||||
# 多个域名用逗号分隔,必须包含 https:// 前缀
|
||||
#
|
||||
# 示例:
|
||||
# - 单域名:https://voicehub.example.com
|
||||
# - 多域名(Vercel 预览 + 生产):https://voicehub.example.com,https://voicehub-xxx.vercel.app
|
||||
# - 二级域名:https://voicehub.example.com,https://sub.voicehub.example.com
|
||||
#
|
||||
# 不配置时:默认信任请求的 Origin(适合开发环境)
|
||||
# 生产环境:强烈建议配置,以防止恶意站点伪造 Origin 攻击
|
||||
WEBAUTHN_ORIGIN=
|
||||
|
||||
# 应用运行环境(可选)
|
||||
# development: 开发环境
|
||||
# production: 生产环境
|
||||
NODE_ENV=development
|
||||
|
||||
# Redis 缓存服务(可选,暂不推荐)
|
||||
# 如果设置了 REDIS_URL,系统将自动启用 Redis 缓存功能
|
||||
# 格式:redis://用户名:密码@主机:端口
|
||||
# REDIS_URL="redis://default:password@host:port"
|
||||
|
||||
# OAuth 配置(可选)
|
||||
# 启用相应的 OAuth 提供商需要填写对应的 Client ID 和 Client Secret
|
||||
|
||||
# OAuth 回调地址(必填,如果启用 OAuth)
|
||||
# 注意:[provider] 是占位符,实际使用时需要根据具体情况填写
|
||||
OAUTH_REDIRECT_URI="http://localhost:3000/api/auth/[provider]/callback"
|
||||
|
||||
# 用于加密 OAuth state 的密钥(必填,如果启用 OAuth)
|
||||
OAUTH_STATE_SECRET=your-secure-random-string-for-oauth-state
|
||||
|
||||
# GitHub OAuth
|
||||
GITHUB_CLIENT_ID=
|
||||
GITHUB_CLIENT_SECRET=
|
||||
|
||||
# Casdoor OAuth
|
||||
CASDOOR_CLIENT_ID=
|
||||
CASDOOR_CLIENT_SECRET=
|
||||
CASDOOR_ENDPOINT=
|
||||
|
||||
# Google OAuth
|
||||
GOOGLE_CLIENT_ID=
|
||||
GOOGLE_CLIENT_SECRET=
|
||||
+124
@@ -0,0 +1,124 @@
|
||||
name: 🐛 问题反馈
|
||||
description: 报告VoiceHub系统中的问题或错误
|
||||
title: '[Bug]: '
|
||||
labels: ['bug', '需要确认']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您报告问题!请详细描述遇到的问题,这将帮助我们更快地定位和解决问题。
|
||||
|
||||
- type: dropdown
|
||||
id: bug-type
|
||||
attributes:
|
||||
label: 问题类型
|
||||
description: 请选择最符合的问题类型
|
||||
options:
|
||||
- 音乐播放相关
|
||||
- 用户登录/注册
|
||||
- 点歌功能
|
||||
- 通知系统
|
||||
- 管理员功能
|
||||
- 界面显示
|
||||
- 性能问题
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: bug-description
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 请详细描述遇到的问题
|
||||
placeholder: 请清楚地描述问题的具体表现...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: steps-to-reproduce
|
||||
attributes:
|
||||
label: 复现步骤
|
||||
description: 请提供详细的复现步骤
|
||||
placeholder: |
|
||||
1. 进入...
|
||||
2. 点击...
|
||||
3. 看到错误...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected-behavior
|
||||
attributes:
|
||||
label: 期望行为
|
||||
description: 描述您期望发生的正确行为
|
||||
placeholder: 应该显示/执行...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual-behavior
|
||||
attributes:
|
||||
label: 实际行为
|
||||
description: 描述实际发生的错误行为
|
||||
placeholder: 实际显示/执行了...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: browser
|
||||
attributes:
|
||||
label: 浏览器
|
||||
description: 使用的浏览器类型
|
||||
options:
|
||||
- Chrome
|
||||
- Firefox
|
||||
- Safari
|
||||
- Edge
|
||||
- 其他
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: input
|
||||
id: browser-version
|
||||
attributes:
|
||||
label: 浏览器版本
|
||||
description: 浏览器的具体版本号
|
||||
placeholder: 例如:Chrome 120.0.0.0
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: device-type
|
||||
attributes:
|
||||
label: 设备类型
|
||||
description: 使用的设备类型
|
||||
options:
|
||||
- 桌面电脑
|
||||
- 笔记本电脑
|
||||
- 平板电脑
|
||||
- 手机
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 任何其他有助于理解问题的信息
|
||||
placeholder: 截图、错误日志、相关配置等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: 确认事项
|
||||
description: 请确认以下事项
|
||||
options:
|
||||
- label: 我已经搜索了现有的Issues,确认这不是重复问题
|
||||
required: true
|
||||
- label: 我已经尝试刷新页面或重新登录
|
||||
required: false
|
||||
- label: 问题可以稳定复现
|
||||
required: false
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
blank_issues_enabled: true
|
||||
contact_links:
|
||||
- name: 📚 使用文档
|
||||
url: https://docs.voicehub.lao-shui.top
|
||||
about: 查看VoiceHub的使用文档和常见问题解答
|
||||
- name: 💬 讨论区
|
||||
url: https://github.com/laoshuikaixue/VoiceHub/discussions
|
||||
about: 在讨论区提问、分享经验或参与社区讨论
|
||||
- name: 🔒 安全问题私密报告
|
||||
url: mailto:contact@lao-shui.top
|
||||
about: 对于严重的安全漏洞,请通过邮件私密报告
|
||||
- name: 📧 联系我们
|
||||
url: mailto:contact@lao-shui.top
|
||||
about: 如果您需要技术支持或有其他问题,可以直接联系我们
|
||||
@@ -0,0 +1,139 @@
|
||||
name: ✨ 功能建议
|
||||
description: 为VoiceHub系统提出新功能或改进建议
|
||||
title: '[Feature]: '
|
||||
labels: ['enhancement', '功能建议']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您为VoiceHub提出功能建议!您的想法将帮助我们改进系统,为用户提供更好的体验。
|
||||
|
||||
- type: dropdown
|
||||
id: feature-category
|
||||
attributes:
|
||||
label: 功能类别
|
||||
description: 请选择建议功能所属的类别
|
||||
options:
|
||||
- 音乐播放功能
|
||||
- 点歌系统
|
||||
- 用户管理
|
||||
- 通知系统
|
||||
- 管理员工具
|
||||
- 用户界面
|
||||
- 数据统计
|
||||
- 安全功能
|
||||
- API接口
|
||||
- 其他
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: feature-summary
|
||||
attributes:
|
||||
label: 功能概述
|
||||
description: 简要描述您建议的功能
|
||||
placeholder: 用一两句话概括您的功能建议...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: problem-statement
|
||||
attributes:
|
||||
label: 问题描述
|
||||
description: 描述当前存在的问题或不便之处
|
||||
placeholder: 目前在使用VoiceHub时遇到了什么问题或不便?
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: proposed-solution
|
||||
attributes:
|
||||
label: 建议方案
|
||||
description: 详细描述您建议的解决方案
|
||||
placeholder: |
|
||||
请详细描述您的功能建议:
|
||||
- 功能如何工作
|
||||
- 用户如何使用
|
||||
- 预期效果
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: user-stories
|
||||
attributes:
|
||||
label: 用户场景
|
||||
description: 描述用户在什么情况下会使用这个功能
|
||||
placeholder: |
|
||||
作为一个[用户角色],我希望[功能描述],这样我就可以[预期收益]。
|
||||
|
||||
例如:
|
||||
- 作为学生,我希望能够预约点歌时间,这样我就可以在特定时间播放我的歌曲
|
||||
- 作为管理员,我希望能够批量管理歌曲,这样我就可以更高效地维护歌曲库
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: 替代方案
|
||||
description: 是否考虑过其他解决方案?
|
||||
placeholder: 描述您考虑过的其他可能的解决方案...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: priority
|
||||
attributes:
|
||||
label: 优先级
|
||||
description: 您认为这个功能的重要程度如何?
|
||||
options:
|
||||
- 高 - 非常重要,急需实现
|
||||
- 中 - 重要,希望尽快实现
|
||||
- 低 - 有了更好,不急
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-type
|
||||
attributes:
|
||||
label: 目标用户
|
||||
description: 这个功能主要面向哪类用户?
|
||||
options:
|
||||
- 所有用户
|
||||
- 普通学生用户
|
||||
- 管理员
|
||||
- 特定用户群体
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: technical-considerations
|
||||
attributes:
|
||||
label: 技术考虑
|
||||
description: 如果您有技术背景,可以提供一些技术实现的想法
|
||||
placeholder: 实现思路、技术栈建议、性能考虑等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: additional-context
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 任何其他有助于理解功能需求的信息
|
||||
placeholder: 参考资料、类似功能的例子、设计草图等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: terms
|
||||
attributes:
|
||||
label: 确认事项
|
||||
description: 请确认以下事项
|
||||
options:
|
||||
- label: 我已经搜索了现有的Issues,确认这不是重复建议
|
||||
required: true
|
||||
- label: 我愿意参与功能的讨论和测试
|
||||
required: false
|
||||
- label: 如果有能力,我愿意协助开发这个功能
|
||||
required: false
|
||||
+112
@@ -0,0 +1,112 @@
|
||||
name: '❓ 提问 / Question'
|
||||
description: '遇到问题需要帮助?在这里提问,我们会尽快为您解答!'
|
||||
title: '[Question]: '
|
||||
labels: ['question', '需要帮助']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
感谢您使用 VoiceHub 校园广播点歌系统!如果您在使用过程中遇到任何问题,请填写以下信息,我们会尽快为您提供帮助。
|
||||
|
||||
💡 **提示**:提问前建议先查看已有的 Issues。
|
||||
|
||||
- type: dropdown
|
||||
id: question-type
|
||||
attributes:
|
||||
label: '问题类型'
|
||||
description: '请选择您遇到的问题类型'
|
||||
options:
|
||||
- '使用问题 - 不知道如何操作某个功能'
|
||||
- '技术问题 - 功能无法正常工作'
|
||||
- '配置问题 - 系统设置相关'
|
||||
- '账户问题 - 登录、权限等'
|
||||
- '音频问题 - 播放、上传相关'
|
||||
- '界面问题 - 显示异常或操作困难'
|
||||
- '其他问题'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: question-description
|
||||
attributes:
|
||||
label: '问题描述'
|
||||
description: '请详细描述您遇到的问题,包括具体的操作步骤和现象'
|
||||
placeholder: |
|
||||
例如:
|
||||
1. 我在尝试点歌时...
|
||||
2. 点击"添加到播放列表"按钮后...
|
||||
3. 期望的结果是...,但实际发生的是...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: user-role
|
||||
attributes:
|
||||
label: '用户角色'
|
||||
description: '您在系统中的角色是什么?'
|
||||
options:
|
||||
- '学生用户'
|
||||
- '教师用户'
|
||||
- '管理员'
|
||||
- '超级管理员'
|
||||
- '访客'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: input
|
||||
id: environment
|
||||
attributes:
|
||||
label: '使用环境'
|
||||
description: '请提供您的使用环境信息'
|
||||
placeholder: '例如:Chrome 120, Windows 11, 移动端Safari等'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: attempted-solutions
|
||||
attributes:
|
||||
label: '已尝试的解决方法'
|
||||
description: '请描述您已经尝试过的解决方法(如果有的话)'
|
||||
placeholder: |
|
||||
例如:
|
||||
- 刷新了页面
|
||||
- 清除了浏览器缓存
|
||||
- 尝试了不同的浏览器
|
||||
- 重新登录了账户
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: urgency
|
||||
attributes:
|
||||
label: '紧急程度'
|
||||
description: '这个问题对您的使用影响有多大?'
|
||||
options:
|
||||
- '低 - 不影响正常使用,有时间时解决即可'
|
||||
- '中 - 影响部分功能,希望尽快解决'
|
||||
- '高 - 严重影响使用,需要紧急处理'
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: '补充信息'
|
||||
description: '任何其他可能有帮助的信息,如截图、错误信息等'
|
||||
placeholder: '您可以在这里添加截图、错误截图或其他相关信息'
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: confirmation
|
||||
attributes:
|
||||
label: '确认事项'
|
||||
description: '请确认以下事项'
|
||||
options:
|
||||
- label: '我已经搜索了现有的 Issues,确认这不是重复问题'
|
||||
required: true
|
||||
# - label: "我已经查看了项目文档和常见问题"
|
||||
# required: false
|
||||
- label: '我愿意提供更多信息来帮助解决这个问题'
|
||||
required: false
|
||||
@@ -0,0 +1,149 @@
|
||||
name: 🔒 安全问题报告
|
||||
description: 报告VoiceHub系统中的安全漏洞或安全相关问题
|
||||
title: '[Security]: '
|
||||
labels: ['security', '高优先级']
|
||||
assignees: []
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
⚠️ **重要提醒**:如果您发现了严重的安全漏洞,请不要在公开的Issue中详细描述。
|
||||
请发送邮件至 `contact@lao-shui.top` 。
|
||||
|
||||
感谢您帮助我们保护VoiceHub系统的安全!
|
||||
|
||||
- type: dropdown
|
||||
id: security-type
|
||||
attributes:
|
||||
label: 安全问题类型
|
||||
description: 请选择最符合的安全问题类型
|
||||
options:
|
||||
- 身份认证问题
|
||||
- 权限控制问题
|
||||
- 数据泄露风险
|
||||
- 跨站脚本攻击(XSS)
|
||||
- SQL注入
|
||||
- 跨站请求伪造(CSRF)
|
||||
- 文件上传安全
|
||||
- 会话管理问题
|
||||
- 密码安全
|
||||
- API安全
|
||||
- 其他安全问题
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: dropdown
|
||||
id: severity
|
||||
attributes:
|
||||
label: 严重程度
|
||||
description: 请评估安全问题的严重程度
|
||||
options:
|
||||
- 严重 - 可能导致系统完全被攻破
|
||||
- 高 - 可能导致重要数据泄露
|
||||
- 中 - 可能影响部分用户或功能
|
||||
- 低 - 轻微的安全改进建议
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: security-summary
|
||||
attributes:
|
||||
label: 安全问题概述
|
||||
description: 简要描述发现的安全问题(避免详细的攻击步骤)
|
||||
placeholder: 简要说明发现的安全问题类型和影响范围...
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: affected-components
|
||||
attributes:
|
||||
label: 受影响的组件
|
||||
description: 列出可能受到影响的系统组件或功能模块
|
||||
placeholder: |
|
||||
例如:
|
||||
- 用户登录模块
|
||||
- 文件上传功能
|
||||
- 管理员面板
|
||||
- API接口
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: potential-impact
|
||||
attributes:
|
||||
label: 潜在影响
|
||||
description: 描述这个安全问题可能造成的影响
|
||||
placeholder: |
|
||||
可能的影响包括:
|
||||
- 用户数据泄露
|
||||
- 未授权访问
|
||||
- 系统功能被滥用
|
||||
- 其他安全风险
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: reproduction-info
|
||||
attributes:
|
||||
label: 复现信息
|
||||
description: 提供必要的复现信息(请避免详细的攻击步骤)
|
||||
placeholder: |
|
||||
请提供:
|
||||
- 发现问题的环境
|
||||
- 触发条件
|
||||
- 观察到的异常行为
|
||||
|
||||
注意:请不要提供完整的攻击代码或详细攻击步骤
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: textarea
|
||||
id: mitigation-suggestions
|
||||
attributes:
|
||||
label: 缓解建议
|
||||
description: 如果您有安全方面的建议,请提供
|
||||
placeholder: |
|
||||
可能的解决方案:
|
||||
- 输入验证改进
|
||||
- 权限检查加强
|
||||
- 安全配置调整
|
||||
- 其他安全措施
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: dropdown
|
||||
id: disclosure-timeline
|
||||
attributes:
|
||||
label: 披露时间线
|
||||
description: 您希望的问题处理时间线
|
||||
options:
|
||||
- 立即处理 - 严重安全问题
|
||||
- 1周内 - 重要安全问题
|
||||
- 2周内 - 一般安全问题
|
||||
- 1个月内 - 安全改进建议
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: additional-info
|
||||
attributes:
|
||||
label: 附加信息
|
||||
description: 其他有助于理解和解决安全问题的信息
|
||||
placeholder: 相关文档、参考资料、类似案例等...
|
||||
validations:
|
||||
required: false
|
||||
|
||||
- type: checkboxes
|
||||
id: security-terms
|
||||
attributes:
|
||||
label: 安全报告确认
|
||||
description: 请确认以下事项
|
||||
options:
|
||||
- label: 我理解这是一个安全相关的问题报告
|
||||
required: true
|
||||
- label: 我已经避免在公开Issue中透露详细的攻击方法
|
||||
required: true
|
||||
- label: 我愿意配合安全团队进行问题验证和修复
|
||||
required: true
|
||||
- label: 我同意遵循负责任的安全披露原则
|
||||
required: true
|
||||
Vendored
+90
@@ -0,0 +1,90 @@
|
||||
# To get started with Dependabot version updates, you'll need to specify which
|
||||
# package ecosystems to update and where the package manifests are located.
|
||||
# Please see the documentation for all configuration options:
|
||||
# https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file
|
||||
|
||||
version: 2
|
||||
updates:
|
||||
# Enable version updates for npm
|
||||
- package-ecosystem: 'npm'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'saturday'
|
||||
time: '09:00'
|
||||
timezone: 'Asia/Shanghai'
|
||||
open-pull-requests-limit: 10
|
||||
commit-message:
|
||||
prefix: 'chore'
|
||||
prefix-development: 'chore'
|
||||
include: 'scope'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'automated'
|
||||
- 'chore'
|
||||
allow:
|
||||
- dependency-type: 'direct'
|
||||
- dependency-type: 'indirect'
|
||||
ignore:
|
||||
# 忽略主要版本更新,避免破坏性变更
|
||||
- dependency-name: '*'
|
||||
update-types: ['version-update:semver-major']
|
||||
groups:
|
||||
# 将所有非重大(minor/patch)更新合并到一个 PR
|
||||
npm-minor-patch:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- '*'
|
||||
update-types:
|
||||
- 'minor'
|
||||
- 'patch'
|
||||
|
||||
# Enable version updates for Docker
|
||||
- package-ecosystem: 'docker'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'saturday'
|
||||
time: '09:00'
|
||||
timezone: 'Asia/Shanghai'
|
||||
open-pull-requests-limit: 5
|
||||
reviewers:
|
||||
- 'laoshuikaixue'
|
||||
assignees:
|
||||
- 'laoshuikaixue'
|
||||
commit-message:
|
||||
prefix: 'chore'
|
||||
include: 'scope'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'automated'
|
||||
- 'chore'
|
||||
ignore:
|
||||
- dependency-name: '*node*'
|
||||
groups:
|
||||
docker-all:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- '*'
|
||||
|
||||
# Enable version updates for GitHub Actions
|
||||
- package-ecosystem: 'github-actions'
|
||||
directory: '/'
|
||||
schedule:
|
||||
interval: 'weekly'
|
||||
day: 'saturday'
|
||||
time: '09:00'
|
||||
timezone: 'Asia/Shanghai'
|
||||
open-pull-requests-limit: 5
|
||||
commit-message:
|
||||
prefix: 'chore'
|
||||
include: 'scope'
|
||||
labels:
|
||||
- 'dependencies'
|
||||
- 'automated'
|
||||
- 'chore'
|
||||
groups:
|
||||
gha-all:
|
||||
applies-to: version-updates
|
||||
patterns:
|
||||
- '*'
|
||||
+107
@@ -0,0 +1,107 @@
|
||||
# .github/workflows/build-fpk.yml
|
||||
# VoiceHub 飞牛 fnOS FPK 安装包自动构建
|
||||
# 手动触发,版本号自动从 package.json 读取
|
||||
|
||||
name: Build VoiceHub FPK
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-fpk:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: 读取版本号 & 安装 fnpack
|
||||
run: |
|
||||
VER=$(node -p "require('./package.json').version")
|
||||
echo "APP_VER=$VER" >> "$GITHUB_ENV"
|
||||
echo "📦 版本: $VER"
|
||||
|
||||
wget -q https://static2.fnnas.com/fnpack/fnpack-1.2.1-linux-amd64 -O /usr/local/bin/fnpack
|
||||
chmod +x /usr/local/bin/fnpack
|
||||
|
||||
- name: 创建 FPK 目录
|
||||
run: |
|
||||
fnpack create voicehub --template docker
|
||||
echo "📁 模板结构:"
|
||||
find voicehub -type f | sort
|
||||
|
||||
- name: 写入 manifest & 准备图标
|
||||
run: |
|
||||
# 复制仓库中的 fnos 模板
|
||||
cp -r fnos/* voicehub/
|
||||
|
||||
# 从 public 目录复制图标到构建目录
|
||||
mkdir -p voicehub/app/ui/images
|
||||
cp public/images/logo.png voicehub/app/ui/images/icon-64.png
|
||||
cp public/images/logo.png voicehub/app/ui/images/icon-256.png
|
||||
cp public/images/logo.png voicehub/ICON.PNG
|
||||
cp public/images/logo.png voicehub/ICON_256.PNG
|
||||
|
||||
# 更新版本号
|
||||
sed -i "s/^version=.*/version=${APP_VER}/" voicehub/manifest
|
||||
chmod +x voicehub/cmd/main
|
||||
echo "✅ manifest, cmd/main & icons 已就绪"
|
||||
|
||||
- name: 从仓库读取 docker-compose-prebuild-nju.yml 并写入
|
||||
run: |
|
||||
# 从项目根目录读取仓库内的 compose 文件
|
||||
SRC_COMPOSE="docker-compose/docker-compose-prebuild-nju.yml"
|
||||
# 目标路径
|
||||
DST_COMPOSE="voicehub/app/docker/docker-compose.yaml"
|
||||
|
||||
# 创建目录
|
||||
mkdir -p voicehub/app/docker
|
||||
|
||||
# 复制内容并进行修改:
|
||||
# 1. 移除 postgres 服务的端口映射,以避免 fnOS 5432 端口冲突
|
||||
# 2. 移除 voicehub 服务的代码挂载 (.:/app),避免覆盖镜像内的预构建代码导致 MODULE_NOT_FOUND
|
||||
# 3. 确保容器间通过 service name 互联
|
||||
# 直接使用 awk 工具更精确地过滤掉整个 volumes 块和 ports 块中我们不想要的行,或者删除空块
|
||||
awk '
|
||||
/^[[:space:]]+volumes:/ { in_volumes=1; print; next }
|
||||
/^[[:space:]]+ports:/ { in_ports=1; print; next }
|
||||
/^[[:space:]]+restart:/ { in_volumes=0; in_ports=0; print; next }
|
||||
/^[[:space:]]+[a-zA-Z0-9_]+:/ { in_volumes=0; in_ports=0; print; next }
|
||||
|
||||
in_volumes && /^[[:space:]]+-[[:space:]]+.:\/app/ { next }
|
||||
in_volumes && /^[[:space:]]+-[[:space:]]+\/app\/node_modules/ { next }
|
||||
in_ports && /^[[:space:]]+-[[:space:]]+.5432:5432./ { next }
|
||||
|
||||
{ print }
|
||||
' "$SRC_COMPOSE" > "$DST_COMPOSE.tmp"
|
||||
|
||||
# 清理由于上述删除可能产生的空的 volumes: 或 ports: 标签
|
||||
# 使用 grep 检查下一行是否是配置项或空行
|
||||
sed -E '/^[[:space:]]+volumes:[[:space:]]*$/{N;/\n[[:space:]]+-/!d}' "$DST_COMPOSE.tmp" > "$DST_COMPOSE.tmp2"
|
||||
sed -E '/^[[:space:]]+ports:[[:space:]]*$/{N;/\n[[:space:]]+-/!d}' "$DST_COMPOSE.tmp2" > "$DST_COMPOSE"
|
||||
rm "$DST_COMPOSE.tmp" "$DST_COMPOSE.tmp2"
|
||||
|
||||
echo "✅ 已处理 compose 文件:移除数据库端口暴露 & 移除应用代码挂载"
|
||||
cat "$DST_COMPOSE"
|
||||
|
||||
- name: 最终目录 & 打包
|
||||
run: |
|
||||
echo "📦 FPK 结构:"
|
||||
find voicehub -type f | sort
|
||||
echo "---"
|
||||
cd voicehub
|
||||
fnpack build
|
||||
ls -lh *.fpk 2>/dev/null || { echo "❌ 打包失败"; exit 1; }
|
||||
mv *.fpk ../
|
||||
|
||||
- name: 上传 Artifact
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: voicehub-fpk-${{ env.APP_VER }}
|
||||
path: "*.fpk"
|
||||
retention-days: 30
|
||||
@@ -0,0 +1,84 @@
|
||||
name: Build and Push Docker Image
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
reason:
|
||||
description: 'Reason for manual trigger'
|
||||
required: false
|
||||
default: 'Manual trigger'
|
||||
|
||||
env:
|
||||
REGISTRY: ghcr.io
|
||||
IMAGE_NAME: ${{ github.repository }}
|
||||
|
||||
jobs:
|
||||
build-and-push:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
contents: read
|
||||
packages: write
|
||||
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v6
|
||||
|
||||
# 提取版本号并处理成不同格式
|
||||
- name: Extract version from package.json
|
||||
id: extract-version
|
||||
run: |
|
||||
# 提取完整版本号
|
||||
FULL_VERSION=$(node -p "require('./package.json').version")
|
||||
# 提取主版本.次版本 (如 1.2 从 1.2.3)
|
||||
MAJOR_MINOR_VERSION=$(echo "$FULL_VERSION" | sed -E 's/^([0-9]+\.[0-9]+)\.[0-9]+$/\1/')
|
||||
|
||||
echo "full-version=$FULL_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "major-minor-version=$MAJOR_MINOR_VERSION" >> $GITHUB_OUTPUT
|
||||
echo "Extracted full version: $FULL_VERSION"
|
||||
echo "Extracted major.minor version: $MAJOR_MINOR_VERSION"
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
with:
|
||||
platforms: arm,arm64
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
id: buildx
|
||||
uses: docker/setup-buildx-action@v4
|
||||
|
||||
- name: Log in to the Container registry
|
||||
uses: docker/login-action@v4
|
||||
with:
|
||||
registry: ${{ env.REGISTRY }}
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
- name: Extract metadata (tags, labels) for Docker
|
||||
id: meta
|
||||
uses: docker/metadata-action@v6
|
||||
with:
|
||||
images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}
|
||||
tags: |
|
||||
type=raw,value=latest,enable={{is_default_branch}}
|
||||
type=ref,event=branch
|
||||
type=ref,event=pr
|
||||
# 使用从package.json提取的完整版本号
|
||||
type=raw,value=${{ steps.extract-version.outputs.full-version }}
|
||||
# 使用主版本.次版本格式
|
||||
type=raw,value=${{ steps.extract-version.outputs.major-minor-version }}
|
||||
|
||||
- name: Build and push Docker image
|
||||
id: build-and-push
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
context: .
|
||||
push: ${{ github.event_name != 'pull_request' }}
|
||||
tags: ${{ steps.meta.outputs.tags }}
|
||||
labels: ${{ steps.meta.outputs.labels }}
|
||||
platforms: linux/amd64,linux/arm64,linux/arm/v7
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
@@ -0,0 +1,42 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
docs
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Backups
|
||||
backups/
|
||||
*.backup
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
.kiro
|
||||
.cursor
|
||||
.vercel
|
||||
.netlify
|
||||
.vscode
|
||||
.trae
|
||||
.qoder
|
||||
.figma
|
||||
scripts/package-lock.json
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Tencent Cloud EdgeOne
|
||||
.edgeone/*
|
||||
.tef_dist/*
|
||||
@@ -0,0 +1,19 @@
|
||||
.nuxt/
|
||||
.output/
|
||||
dist/
|
||||
node_modules/
|
||||
public/
|
||||
coverage/
|
||||
*.min.js
|
||||
*.min.css
|
||||
.DS_Store
|
||||
*.log
|
||||
vendor/
|
||||
oh_modules/
|
||||
**/oh_modules/
|
||||
VoiceHub-hmos/oh_modules/
|
||||
VoiceHub-hmos/oh-package-lock.json5
|
||||
app/drizzle/
|
||||
**/build/
|
||||
**/.preview/
|
||||
VoiceHub-hmos/
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": false,
|
||||
"singleQuote": true,
|
||||
"tabWidth": 2,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
.git
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
README.md
|
||||
@@ -0,0 +1,76 @@
|
||||
# VoiceHub Code of Conduct
|
||||
|
||||
## Our Commitment
|
||||
|
||||
VoiceHub is an open source, community-driven campus broadcasting and song request system.
|
||||
|
||||
We are committed to providing a friendly, respectful, and harassment-free environment for everyone who participates in this project — including users, contributors, maintainers, and community members — regardless of age, disability, ethnicity, gender identity and expression, level of experience, nationality, personal appearance, race, religion, or sexual orientation.
|
||||
|
||||
We aim to foster an open, inclusive, and constructive community where collaboration and learning are encouraged.
|
||||
|
||||
---
|
||||
|
||||
## Expected Behavior
|
||||
|
||||
We encourage all participants to:
|
||||
|
||||
- Be respectful and considerate in discussions, reviews, and feedback
|
||||
- Communicate thoughtfully, especially when opinions differ
|
||||
- Welcome newcomers and help them learn
|
||||
- Provide constructive criticism focused on improving the project
|
||||
- Take responsibility for mistakes and be willing to correct them
|
||||
- Respect the campus-oriented and community-serving nature of this project
|
||||
|
||||
---
|
||||
|
||||
## Unacceptable Behavior
|
||||
|
||||
The following behaviors are not tolerated within the VoiceHub community:
|
||||
|
||||
- Harassment, discrimination, or exclusion of any kind
|
||||
- Insulting, demeaning, or hostile language
|
||||
- Personal attacks, threats, or intimidation
|
||||
- Sexualized language, imagery, or unwanted attention
|
||||
- Publishing others’ private information without explicit permission
|
||||
- Deliberate disruption of discussions, development, or community activities
|
||||
- Any behavior that would be considered inappropriate in an educational or professional environment
|
||||
|
||||
---
|
||||
|
||||
## Scope
|
||||
|
||||
This Code of Conduct applies to all VoiceHub-related spaces, including but not limited to:
|
||||
|
||||
- GitHub repositories, issues, pull requests, and discussions
|
||||
- Official communication channels and community platforms
|
||||
- Public spaces where individuals represent VoiceHub, including events, documentation, or social media
|
||||
|
||||
---
|
||||
|
||||
## Enforcement
|
||||
|
||||
If you experience or witness behavior that violates this Code of Conduct, please report it to the project maintainers.
|
||||
|
||||
All reports will be reviewed fairly and promptly. Maintainers will handle reports with discretion and respect the privacy of all parties involved.
|
||||
|
||||
Project maintainers have the responsibility and authority to take appropriate action in response to violations.
|
||||
|
||||
---
|
||||
|
||||
## Enforcement Actions
|
||||
|
||||
Depending on the severity and frequency of the behavior, maintainers may take one or more of the following actions:
|
||||
|
||||
1. **Private Reminder** – A private message explaining the issue and expected behavior.
|
||||
2. **Formal Warning** – A clear warning with expectations for future conduct.
|
||||
3. **Temporary Restriction** – Temporary limitation or suspension from community interaction.
|
||||
4. **Permanent Ban** – Permanent removal from participation in the project or community spaces.
|
||||
|
||||
Actions will be taken in a manner proportional to the impact on the community.
|
||||
|
||||
---
|
||||
|
||||
## Attribution
|
||||
|
||||
This Code of Conduct is inspired by the Contributor Covenant, version 2.1.
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct
|
||||
@@ -0,0 +1,76 @@
|
||||
# VoiceHub 行为准则
|
||||
|
||||
## 我们的承诺
|
||||
|
||||
VoiceHub 是一个面向校园与社区的开源广播与点歌系统项目。
|
||||
|
||||
我们致力于为所有参与本项目的人 —— 包括用户、贡献者、维护者以及社区成员 —— 提供一个友善、尊重、免于骚扰的环境,无论其年龄、残疾状况、族裔、性别认同与表达、经验水平、国籍、外表、宗教或性取向如何。
|
||||
|
||||
我们希望构建一个开放、包容、理性且富有建设性的社区氛围,鼓励协作与学习。
|
||||
|
||||
---
|
||||
|
||||
## 鼓励的行为
|
||||
|
||||
我们鼓励所有参与者:
|
||||
|
||||
- 在讨论、评审和反馈中保持尊重与克制
|
||||
- 在观点不一致时进行理性、善意的交流
|
||||
- 友好对待新参与者,帮助他们熟悉项目
|
||||
- 提供以改进项目为目标的建设性意见
|
||||
- 勇于承认并纠正错误
|
||||
- 尊重 VoiceHub 作为校园与社区服务项目的定位
|
||||
|
||||
---
|
||||
|
||||
## 不可接受的行为
|
||||
|
||||
在 VoiceHub 社区中,以下行为是不被允许的:
|
||||
|
||||
- 任何形式的骚扰、歧视或排斥行为
|
||||
- 侮辱性、贬损性或带有敌意的言辞
|
||||
- 人身攻击、威胁或恐吓
|
||||
- 带有性暗示的语言、图像或不受欢迎的关注
|
||||
- 未经许可公开他人的私人信息
|
||||
- 蓄意干扰社区讨论、开发流程或正常协作
|
||||
- 任何在教育或专业环境中被认为不恰当的行为
|
||||
|
||||
---
|
||||
|
||||
## 适用范围
|
||||
|
||||
本行为准则适用于所有与 VoiceHub 相关的空间,包括但不限于:
|
||||
|
||||
- GitHub 仓库、Issue、Pull Request 与讨论区
|
||||
- 官方社区交流渠道
|
||||
- 任何以 VoiceHub 名义进行的公开活动、文档或社交媒体发言
|
||||
|
||||
---
|
||||
|
||||
## 执行与举报
|
||||
|
||||
如果你遭遇或目睹了违反本行为准则的情况,请向项目维护者进行反馈。
|
||||
|
||||
所有举报都会被认真、公正地对待。维护者将尊重相关人员的隐私,并谨慎处理相关信息。
|
||||
|
||||
项目维护者有责任并有权对违规行为采取适当措施。
|
||||
|
||||
---
|
||||
|
||||
## 处理措施
|
||||
|
||||
根据违规行为的严重程度和影响,维护者可能采取以下一种或多种措施:
|
||||
|
||||
1. **私下提醒** —— 通过私下沟通说明问题并明确期望行为
|
||||
2. **正式警告** —— 明确指出违规行为及后续要求
|
||||
3. **临时限制** —— 暂时限制或暂停其参与社区互动
|
||||
4. **永久封禁** —— 永久取消其参与项目或社区的资格
|
||||
|
||||
所有处理措施都会遵循“与社区影响相匹配”的原则。
|
||||
|
||||
---
|
||||
|
||||
## 致谢
|
||||
|
||||
本行为准则参考并改编自 Contributor Covenant 2.1 版本:
|
||||
https://www.contributor-covenant.org/version/2/1/code_of_conduct
|
||||
@@ -0,0 +1,77 @@
|
||||
# 支持: linux/amd64, linux/arm64, linux/arm/v7
|
||||
|
||||
# ==========================================
|
||||
# 第一阶段:构建阶段
|
||||
# ==========================================
|
||||
# 预定义各架构的构建镜像
|
||||
FROM node:24-alpine AS builder-amd64
|
||||
FROM node:24-alpine AS builder-arm64
|
||||
FROM arm32v7/node:22-alpine AS builder-arm
|
||||
# FROM s390x/node:24-alpine AS builder-s390x
|
||||
# FROM ppc64le/node:24-slim AS builder-ppc64le
|
||||
|
||||
# 根据 TARGETARCH 选择对应的构建镜像
|
||||
FROM builder-${TARGETARCH} AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# 复制依赖文件和 scripts 目录
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY scripts ./scripts
|
||||
|
||||
# 安装所有依赖
|
||||
RUN set -eux; \
|
||||
corepack enable; \
|
||||
pnpm config set fetch-retries 5; \
|
||||
pnpm config set fetch-retry-mintimeout 20000; \
|
||||
pnpm config set fetch-retry-maxtimeout 120000; \
|
||||
pnpm install --frozen-lockfile || ( \
|
||||
rm -rf node_modules; \
|
||||
pnpm install --no-frozen-lockfile || ( \
|
||||
pnpm config set registry https://registry.npmmirror.com; \
|
||||
pnpm install --no-frozen-lockfile \
|
||||
) \
|
||||
)
|
||||
|
||||
# 复制所有源代码
|
||||
COPY . .
|
||||
|
||||
# 构建应用
|
||||
RUN pnpm run build
|
||||
|
||||
# ==========================================
|
||||
# 第二阶段:运行阶段
|
||||
# ==========================================
|
||||
# 预定义各架构的运行时镜像
|
||||
FROM node:24-alpine AS runtime-amd64
|
||||
FROM node:24-alpine AS runtime-arm64
|
||||
FROM arm32v7/node:22-alpine AS runtime-arm
|
||||
# FROM s390x/node:24-alpine AS runtime-s390x
|
||||
# FROM ppc64le/node:24-slim AS runtime-ppc64le
|
||||
|
||||
# 根据 TARGETARCH 选择对应的运行时镜像
|
||||
FROM runtime-${TARGETARCH} AS runtime
|
||||
|
||||
USER root
|
||||
WORKDIR /app
|
||||
|
||||
# 从构建阶段复制必要文件
|
||||
COPY --from=builder /app/package.json ./
|
||||
COPY --from=builder /app/pnpm-lock.yaml ./
|
||||
COPY --from=builder /app/drizzle.config.ts ./
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/.output ./.output
|
||||
COPY --from=builder /app/app/drizzle ./app/drizzle
|
||||
COPY --from=builder /app/scripts ./scripts
|
||||
|
||||
# 环境变量配置
|
||||
ENV NODE_ENV=production \
|
||||
PORT=3000 \
|
||||
NPM_CONFIG_UPDATE_NOTIFIER=false \
|
||||
PREBUILT=true
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE $PORT
|
||||
|
||||
# 启动命令:先执行数据库迁移,再启动应用
|
||||
CMD ["sh", "-c", "node scripts/deploy.js && node .output/server/index.mjs"]
|
||||
@@ -0,0 +1,674 @@
|
||||
GNU GENERAL PUBLIC LICENSE
|
||||
Version 3, 29 June 2007
|
||||
|
||||
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||
Everyone is permitted to copy and distribute verbatim copies
|
||||
of this license document, but changing it is not allowed.
|
||||
|
||||
Preamble
|
||||
|
||||
The GNU General Public License is a free, copyleft license for
|
||||
software and other kinds of works.
|
||||
|
||||
The licenses for most software and other practical works are designed
|
||||
to take away your freedom to share and change the works. By contrast,
|
||||
the GNU General Public License is intended to guarantee your freedom to
|
||||
share and change all versions of a program--to make sure it remains free
|
||||
software for all its users. We, the Free Software Foundation, use the
|
||||
GNU General Public License for most of our software; it applies also to
|
||||
any other work released this way by its authors. You can apply it to
|
||||
your programs, too.
|
||||
|
||||
When we speak of free software, we are referring to freedom, not
|
||||
price. Our General Public Licenses are designed to make sure that you
|
||||
have the freedom to distribute copies of free software (and charge for
|
||||
them if you wish), that you receive source code or can get it if you
|
||||
want it, that you can change the software or use pieces of it in new
|
||||
free programs, and that you know you can do these things.
|
||||
|
||||
To protect your rights, we need to prevent others from denying you
|
||||
these rights or asking you to surrender the rights. Therefore, you have
|
||||
certain responsibilities if you distribute copies of the software, or if
|
||||
you modify it: responsibilities to respect the freedom of others.
|
||||
|
||||
For example, if you distribute copies of such a program, whether
|
||||
gratis or for a fee, you must pass on to the recipients the same
|
||||
freedoms that you received. You must make sure that they, too, receive
|
||||
or can get the source code. And you must show them these terms so they
|
||||
know their rights.
|
||||
|
||||
Developers that use the GNU GPL protect your rights with two steps:
|
||||
(1) assert copyright on the software, and (2) offer you this License
|
||||
giving you legal permission to copy, distribute and/or modify it.
|
||||
|
||||
For the developers' and authors' protection, the GPL clearly explains
|
||||
that there is no warranty for this free software. For both users' and
|
||||
authors' sake, the GPL requires that modified versions be marked as
|
||||
changed, so that their problems will not be attributed erroneously to
|
||||
authors of previous versions.
|
||||
|
||||
Some devices are designed to deny users access to install or run
|
||||
modified versions of the software inside them, although the manufacturer
|
||||
can do so. This is fundamentally incompatible with the aim of
|
||||
protecting users' freedom to change the software. The systematic
|
||||
pattern of such abuse occurs in the area of products for individuals to
|
||||
use, which is precisely where it is most unacceptable. Therefore, we
|
||||
have designed this version of the GPL to prohibit the practice for those
|
||||
products. If such problems arise substantially in other domains, we
|
||||
stand ready to extend this provision to those domains in future versions
|
||||
of the GPL, as needed to protect the freedom of users.
|
||||
|
||||
Finally, every program is threatened constantly by software patents.
|
||||
States should not allow patents to restrict development and use of
|
||||
software on general-purpose computers, but in those that do, we wish to
|
||||
avoid the special danger that patents applied to a free program could
|
||||
make it effectively proprietary. To prevent this, the GPL assures that
|
||||
patents cannot be used to render the program non-free.
|
||||
|
||||
The precise terms and conditions for copying, distribution and
|
||||
modification follow.
|
||||
|
||||
TERMS AND CONDITIONS
|
||||
|
||||
0. Definitions.
|
||||
|
||||
"This License" refers to version 3 of the GNU General Public License.
|
||||
|
||||
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||
works, such as semiconductor masks.
|
||||
|
||||
"The Program" refers to any copyrightable work licensed under this
|
||||
License. Each licensee is addressed as "you". "Licensees" and
|
||||
"recipients" may be individuals or organizations.
|
||||
|
||||
To "modify" a work means to copy from or adapt all or part of the work
|
||||
in a fashion requiring copyright permission, other than the making of an
|
||||
exact copy. The resulting work is called a "modified version" of the
|
||||
earlier work or a work "based on" the earlier work.
|
||||
|
||||
A "covered work" means either the unmodified Program or a work based
|
||||
on the Program.
|
||||
|
||||
To "propagate" a work means to do anything with it that, without
|
||||
permission, would make you directly or secondarily liable for
|
||||
infringement under applicable copyright law, except executing it on a
|
||||
computer or modifying a private copy. Propagation includes copying,
|
||||
distribution (with or without modification), making available to the
|
||||
public, and in some countries other activities as well.
|
||||
|
||||
To "convey" a work means any kind of propagation that enables other
|
||||
parties to make or receive copies. Mere interaction with a user through
|
||||
a computer network, with no transfer of a copy, is not conveying.
|
||||
|
||||
An interactive user interface displays "Appropriate Legal Notices"
|
||||
to the extent that it includes a convenient and prominently visible
|
||||
feature that (1) displays an appropriate copyright notice, and (2)
|
||||
tells the user that there is no warranty for the work (except to the
|
||||
extent that warranties are provided), that licensees may convey the
|
||||
work under this License, and how to view a copy of this License. If
|
||||
the interface presents a list of user commands or options, such as a
|
||||
menu, a prominent item in the list meets this criterion.
|
||||
|
||||
1. Source Code.
|
||||
|
||||
The "source code" for a work means the preferred form of the work
|
||||
for making modifications to it. "Object code" means any non-source
|
||||
form of a work.
|
||||
|
||||
A "Standard Interface" means an interface that either is an official
|
||||
standard defined by a recognized standards body, or, in the case of
|
||||
interfaces specified for a particular programming language, one that
|
||||
is widely used among developers working in that language.
|
||||
|
||||
The "System Libraries" of an executable work include anything, other
|
||||
than the work as a whole, that (a) is included in the normal form of
|
||||
packaging a Major Component, but which is not part of that Major
|
||||
Component, and (b) serves only to enable use of the work with that
|
||||
Major Component, or to implement a Standard Interface for which an
|
||||
implementation is available to the public in source code form. A
|
||||
"Major Component", in this context, means a major essential component
|
||||
(kernel, window system, and so on) of the specific operating system
|
||||
(if any) on which the executable work runs, or a compiler used to
|
||||
produce the work, or an object code interpreter used to run it.
|
||||
|
||||
The "Corresponding Source" for a work in object code form means all
|
||||
the source code needed to generate, install, and (for an executable
|
||||
work) run the object code and to modify the work, including scripts to
|
||||
control those activities. However, it does not include the work's
|
||||
System Libraries, or general-purpose tools or generally available free
|
||||
programs which are used unmodified in performing those activities but
|
||||
which are not part of the work. For example, Corresponding Source
|
||||
includes interface definition files associated with source files for
|
||||
the work, and the source code for shared libraries and dynamically
|
||||
linked subprograms that the work is specifically designed to require,
|
||||
such as by intimate data communication or control flow between those
|
||||
subprograms and other parts of the work.
|
||||
|
||||
The Corresponding Source need not include anything that users
|
||||
can regenerate automatically from other parts of the Corresponding
|
||||
Source.
|
||||
|
||||
The Corresponding Source for a work in source code form is that
|
||||
same work.
|
||||
|
||||
2. Basic Permissions.
|
||||
|
||||
All rights granted under this License are granted for the term of
|
||||
copyright on the Program, and are irrevocable provided the stated
|
||||
conditions are met. This License explicitly affirms your unlimited
|
||||
permission to run the unmodified Program. The output from running a
|
||||
covered work is covered by this License only if the output, given its
|
||||
content, constitutes a covered work. This License acknowledges your
|
||||
rights of fair use or other equivalent, as provided by copyright law.
|
||||
|
||||
You may make, run and propagate covered works that you do not
|
||||
convey, without conditions so long as your license otherwise remains
|
||||
in force. You may convey covered works to others for the sole purpose
|
||||
of having them make modifications exclusively for you, or provide you
|
||||
with facilities for running those works, provided that you comply with
|
||||
the terms of this License in conveying all material for which you do
|
||||
not control copyright. Those thus making or running the covered works
|
||||
for you must do so exclusively on your behalf, under your direction
|
||||
and control, on terms that prohibit them from making any copies of
|
||||
your copyrighted material outside their relationship with you.
|
||||
|
||||
Conveying under any other circumstances is permitted solely under
|
||||
the conditions stated below. Sublicensing is not allowed; section 10
|
||||
makes it unnecessary.
|
||||
|
||||
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||
|
||||
No covered work shall be deemed part of an effective technological
|
||||
measure under any applicable law fulfilling obligations under article
|
||||
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||
similar laws prohibiting or restricting circumvention of such
|
||||
measures.
|
||||
|
||||
When you convey a covered work, you waive any legal power to forbid
|
||||
circumvention of technological measures to the extent such circumvention
|
||||
is effected by exercising rights under this License with respect to
|
||||
the covered work, and you disclaim any intention to limit operation or
|
||||
modification of the work as a means of enforcing, against the work's
|
||||
users, your or third parties' legal rights to forbid circumvention of
|
||||
technological measures.
|
||||
|
||||
4. Conveying Verbatim Copies.
|
||||
|
||||
You may convey verbatim copies of the Program's source code as you
|
||||
receive it, in any medium, provided that you conspicuously and
|
||||
appropriately publish on each copy an appropriate copyright notice;
|
||||
keep intact all notices stating that this License and any
|
||||
non-permissive terms added in accord with section 7 apply to the code;
|
||||
keep intact all notices of the absence of any warranty; and give all
|
||||
recipients a copy of this License along with the Program.
|
||||
|
||||
You may charge any price or no price for each copy that you convey,
|
||||
and you may offer support or warranty protection for a fee.
|
||||
|
||||
5. Conveying Modified Source Versions.
|
||||
|
||||
You may convey a work based on the Program, or the modifications to
|
||||
produce it from the Program, in the form of source code under the
|
||||
terms of section 4, provided that you also meet all of these conditions:
|
||||
|
||||
a) The work must carry prominent notices stating that you modified
|
||||
it, and giving a relevant date.
|
||||
|
||||
b) The work must carry prominent notices stating that it is
|
||||
released under this License and any conditions added under section
|
||||
7. This requirement modifies the requirement in section 4 to
|
||||
"keep intact all notices".
|
||||
|
||||
c) You must license the entire work, as a whole, under this
|
||||
License to anyone who comes into possession of a copy. This
|
||||
License will therefore apply, along with any applicable section 7
|
||||
additional terms, to the whole of the work, and all its parts,
|
||||
regardless of how they are packaged. This License gives no
|
||||
permission to license the work in any other way, but it does not
|
||||
invalidate such permission if you have separately received it.
|
||||
|
||||
d) If the work has interactive user interfaces, each must display
|
||||
Appropriate Legal Notices; however, if the Program has interactive
|
||||
interfaces that do not display Appropriate Legal Notices, your
|
||||
work need not make them do so.
|
||||
|
||||
A compilation of a covered work with other separate and independent
|
||||
works, which are not by their nature extensions of the covered work,
|
||||
and which are not combined with it such as to form a larger program,
|
||||
in or on a volume of a storage or distribution medium, is called an
|
||||
"aggregate" if the compilation and its resulting copyright are not
|
||||
used to limit the access or legal rights of the compilation's users
|
||||
beyond what the individual works permit. Inclusion of a covered work
|
||||
in an aggregate does not cause this License to apply to the other
|
||||
parts of the aggregate.
|
||||
|
||||
6. Conveying Non-Source Forms.
|
||||
|
||||
You may convey a covered work in object code form under the terms
|
||||
of sections 4 and 5, provided that you also convey the
|
||||
machine-readable Corresponding Source under the terms of this License,
|
||||
in one of these ways:
|
||||
|
||||
a) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by the
|
||||
Corresponding Source fixed on a durable physical medium
|
||||
customarily used for software interchange.
|
||||
|
||||
b) Convey the object code in, or embodied in, a physical product
|
||||
(including a physical distribution medium), accompanied by a
|
||||
written offer, valid for at least three years and valid for as
|
||||
long as you offer spare parts or customer support for that product
|
||||
model, to give anyone who possesses the object code either (1) a
|
||||
copy of the Corresponding Source for all the software in the
|
||||
product that is covered by this License, on a durable physical
|
||||
medium customarily used for software interchange, for a price no
|
||||
more than your reasonable cost of physically performing this
|
||||
conveying of source, or (2) access to copy the
|
||||
Corresponding Source from a network server at no charge.
|
||||
|
||||
c) Convey individual copies of the object code with a copy of the
|
||||
written offer to provide the Corresponding Source. This
|
||||
alternative is allowed only occasionally and noncommercially, and
|
||||
only if you received the object code with such an offer, in accord
|
||||
with subsection 6b.
|
||||
|
||||
d) Convey the object code by offering access from a designated
|
||||
place (gratis or for a charge), and offer equivalent access to the
|
||||
Corresponding Source in the same way through the same place at no
|
||||
further charge. You need not require recipients to copy the
|
||||
Corresponding Source along with the object code. If the place to
|
||||
copy the object code is a network server, the Corresponding Source
|
||||
may be on a different server (operated by you or a third party)
|
||||
that supports equivalent copying facilities, provided you maintain
|
||||
clear directions next to the object code saying where to find the
|
||||
Corresponding Source. Regardless of what server hosts the
|
||||
Corresponding Source, you remain obligated to ensure that it is
|
||||
available for as long as needed to satisfy these requirements.
|
||||
|
||||
e) Convey the object code using peer-to-peer transmission, provided
|
||||
you inform other peers where the object code and Corresponding
|
||||
Source of the work are being offered to the general public at no
|
||||
charge under subsection 6d.
|
||||
|
||||
A separable portion of the object code, whose source code is excluded
|
||||
from the Corresponding Source as a System Library, need not be
|
||||
included in conveying the object code work.
|
||||
|
||||
A "User Product" is either (1) a "consumer product", which means any
|
||||
tangible personal property which is normally used for personal, family,
|
||||
or household purposes, or (2) anything designed or sold for incorporation
|
||||
into a dwelling. In determining whether a product is a consumer product,
|
||||
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||
product received by a particular user, "normally used" refers to a
|
||||
typical or common use of that class of product, regardless of the status
|
||||
of the particular user or of the way in which the particular user
|
||||
actually uses, or expects or is expected to use, the product. A product
|
||||
is a consumer product regardless of whether the product has substantial
|
||||
commercial, industrial or non-consumer uses, unless such uses represent
|
||||
the only significant mode of use of the product.
|
||||
|
||||
"Installation Information" for a User Product means any methods,
|
||||
procedures, authorization keys, or other information required to install
|
||||
and execute modified versions of a covered work in that User Product from
|
||||
a modified version of its Corresponding Source. The information must
|
||||
suffice to ensure that the continued functioning of the modified object
|
||||
code is in no case prevented or interfered with solely because
|
||||
modification has been made.
|
||||
|
||||
If you convey an object code work under this section in, or with, or
|
||||
specifically for use in, a User Product, and the conveying occurs as
|
||||
part of a transaction in which the right of possession and use of the
|
||||
User Product is transferred to the recipient in perpetuity or for a
|
||||
fixed term (regardless of how the transaction is characterized), the
|
||||
Corresponding Source conveyed under this section must be accompanied
|
||||
by the Installation Information. But this requirement does not apply
|
||||
if neither you nor any third party retains the ability to install
|
||||
modified object code on the User Product (for example, the work has
|
||||
been installed in ROM).
|
||||
|
||||
The requirement to provide Installation Information does not include a
|
||||
requirement to continue to provide support service, warranty, or updates
|
||||
for a work that has been modified or installed by the recipient, or for
|
||||
the User Product in which it has been modified or installed. Access to a
|
||||
network may be denied when the modification itself materially and
|
||||
adversely affects the operation of the network or violates the rules and
|
||||
protocols for communication across the network.
|
||||
|
||||
Corresponding Source conveyed, and Installation Information provided,
|
||||
in accord with this section must be in a format that is publicly
|
||||
documented (and with an implementation available to the public in
|
||||
source code form), and must require no special password or key for
|
||||
unpacking, reading or copying.
|
||||
|
||||
7. Additional Terms.
|
||||
|
||||
"Additional permissions" are terms that supplement the terms of this
|
||||
License by making exceptions from one or more of its conditions.
|
||||
Additional permissions that are applicable to the entire Program shall
|
||||
be treated as though they were included in this License, to the extent
|
||||
that they are valid under applicable law. If additional permissions
|
||||
apply only to part of the Program, that part may be used separately
|
||||
under those permissions, but the entire Program remains governed by
|
||||
this License without regard to the additional permissions.
|
||||
|
||||
When you convey a copy of a covered work, you may at your option
|
||||
remove any additional permissions from that copy, or from any part of
|
||||
it. (Additional permissions may be written to require their own
|
||||
removal in certain cases when you modify the work.) You may place
|
||||
additional permissions on material, added by you to a covered work,
|
||||
for which you have or can give appropriate copyright permission.
|
||||
|
||||
Notwithstanding any other provision of this License, for material you
|
||||
add to a covered work, you may (if authorized by the copyright holders of
|
||||
that material) supplement the terms of this License with terms:
|
||||
|
||||
a) Disclaiming warranty or limiting liability differently from the
|
||||
terms of sections 15 and 16 of this License; or
|
||||
|
||||
b) Requiring preservation of specified reasonable legal notices or
|
||||
author attributions in that material or in the Appropriate Legal
|
||||
Notices displayed by works containing it; or
|
||||
|
||||
c) Prohibiting misrepresentation of the origin of that material, or
|
||||
requiring that modified versions of such material be marked in
|
||||
reasonable ways as different from the original version; or
|
||||
|
||||
d) Limiting the use for publicity purposes of names of licensors or
|
||||
authors of the material; or
|
||||
|
||||
e) Declining to grant rights under trademark law for use of some
|
||||
trade names, trademarks, or service marks; or
|
||||
|
||||
f) Requiring indemnification of licensors and authors of that
|
||||
material by anyone who conveys the material (or modified versions of
|
||||
it) with contractual assumptions of liability to the recipient, for
|
||||
any liability that these contractual assumptions directly impose on
|
||||
those licensors and authors.
|
||||
|
||||
All other non-permissive additional terms are considered "further
|
||||
restrictions" within the meaning of section 10. If the Program as you
|
||||
received it, or any part of it, contains a notice stating that it is
|
||||
governed by this License along with a term that is a further
|
||||
restriction, you may remove that term. If a license document contains
|
||||
a further restriction but permits relicensing or conveying under this
|
||||
License, you may add to a covered work material governed by the terms
|
||||
of that license document, provided that the further restriction does
|
||||
not survive such relicensing or conveying.
|
||||
|
||||
If you add terms to a covered work in accord with this section, you
|
||||
must place, in the relevant source files, a statement of the
|
||||
additional terms that apply to those files, or a notice indicating
|
||||
where to find the applicable terms.
|
||||
|
||||
Additional terms, permissive or non-permissive, may be stated in the
|
||||
form of a separately written license, or stated as exceptions;
|
||||
the above requirements apply either way.
|
||||
|
||||
8. Termination.
|
||||
|
||||
You may not propagate or modify a covered work except as expressly
|
||||
provided under this License. Any attempt otherwise to propagate or
|
||||
modify it is void, and will automatically terminate your rights under
|
||||
this License (including any patent licenses granted under the third
|
||||
paragraph of section 11).
|
||||
|
||||
However, if you cease all violation of this License, then your
|
||||
license from a particular copyright holder is reinstated (a)
|
||||
provisionally, unless and until the copyright holder explicitly and
|
||||
finally terminates your license, and (b) permanently, if the copyright
|
||||
holder fails to notify you of the violation by some reasonable means
|
||||
prior to 60 days after the cessation.
|
||||
|
||||
Moreover, your license from a particular copyright holder is
|
||||
reinstated permanently if the copyright holder notifies you of the
|
||||
violation by some reasonable means, this is the first time you have
|
||||
received notice of violation of this License (for any work) from that
|
||||
copyright holder, and you cure the violation prior to 30 days after
|
||||
your receipt of the notice.
|
||||
|
||||
Termination of your rights under this section does not terminate the
|
||||
licenses of parties who have received copies or rights from you under
|
||||
this License. If your rights have been terminated and not permanently
|
||||
reinstated, you do not qualify to receive new licenses for the same
|
||||
material under section 10.
|
||||
|
||||
9. Acceptance Not Required for Having Copies.
|
||||
|
||||
You are not required to accept this License in order to receive or
|
||||
run a copy of the Program. Ancillary propagation of a covered work
|
||||
occurring solely as a consequence of using peer-to-peer transmission
|
||||
to receive a copy likewise does not require acceptance. However,
|
||||
nothing other than this License grants you permission to propagate or
|
||||
modify any covered work. These actions infringe copyright if you do
|
||||
not accept this License. Therefore, by modifying or propagating a
|
||||
covered work, you indicate your acceptance of this License to do so.
|
||||
|
||||
10. Automatic Licensing of Downstream Recipients.
|
||||
|
||||
Each time you convey a covered work, the recipient automatically
|
||||
receives a license from the original licensors, to run, modify and
|
||||
propagate that work, subject to this License. You are not responsible
|
||||
for enforcing compliance by third parties with this License.
|
||||
|
||||
An "entity transaction" is a transaction transferring control of an
|
||||
organization, or substantially all assets of one, or subdividing an
|
||||
organization, or merging organizations. If propagation of a covered
|
||||
work results from an entity transaction, each party to that
|
||||
transaction who receives a copy of the work also receives whatever
|
||||
licenses to the work the party's predecessor in interest had or could
|
||||
give under the previous paragraph, plus a right to possession of the
|
||||
Corresponding Source of the work from the predecessor in interest, if
|
||||
the predecessor has it or can get it with reasonable efforts.
|
||||
|
||||
You may not impose any further restrictions on the exercise of the
|
||||
rights granted or affirmed under this License. For example, you may
|
||||
not impose a license fee, royalty, or other charge for exercise of
|
||||
rights granted under this License, and you may not initiate litigation
|
||||
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||
any patent claim is infringed by making, using, selling, offering for
|
||||
sale, or importing the Program or any portion of it.
|
||||
|
||||
11. Patents.
|
||||
|
||||
A "contributor" is a copyright holder who authorizes use under this
|
||||
License of the Program or a work on which the Program is based. The
|
||||
work thus licensed is called the contributor's "contributor version".
|
||||
|
||||
A contributor's "essential patent claims" are all patent claims
|
||||
owned or controlled by the contributor, whether already acquired or
|
||||
hereafter acquired, that would be infringed by some manner, permitted
|
||||
by this License, of making, using, or selling its contributor version,
|
||||
but do not include claims that would be infringed only as a
|
||||
consequence of further modification of the contributor version. For
|
||||
purposes of this definition, "control" includes the right to grant
|
||||
patent sublicenses in a manner consistent with the requirements of
|
||||
this License.
|
||||
|
||||
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||
patent license under the contributor's essential patent claims, to
|
||||
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||
propagate the contents of its contributor version.
|
||||
|
||||
In the following three paragraphs, a "patent license" is any express
|
||||
agreement or commitment, however denominated, not to enforce a patent
|
||||
(such as an express permission to practice a patent or covenant not to
|
||||
sue for patent infringement). To "grant" such a patent license to a
|
||||
party means to make such an agreement or commitment not to enforce a
|
||||
patent against the party.
|
||||
|
||||
If you convey a covered work, knowingly relying on a patent license,
|
||||
and the Corresponding Source of the work is not available for anyone
|
||||
to copy, free of charge and under the terms of this License, through a
|
||||
publicly available network server or other readily accessible means,
|
||||
then you must either (1) cause the Corresponding Source to be so
|
||||
available, or (2) arrange to deprive yourself of the benefit of the
|
||||
patent license for this particular work, or (3) arrange, in a manner
|
||||
consistent with the requirements of this License, to extend the patent
|
||||
license to downstream recipients. "Knowingly relying" means you have
|
||||
actual knowledge that, but for the patent license, your conveying the
|
||||
covered work in a country, or your recipient's use of the covered work
|
||||
in a country, would infringe one or more identifiable patents in that
|
||||
country that you have reason to believe are valid.
|
||||
|
||||
If, pursuant to or in connection with a single transaction or
|
||||
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||
covered work, and grant a patent license to some of the parties
|
||||
receiving the covered work authorizing them to use, propagate, modify
|
||||
or convey a specific copy of the covered work, then the patent license
|
||||
you grant is automatically extended to all recipients of the covered
|
||||
work and works based on it.
|
||||
|
||||
A patent license is "discriminatory" if it does not include within
|
||||
the scope of its coverage, prohibits the exercise of, or is
|
||||
conditioned on the non-exercise of one or more of the rights that are
|
||||
specifically granted under this License. You may not convey a covered
|
||||
work if you are a party to an arrangement with a third party that is
|
||||
in the business of distributing software, under which you make payment
|
||||
to the third party based on the extent of your activity of conveying
|
||||
the work, and under which the third party grants, to any of the
|
||||
parties who would receive the covered work from you, a discriminatory
|
||||
patent license (a) in connection with copies of the covered work
|
||||
conveyed by you (or copies made from those copies), or (b) primarily
|
||||
for and in connection with specific products or compilations that
|
||||
contain the covered work, unless you entered into that arrangement,
|
||||
or that patent license was granted, prior to 28 March 2007.
|
||||
|
||||
Nothing in this License shall be construed as excluding or limiting
|
||||
any implied license or other defenses to infringement that may
|
||||
otherwise be available to you under applicable patent law.
|
||||
|
||||
12. No Surrender of Others' Freedom.
|
||||
|
||||
If conditions are imposed on you (whether by court order, agreement or
|
||||
otherwise) that contradict the conditions of this License, they do not
|
||||
excuse you from the conditions of this License. If you cannot convey a
|
||||
covered work so as to satisfy simultaneously your obligations under this
|
||||
License and any other pertinent obligations, then as a consequence you may
|
||||
not convey it at all. For example, if you agree to terms that obligate you
|
||||
to collect a royalty for further conveying from those to whom you convey
|
||||
the Program, the only way you could satisfy both those terms and this
|
||||
License would be to refrain entirely from conveying the Program.
|
||||
|
||||
13. Use with the GNU Affero General Public License.
|
||||
|
||||
Notwithstanding any other provision of this License, you have
|
||||
permission to link or combine any covered work with a work licensed
|
||||
under version 3 of the GNU Affero General Public License into a single
|
||||
combined work, and to convey the resulting work. The terms of this
|
||||
License will continue to apply to the part which is the covered work,
|
||||
but the special requirements of the GNU Affero General Public License,
|
||||
section 13, concerning interaction through a network will apply to the
|
||||
combination as such.
|
||||
|
||||
14. Revised Versions of this License.
|
||||
|
||||
The Free Software Foundation may publish revised and/or new versions of
|
||||
the GNU General Public License from time to time. Such new versions will
|
||||
be similar in spirit to the present version, but may differ in detail to
|
||||
address new problems or concerns.
|
||||
|
||||
Each version is given a distinguishing version number. If the
|
||||
Program specifies that a certain numbered version of the GNU General
|
||||
Public License "or any later version" applies to it, you have the
|
||||
option of following the terms and conditions either of that numbered
|
||||
version or of any later version published by the Free Software
|
||||
Foundation. If the Program does not specify a version number of the
|
||||
GNU General Public License, you may choose any version ever published
|
||||
by the Free Software Foundation.
|
||||
|
||||
If the Program specifies that a proxy can decide which future
|
||||
versions of the GNU General Public License can be used, that proxy's
|
||||
public statement of acceptance of a version permanently authorizes you
|
||||
to choose that version for the Program.
|
||||
|
||||
Later license versions may give you additional or different
|
||||
permissions. However, no additional obligations are imposed on any
|
||||
author or copyright holder as a result of your choosing to follow a
|
||||
later version.
|
||||
|
||||
15. Disclaimer of Warranty.
|
||||
|
||||
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||
|
||||
16. Limitation of Liability.
|
||||
|
||||
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||
SUCH DAMAGES.
|
||||
|
||||
17. Interpretation of Sections 15 and 16.
|
||||
|
||||
If the disclaimer of warranty and limitation of liability provided
|
||||
above cannot be given local legal effect according to their terms,
|
||||
reviewing courts shall apply local law that most closely approximates
|
||||
an absolute waiver of all civil liability in connection with the
|
||||
Program, unless a warranty or assumption of liability accompanies a
|
||||
copy of the Program in return for a fee.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
How to Apply These Terms to Your New Programs
|
||||
|
||||
If you develop a new program, and you want it to be of the greatest
|
||||
possible use to the public, the best way to achieve this is to make it
|
||||
free software which everyone can redistribute and change under these terms.
|
||||
|
||||
To do so, attach the following notices to the program. It is safest
|
||||
to attach them to the start of each source file to most effectively
|
||||
state the exclusion of warranty; and each file should have at least
|
||||
the "copyright" line and a pointer to where the full notice is found.
|
||||
|
||||
<one line to give the program's name and a brief idea of what it does.>
|
||||
Copyright (C) <year> <name of author>
|
||||
|
||||
This program is free software: you can redistribute it and/or modify
|
||||
it under the terms of the GNU General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
This program is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU General Public License for more details.
|
||||
|
||||
You should have received a copy of the GNU General Public License
|
||||
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||
|
||||
Also add information on how to contact you by electronic and paper mail.
|
||||
|
||||
If the program does terminal interaction, make it output a short
|
||||
notice like this when it starts in an interactive mode:
|
||||
|
||||
<program> Copyright (C) <year> <name of author>
|
||||
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
|
||||
This is free software, and you are welcome to redistribute it
|
||||
under certain conditions; type `show c' for details.
|
||||
|
||||
The hypothetical commands `show w' and `show c' should show the appropriate
|
||||
parts of the General Public License. Of course, your program's commands
|
||||
might be different; for a GUI interface, you would use an "about box".
|
||||
|
||||
You should also get your employer (if you work as a programmer) or school,
|
||||
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||
For more information on this, and how to apply and follow the GNU GPL, see
|
||||
<https://www.gnu.org/licenses/>.
|
||||
|
||||
The GNU General Public License does not permit incorporating your program
|
||||
into proprietary programs. If your program is a subroutine library, you
|
||||
may consider it more useful to permit linking proprietary applications with
|
||||
the library. If this is what you want to do, use the GNU Lesser General
|
||||
Public License instead of this License. But first, please read
|
||||
<https://www.gnu.org/licenses/why-not-lgpl.html>.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,206 @@
|
||||
# VoiceHub 升级指南
|
||||
|
||||
本文档介绍如何将 VoiceHub 项目从旧版本升级到最新版本。请根据您的部署方式选择相应的升级步骤。
|
||||
|
||||
> ⚠️ **升级前请务必备份数据库和配置文件!**
|
||||
|
||||
## 1. 备份数据
|
||||
|
||||
无论使用何种部署方式,升级前请先备份数据库。
|
||||
|
||||
### 1.1 使用管理后台备份(推荐)
|
||||
|
||||
1. 登录管理员后台
|
||||
2. 进入"数据库管理"页面
|
||||
3. 点击"创建备份" -> 选择"完整备份"
|
||||
4. 待备份完成后,下载备份文件到本地保存
|
||||
|
||||
### 1.2 使用命令行备份
|
||||
|
||||
也可以使用 PostgreSQL 命令行工具:
|
||||
|
||||
```bash
|
||||
# Docker 部署用户
|
||||
docker exec voicehub-postgres-1 pg_dump -U user voicehub > backup_$(date +%Y%m%d).sql
|
||||
|
||||
# 源码部署用户
|
||||
pg_dump -h localhost -U your_username -d voicehub > backup_$(date +%Y%m%d).sql
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. Docker 部署升级
|
||||
|
||||
### 2.1 使用 Docker Compose(推荐)
|
||||
|
||||
如果您使用 `docker-compose.yml` 部署:
|
||||
|
||||
1. **拉取最新代码**
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
2. **重建并重启服务**
|
||||
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
> **注意**:由于默认配置使用本地构建,单纯使用 `pull` 命令无效。必须添加 `--build` 参数以确保应用代码变更。
|
||||
|
||||
3. **验证升级**
|
||||
```bash
|
||||
docker-compose logs -f voicehub
|
||||
```
|
||||
查看日志确认没有报错,且显示 "部署完成"。
|
||||
|
||||
### 2.2 使用 Docker Run 命令
|
||||
|
||||
如果您直接使用 `docker run` 命令:
|
||||
|
||||
1. **停止并删除旧容器**
|
||||
|
||||
```bash
|
||||
docker stop voicehub
|
||||
docker rm voicehub
|
||||
```
|
||||
|
||||
2. **拉取最新镜像**
|
||||
|
||||
```bash
|
||||
docker pull ghcr.io/laoshuikaixue/voicehub:latest
|
||||
# 或
|
||||
docker pull ghcr.nju.edu.cn/laoshuikaixue/voicehub:latest
|
||||
```
|
||||
|
||||
3. **启动新容器**
|
||||
使用与之前相同的环境变量启动:
|
||||
```bash
|
||||
docker run -d \
|
||||
-p 3000:3000 \
|
||||
-e DATABASE_URL="..." \
|
||||
-e JWT_SECRET="..." \
|
||||
--name voicehub \
|
||||
ghcr.io/laoshuikaixue/voicehub:latest
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 源码/本地部署升级
|
||||
|
||||
### 3.1 停止运行中的服务
|
||||
|
||||
```bash
|
||||
# 如果使用 PM2
|
||||
pm2 stop voicehub
|
||||
|
||||
# 如果使用 systemd
|
||||
sudo systemctl stop voicehub
|
||||
|
||||
# 如果直接运行(Ctrl+C 停止进程)
|
||||
```
|
||||
|
||||
### 3.2 获取最新代码
|
||||
|
||||
```bash
|
||||
# 拉取最新代码
|
||||
git pull origin main
|
||||
|
||||
# 或指定分支
|
||||
# git pull origin <branch-name>
|
||||
```
|
||||
|
||||
### 3.3 更新依赖
|
||||
|
||||
```bash
|
||||
# 安装最新依赖
|
||||
pnpm install --frozen-lockfile
|
||||
```
|
||||
|
||||
### 3.4 数据库迁移
|
||||
|
||||
**重要**:新版本通常包含数据库结构变更,必须执行迁移。
|
||||
|
||||
```bash
|
||||
# 方式 1:使用自动同步脚本(推荐)
|
||||
node scripts/db-sync.js
|
||||
|
||||
# 方式 2:手动执行迁移
|
||||
pnpm run db:migrate
|
||||
|
||||
# 方式 3:如果迁移失败,且确信可以覆盖(仅开发环境)
|
||||
# pnpm run db:push
|
||||
```
|
||||
|
||||
**迁移说明**:
|
||||
|
||||
- `db-sync.js` 脚本会自动检测数据库状态并选择合适的迁移方式
|
||||
- 空数据库会执行完整迁移
|
||||
- 已有数据的数据库会使用 push 方式同步结构
|
||||
|
||||
### 3.5 重新构建应用
|
||||
|
||||
```bash
|
||||
# 清理旧的构建文件(可选)
|
||||
rm -rf .nuxt .output
|
||||
|
||||
# 构建生产版本
|
||||
pnpm run build
|
||||
```
|
||||
|
||||
构建完成后,会在 `.output` 目录生成生产环境文件。
|
||||
|
||||
### 3.6 重启服务
|
||||
|
||||
根据您的部署方式选择:
|
||||
|
||||
#### 使用 PM2(推荐)
|
||||
|
||||
```bash
|
||||
# 重启服务
|
||||
pm2 restart voicehub
|
||||
|
||||
# 查看日志
|
||||
pm2 logs voicehub
|
||||
|
||||
# 查看状态
|
||||
pm2 status
|
||||
```
|
||||
|
||||
如果是首次使用 PM2:
|
||||
|
||||
```bash
|
||||
# 安装 PM2
|
||||
pnpm add -g pm2
|
||||
|
||||
# 启动应用
|
||||
pm2 start pnpm --name voicehub -- run start
|
||||
|
||||
# 设置开机自启
|
||||
pm2 startup
|
||||
pm2 save
|
||||
```
|
||||
|
||||
#### 使用 systemd
|
||||
|
||||
```bash
|
||||
# 重启服务
|
||||
sudo systemctl restart voicehub
|
||||
|
||||
# 查看状态
|
||||
sudo systemctl status voicehub
|
||||
|
||||
# 查看日志
|
||||
sudo journalctl -u voicehub -f
|
||||
```
|
||||
|
||||
#### 直接运行
|
||||
|
||||
```bash
|
||||
# 生产模式启动
|
||||
pnpm run start
|
||||
|
||||
# 或使用 nohup 后台运行
|
||||
nohup pnpm run start > voicehub.log 2>&1 &
|
||||
```
|
||||
@@ -0,0 +1,228 @@
|
||||
<template>
|
||||
<div class="app" data-theme="dark" data-color-scheme="custom">
|
||||
<!-- 全局通知容器组件 -->
|
||||
<LazyUINotificationContainer ref="notificationContainer" />
|
||||
|
||||
<!-- 全局音频播放器 - 使用isPlayerVisible控制显示/隐藏 -->
|
||||
<LazyUIAudioPlayer
|
||||
v-show="isPlayerVisible"
|
||||
:song="currentSong"
|
||||
:is-playlist-mode="isPlaylistMode"
|
||||
@close="handlePlayerClose"
|
||||
@ended="handlePlayerEnded"
|
||||
/>
|
||||
|
||||
<main class="main-content">
|
||||
<div :key="route.path" :data-route="pageViewRouteName" class="page-view">
|
||||
<NuxtPage />
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
// 导入通知容器组件和音频播放器
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useRoute } from 'vue-router'
|
||||
|
||||
// 获取运行时配置
|
||||
const config = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const pageViewRouteName = computed(() => {
|
||||
if (typeof route.name === 'string' && route.name) {
|
||||
return route.name
|
||||
}
|
||||
|
||||
return route.path.split('/').filter(Boolean).join('-') || 'index'
|
||||
})
|
||||
|
||||
// 通知容器引用
|
||||
const notificationContainer = ref(null)
|
||||
|
||||
// 音频播放器
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const currentSong = ref(null)
|
||||
const isPlayerVisible = ref(false) // 控制播放器显示/隐藏
|
||||
|
||||
// 判断是否为播放列表模式
|
||||
// 投稿页面、搜索预览等场景不是播放列表模式,不应该自动跳过
|
||||
const isPlaylistMode = computed(() => {
|
||||
const currentPath = route.path
|
||||
// 投稿页面不是播放列表模式
|
||||
if (currentPath.includes('/request') || currentPath.includes('/submit')) {
|
||||
return false
|
||||
}
|
||||
// 其他页面默认为播放列表模式(如歌曲列表、播出排期等)
|
||||
return true
|
||||
})
|
||||
|
||||
// 监听路由变化,控制播放器显示/隐藏
|
||||
watch(
|
||||
() => route.path,
|
||||
(newPath) => {
|
||||
// 其他页面,如果有当前歌曲则显示播放器
|
||||
if (currentSong.value) {
|
||||
isPlayerVisible.value = true
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 监听当前播放的歌曲
|
||||
watch(
|
||||
() => audioPlayer.getCurrentSong().value,
|
||||
(newSong) => {
|
||||
if (newSong) {
|
||||
currentSong.value = newSong
|
||||
// 显示播放器
|
||||
isPlayerVisible.value = true
|
||||
} else {
|
||||
// 当没有歌曲时,不立即隐藏播放器,而是让动画完成
|
||||
currentSong.value = null
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 处理播放器关闭事件
|
||||
const handlePlayerClose = () => {
|
||||
// 延迟隐藏播放器,与AudioPlayer组件的动画时长同步
|
||||
setTimeout(() => {
|
||||
isPlayerVisible.value = false
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 处理播放结束事件
|
||||
const handlePlayerEnded = () => {
|
||||
// 不在这里处理播放结束逻辑
|
||||
// 播放结束的逻辑已经在 useAudioPlayerControl.ts 的 onEnded 中处理
|
||||
// 包括单曲循环、列表播放等模式
|
||||
// 只有在播放模式为 'off' 时才需要隐藏播放器
|
||||
const audioPlayerControl = useAudioPlayerControl()
|
||||
if (audioPlayerControl.playMode.value === 'off') {
|
||||
// 播放完成后停止播放器
|
||||
audioPlayer.stopSong()
|
||||
// 延迟隐藏播放器
|
||||
setTimeout(() => {
|
||||
isPlayerVisible.value = false
|
||||
}, 500)
|
||||
}
|
||||
}
|
||||
|
||||
// 使用onMounted确保只在客户端初始化认证
|
||||
let auth = null
|
||||
let isAuthenticated = false
|
||||
|
||||
// 设置鸿蒙系统控制事件监听
|
||||
const setupHarmonyOSListeners = () => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
// 监听鸿蒙系统控制事件
|
||||
const handleHarmonyOSPlay = () => {
|
||||
const currentGlobalSong = audioPlayer.getCurrentSong().value
|
||||
if (currentGlobalSong) {
|
||||
// 如果有当前歌曲,恢复播放
|
||||
audioPlayer.playSong(currentGlobalSong)
|
||||
}
|
||||
}
|
||||
|
||||
const handleHarmonyOSPause = () => {
|
||||
audioPlayer.pauseSong()
|
||||
}
|
||||
|
||||
const handleHarmonyOSStop = () => {
|
||||
audioPlayer.stopSong()
|
||||
}
|
||||
|
||||
const handleHarmonyOSNext = async () => {
|
||||
try {
|
||||
const success = await audioPlayer.playNext()
|
||||
if (!success) {
|
||||
console.log('没有下一首歌曲或切换失败,继续播放当前歌曲')
|
||||
// 如果切换失败,不做任何操作,继续播放当前歌曲
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换下一首歌曲失败:', error)
|
||||
// 切换失败时不停止播放,继续播放当前歌曲
|
||||
}
|
||||
}
|
||||
|
||||
const handleHarmonyOSPrevious = async () => {
|
||||
try {
|
||||
const success = await audioPlayer.playPrevious()
|
||||
if (!success) {
|
||||
console.log('没有上一首歌曲或切换失败,继续播放当前歌曲')
|
||||
// 如果切换失败,不做任何操作,继续播放当前歌曲
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('切换上一首歌曲失败:', error)
|
||||
// 切换失败时不停止播放,继续播放当前歌曲
|
||||
}
|
||||
}
|
||||
|
||||
// 使用Nuxt的事件总线监听鸿蒙控制事件
|
||||
if (window.__NUXT__ && window.__NUXT__.$nuxt) {
|
||||
const nuxtApp = window.__NUXT__.$nuxt
|
||||
if (nuxtApp.$on) {
|
||||
nuxtApp.$on('harmonyos-play', handleHarmonyOSPlay)
|
||||
nuxtApp.$on('harmonyos-pause', handleHarmonyOSPause)
|
||||
nuxtApp.$on('harmonyos-stop', handleHarmonyOSStop)
|
||||
nuxtApp.$on('harmonyos-next', handleHarmonyOSNext)
|
||||
nuxtApp.$on('harmonyos-previous', handleHarmonyOSPrevious)
|
||||
}
|
||||
}
|
||||
|
||||
// 备用方案:直接在window上监听自定义事件
|
||||
window.addEventListener('harmonyos-play', handleHarmonyOSPlay)
|
||||
window.addEventListener('harmonyos-pause', handleHarmonyOSPause)
|
||||
window.addEventListener('harmonyos-stop', handleHarmonyOSStop)
|
||||
window.addEventListener('harmonyos-next', handleHarmonyOSNext)
|
||||
window.addEventListener('harmonyos-previous', handleHarmonyOSPrevious)
|
||||
}
|
||||
|
||||
// 在组件挂载后初始化认证(只会在客户端执行)
|
||||
onMounted(async () => {
|
||||
auth = useAuth()
|
||||
isAuthenticated = auth.isAuthenticated.value
|
||||
|
||||
// 初始化鸿蒙系统控制事件监听
|
||||
setupHarmonyOSListeners()
|
||||
})
|
||||
|
||||
// 使用计算属性确保安全地访问auth对象
|
||||
const safeIsAuthenticated = computed(() => auth?.isAuthenticated?.value || false)
|
||||
|
||||
const handleLogout = () => {
|
||||
if (auth) {
|
||||
auth.logout()
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style>
|
||||
/* 应用布局 */
|
||||
.app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* 主要内容 */
|
||||
.main-content {
|
||||
flex: 1;
|
||||
padding: 0;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* 响应式调整 */
|
||||
@media (max-width: 768px) {
|
||||
.main-content {
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,410 @@
|
||||
/* 通用组件样式 - 统一设计系统 */
|
||||
|
||||
/* 按钮组件 */
|
||||
.btn-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: var(--font-medium);
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.btn-base:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.btn-base:disabled {
|
||||
background-color: var(--btn-disabled-bg);
|
||||
color: var(--btn-disabled-text);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.btn-base svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* 按钮变体 */
|
||||
.btn-primary {
|
||||
background-color: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-text);
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
background-color: var(--btn-primary-hover);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--btn-secondary-bg);
|
||||
color: var(--btn-secondary-text);
|
||||
border: 1px solid var(--btn-secondary-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover:not(:disabled) {
|
||||
background-color: var(--btn-secondary-hover);
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--btn-success-bg);
|
||||
color: var(--btn-success-text);
|
||||
}
|
||||
|
||||
.btn-success:hover:not(:disabled) {
|
||||
background-color: var(--btn-success-hover);
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--btn-warning-bg);
|
||||
color: var(--btn-warning-text);
|
||||
}
|
||||
|
||||
.btn-warning:hover:not(:disabled) {
|
||||
background-color: var(--btn-warning-hover);
|
||||
}
|
||||
|
||||
.btn-error {
|
||||
background-color: var(--btn-error-bg);
|
||||
color: var(--btn-error-text);
|
||||
}
|
||||
|
||||
.btn-error:hover:not(:disabled) {
|
||||
background-color: var(--btn-error-hover);
|
||||
}
|
||||
|
||||
/* 按钮尺寸 */
|
||||
.btn-sm {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
font-size: var(--text-xs);
|
||||
}
|
||||
|
||||
.btn-lg {
|
||||
padding: var(--spacing-lg) var(--spacing-2xl);
|
||||
font-size: var(--text-base);
|
||||
}
|
||||
|
||||
/* 输入框组件 */
|
||||
.input-base {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background-color: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.5;
|
||||
transition:
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-base:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.input-base::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
}
|
||||
|
||||
.input-base:disabled {
|
||||
background-color: var(--bg-quaternary);
|
||||
color: var(--text-disabled);
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* 卡片组件 */
|
||||
.card-base {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-2xl);
|
||||
box-shadow: var(--card-shadow);
|
||||
transition:
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.card-base:hover {
|
||||
border-color: var(--card-hover-border);
|
||||
}
|
||||
|
||||
.card-interactive {
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform var(--transition-normal),
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.card-interactive:hover {
|
||||
transform: translateY(-2px);
|
||||
border-color: var(--card-hover-border);
|
||||
box-shadow: var(--shadow-lg);
|
||||
}
|
||||
|
||||
/* 模态框组件 */
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: var(--modal-backdrop);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: var(--z-modal-backdrop);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--modal-bg);
|
||||
border: 1px solid var(--modal-border);
|
||||
border-radius: var(--radius-xl);
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: var(--shadow-xl);
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-xl) var(--spacing-2xl);
|
||||
border-bottom: 1px solid var(--modal-header-border);
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-primary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-xl) var(--spacing-2xl);
|
||||
border-top: 1px solid var(--modal-footer-border);
|
||||
}
|
||||
|
||||
/* 表格组件 */
|
||||
.table-container {
|
||||
background: var(--card-bg);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.table-header {
|
||||
background: var(--table-header-bg);
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
.table-row {
|
||||
border-bottom: 1px solid var(--table-border);
|
||||
padding: var(--spacing-lg) var(--spacing-xl);
|
||||
transition: background-color var(--transition-normal);
|
||||
}
|
||||
|
||||
.table-row:hover {
|
||||
background-color: var(--table-row-hover);
|
||||
}
|
||||
|
||||
.table-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.table-row.selected {
|
||||
background-color: var(--table-selected);
|
||||
}
|
||||
|
||||
/* 状态徽章 */
|
||||
.badge-base {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
font-weight: var(--font-medium);
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: var(--status-approved-light);
|
||||
color: var(--status-approved);
|
||||
border: 1px solid var(--success-border);
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: var(--status-pending-light);
|
||||
color: var(--status-pending);
|
||||
border: 1px solid var(--warning-border);
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: var(--error-light);
|
||||
color: var(--error);
|
||||
border: 1px solid var(--error-border);
|
||||
}
|
||||
|
||||
.badge-info {
|
||||
background-color: var(--info-light);
|
||||
color: var(--info);
|
||||
border: 1px solid var(--info-border);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid var(--spinner-secondary);
|
||||
border-top: 3px solid var(--spinner-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-width: 2px;
|
||||
}
|
||||
|
||||
.spinner-lg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border-width: 4px;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 工具提示 */
|
||||
.tooltip {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.tooltip-content {
|
||||
position: absolute;
|
||||
bottom: 100%;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
background: var(--bg-quaternary);
|
||||
color: var(--text-primary);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--text-xs);
|
||||
white-space: nowrap;
|
||||
z-index: var(--z-tooltip);
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
transition:
|
||||
opacity var(--transition-normal),
|
||||
visibility var(--transition-normal);
|
||||
}
|
||||
|
||||
.tooltip:hover .tooltip-content {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
}
|
||||
|
||||
/* 分页组件 */
|
||||
.pagination {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.pagination-btn {
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--btn-secondary-bg);
|
||||
border: 1px solid var(--btn-secondary-border);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--btn-secondary-text);
|
||||
font-size: var(--text-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
}
|
||||
|
||||
.pagination-btn:hover:not(:disabled) {
|
||||
background: var(--btn-secondary-hover);
|
||||
}
|
||||
|
||||
.pagination-btn:disabled {
|
||||
background: var(--btn-disabled-bg);
|
||||
color: var(--btn-disabled-text);
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.pagination-info {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
margin: 0 var(--spacing-lg);
|
||||
}
|
||||
|
||||
/* 空状态 */
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-3xl) var(--spacing-xl);
|
||||
text-align: center;
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
color: var(--text-disabled);
|
||||
}
|
||||
|
||||
.empty-state-title {
|
||||
font-size: var(--text-lg);
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.empty-state-description {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--text-tertiary);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
@@ -0,0 +1,197 @@
|
||||
/*
|
||||
给各个内部元素使用的样式
|
||||
*/
|
||||
|
||||
/* 给每行歌词应用的样式 */
|
||||
.lyricLine {
|
||||
position: absolute;
|
||||
backface-visibility: hidden;
|
||||
transform-origin: left;
|
||||
width: var(--amll-lp-width, 100%);
|
||||
min-width: var(--amll-lp-width, 100%);
|
||||
max-width: var(--amll-lp-width, 100%);
|
||||
width: 100%;
|
||||
height: fit-content;
|
||||
padding: 0.5em 1em;
|
||||
contain: content;
|
||||
will-change: transform;
|
||||
transition:
|
||||
opacity 0.25s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
box-sizing: border-box;
|
||||
border-radius: 0.25em;
|
||||
|
||||
padding-left: 1em;
|
||||
padding-right: 1em;
|
||||
|
||||
@media screen and (max-width: 500px) {
|
||||
padding-left: 20px;
|
||||
padding-right: 20px;
|
||||
}
|
||||
|
||||
&.dirty {
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
&:has(> *):hover {
|
||||
background-color: var(--amll-lp-hover-bg-color, #fff1);
|
||||
/* box-shadow: 0 0 0 4px var(--amll-lp-hover-bg-color, #fff1); */
|
||||
}
|
||||
|
||||
&:has(> *):active {
|
||||
background-color: var(--amll-lp-hover-bg-color, #ffffff05);
|
||||
/* box-shadow: 0 0 0 var(--amll-lp-hover-bg-color, #fff1); */
|
||||
}
|
||||
}
|
||||
|
||||
.lyricBgLine {
|
||||
opacity: 0.0001;
|
||||
font-size: max(calc(1em * var(--amll-lp-bg-line-scale, 0.7)), 10px);
|
||||
transition:
|
||||
opacity 0.25s,
|
||||
scale 0.5s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
/* 因为字体大小缩小了,故内边距要和主行字体大小统一,行边距计算公式为 100% / font-size 转 em 单位 */
|
||||
padding: 1vh calc(var(--amll-lp-line-padding-x, 1em) / var(--amll-lp-bg-line-scale, 0.7));
|
||||
|
||||
&.active {
|
||||
transition:
|
||||
opacity 0.5s 0.25s,
|
||||
scale 1.5s cubic-bezier(0, 1, 0, 1) 0.25s,
|
||||
filter 0.2s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
:global(.amll-lyric-player) {
|
||||
&:hover .lyricLine {
|
||||
filter: unset !important;
|
||||
}
|
||||
|
||||
&.hasDuetLine {
|
||||
.lyricLine:not(.lyricDuetLine) {
|
||||
padding-right: 15%;
|
||||
}
|
||||
|
||||
.lyricDuetLine {
|
||||
padding-left: 15%;
|
||||
}
|
||||
}
|
||||
|
||||
&:not(:global(.playing)) > .lyricBgLine {
|
||||
opacity: 0.4;
|
||||
}
|
||||
}
|
||||
|
||||
.lyricDuetLine {
|
||||
text-align: right;
|
||||
transform-origin: right;
|
||||
}
|
||||
|
||||
.lyricMainLine {
|
||||
transition: opacity 0.3s 0.1s;
|
||||
margin: -1em;
|
||||
padding: 1em;
|
||||
/* 为主歌词行增加文字阴影,以提升亮色背景下的可读性 */
|
||||
text-shadow:
|
||||
0 2px 6px rgba(0, 0, 0, 0.6),
|
||||
0 0 10px rgba(0, 0, 0, 0.5);
|
||||
|
||||
& span {
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.romanWord {
|
||||
font-size: 0.5em;
|
||||
line-height: 1em;
|
||||
}
|
||||
|
||||
& > span,
|
||||
span.emphasizeWrapper {
|
||||
white-space: pre-wrap;
|
||||
display: inline-block;
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
will-change: transform;
|
||||
|
||||
&.emphasize,
|
||||
span.emphasize {
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
backface-visibility: hidden;
|
||||
|
||||
& > span {
|
||||
padding: 1em;
|
||||
margin: -1em;
|
||||
will-change: transform;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyricSubLine {
|
||||
font-size: max(0.5em, 10px);
|
||||
line-height: 1.5em;
|
||||
transition: opacity 0.2s 0.25s;
|
||||
opacity: 0.3;
|
||||
/* 为副歌词行增加轻度文字阴影,避免白字贴白背景 */
|
||||
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
|
||||
.disableSpring > * {
|
||||
transition:
|
||||
filter 0.25s,
|
||||
transform 0.5s,
|
||||
background-color 0.25s,
|
||||
box-shadow 0.25s;
|
||||
}
|
||||
|
||||
.interludeDots {
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
transition: opacity 0.25s;
|
||||
transform-origin: center;
|
||||
width: fit-content;
|
||||
padding: 2.5% 0.75em;
|
||||
position: absolute;
|
||||
display: flex;
|
||||
gap: 0.25em;
|
||||
left: 0;
|
||||
opacity: 0;
|
||||
|
||||
&.enabled {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
& > * {
|
||||
width: clamp(0.5em, 1vh, 3em);
|
||||
height: clamp(0.5em, 1vh, 3em);
|
||||
display: inline-block;
|
||||
border-radius: 50%;
|
||||
aspect-ratio: 1 / 1;
|
||||
background-color: var(--amll-lp-color, white);
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
&.duet {
|
||||
right: 0;
|
||||
transform-origin: center;
|
||||
}
|
||||
}
|
||||
|
||||
@supports (mix-blend-mode: plus-lighter) {
|
||||
.lyricSubLine {
|
||||
opacity: 0.3;
|
||||
}
|
||||
}
|
||||
|
||||
.tmpDisableTransition {
|
||||
transition: none !important;
|
||||
}
|
||||
@@ -0,0 +1,474 @@
|
||||
/* 导入浏览器主题保护样式 */
|
||||
@import url('./theme-protection.css');
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* 防止浏览器自动应用深色主题 */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* 流体背景与全局样式 */
|
||||
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
/* 字体显示策略优化 */
|
||||
@font-face {
|
||||
font-family: 'MiSans';
|
||||
font-display: swap; /* 确保字体加载期间文本可见 */
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'MiSans',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Open Sans',
|
||||
sans-serif;
|
||||
color: var(--text-primary);
|
||||
background-color: var(--bg-primary);
|
||||
line-height: 1.6;
|
||||
overflow-x: hidden;
|
||||
font-size: var(--text-base);
|
||||
/* 字体渲染优化 */
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
text-rendering: optimizeLegibility;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family:
|
||||
'MiSans',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Oxygen,
|
||||
Ubuntu,
|
||||
Cantarell,
|
||||
'Open Sans',
|
||||
sans-serif;
|
||||
}
|
||||
|
||||
/* 链接样式 */
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 简洁现代背景 */
|
||||
body {
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 可选的微妙纹理效果 */
|
||||
body::before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background:
|
||||
radial-gradient(circle at 20% 50%, rgba(120, 119, 198, 0.03) 0%, transparent 50%),
|
||||
radial-gradient(circle at 80% 20%, rgba(255, 255, 255, 0.01) 0%, transparent 50%),
|
||||
radial-gradient(circle at 40% 80%, rgba(120, 119, 198, 0.02) 0%, transparent 50%);
|
||||
z-index: -1;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* 容器 */
|
||||
.container {
|
||||
width: 100%;
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
/* 常用工具类 */
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.justify-center {
|
||||
justify-content: center;
|
||||
}
|
||||
.gap-2 {
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.gap-4 {
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
.gap-6 {
|
||||
gap: var(--spacing-2xl);
|
||||
}
|
||||
|
||||
.grid {
|
||||
display: grid;
|
||||
}
|
||||
.grid-cols-1 {
|
||||
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||
}
|
||||
.grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
/* 间距 */
|
||||
.mt-2 {
|
||||
margin-top: var(--spacing-sm);
|
||||
}
|
||||
.mt-4 {
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
.mt-6 {
|
||||
margin-top: var(--spacing-2xl);
|
||||
}
|
||||
.mt-8 {
|
||||
margin-top: var(--spacing-3xl);
|
||||
}
|
||||
.mb-2 {
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: var(--spacing-2xl);
|
||||
}
|
||||
.mb-8 {
|
||||
margin-bottom: var(--spacing-3xl);
|
||||
}
|
||||
.ml-2 {
|
||||
margin-left: var(--spacing-sm);
|
||||
}
|
||||
.ml-4 {
|
||||
margin-left: var(--spacing-lg);
|
||||
}
|
||||
.mr-2 {
|
||||
margin-right: var(--spacing-sm);
|
||||
}
|
||||
.mr-4 {
|
||||
margin-right: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.p-2 {
|
||||
padding: var(--spacing-sm);
|
||||
}
|
||||
.p-4 {
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
.p-6 {
|
||||
padding: var(--spacing-2xl);
|
||||
}
|
||||
.p-8 {
|
||||
padding: var(--spacing-3xl);
|
||||
}
|
||||
|
||||
/* 文本 */
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-sm {
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
.text-lg {
|
||||
font-size: var(--text-lg);
|
||||
}
|
||||
.text-xl {
|
||||
font-size: var(--text-xl);
|
||||
}
|
||||
.text-2xl {
|
||||
font-size: var(--text-2xl);
|
||||
}
|
||||
.text-3xl {
|
||||
font-size: var(--text-3xl);
|
||||
}
|
||||
.font-bold {
|
||||
font-weight: var(--font-bold);
|
||||
}
|
||||
.text-primary {
|
||||
color: var(--primary);
|
||||
}
|
||||
.text-secondary {
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
.text-tertiary {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
.text-success {
|
||||
color: var(--success);
|
||||
}
|
||||
.text-danger {
|
||||
color: var(--error);
|
||||
}
|
||||
.text-warning {
|
||||
color: var(--warning);
|
||||
}
|
||||
|
||||
/* 卡片 */
|
||||
.card {
|
||||
background: var(--card-bg);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid var(--card-border);
|
||||
border-radius: var(--radius-xl);
|
||||
padding: var(--spacing-2xl);
|
||||
box-shadow:
|
||||
0 4px 6px rgba(0, 0, 0, 0.1),
|
||||
0 1px 3px rgba(0, 0, 0, 0.08),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.05);
|
||||
transition: all var(--transition-normal);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.card::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 1px;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-4px);
|
||||
border-color: var(--card-hover-border);
|
||||
box-shadow:
|
||||
0 12px 24px rgba(0, 0, 0, 0.15),
|
||||
0 4px 8px rgba(0, 0, 0, 0.1),
|
||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* 按钮 */
|
||||
.btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-md) var(--spacing-2xl);
|
||||
font-weight: var(--font-semibold);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-normal);
|
||||
cursor: pointer;
|
||||
text-decoration: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
font-size: var(--text-sm);
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.btn::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: -100%;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.1), transparent);
|
||||
transition: left 0.5s;
|
||||
}
|
||||
|
||||
.btn:hover::before {
|
||||
left: 100%;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--btn-primary-bg) 0%, var(--primary-hover) 100%);
|
||||
color: var(--btn-primary-text);
|
||||
border: 1px solid var(--primary-border);
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
background: linear-gradient(135deg, var(--primary-hover) 0%, var(--primary) 100%);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background: linear-gradient(135deg, var(--btn-secondary-bg) 0%, var(--bg-quaternary) 100%);
|
||||
color: var(--btn-secondary-text);
|
||||
border: 1px solid var(--btn-secondary-border);
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: linear-gradient(135deg, var(--bg-quaternary) 0%, #4a4a4a 100%);
|
||||
color: var(--text-primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.btn-outline {
|
||||
background: transparent;
|
||||
border: 2px solid var(--border-tertiary);
|
||||
color: var(--text-primary);
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.btn-outline:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--primary);
|
||||
color: var(--primary);
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2);
|
||||
}
|
||||
|
||||
.btn:disabled {
|
||||
background: var(--btn-disabled-bg);
|
||||
color: var(--btn-disabled-text);
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.btn:disabled::before {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* 列表样式 */
|
||||
.list-disc {
|
||||
list-style-type: disc;
|
||||
list-style-position: inside;
|
||||
}
|
||||
|
||||
.list-none {
|
||||
list-style-type: none;
|
||||
}
|
||||
|
||||
.pl-5 {
|
||||
padding-left: 1.25rem;
|
||||
}
|
||||
|
||||
/* 表单 */
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: var(--input-bg);
|
||||
border: 2px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
outline: none;
|
||||
transition: all var(--transition-normal);
|
||||
font-size: var(--text-sm);
|
||||
position: relative;
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow:
|
||||
var(--input-shadow-focus),
|
||||
0 4px 12px rgba(102, 126, 234, 0.1);
|
||||
background: var(--bg-secondary);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.input:focus::placeholder {
|
||||
color: var(--text-quaternary);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-weight: var(--font-semibold);
|
||||
color: var(--text-secondary);
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.form-group:focus-within label {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
/* 响应式 */
|
||||
@media (min-width: 640px) {
|
||||
.sm\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.sm\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.sm\:items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.sm\:justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.md\:grid-cols-2 {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
.md\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-width: 1024px) {
|
||||
.lg\:grid-cols-3 {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
.lg\:flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
|
||||
/* 玻璃态效果 */
|
||||
.glass {
|
||||
background: var(--bg-secondary);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid var(--border-secondary);
|
||||
border-radius: var(--radius-xl);
|
||||
}
|
||||
|
||||
/* 手机设备上的优化 */
|
||||
@media (max-width: 640px) {
|
||||
.home {
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 2rem !important;
|
||||
}
|
||||
|
||||
.action-card {
|
||||
padding: 0.75rem !important;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 95% !important;
|
||||
max-width: none !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
/* 移动端管理界面优化样式 */
|
||||
|
||||
/* 确保打印时不受移动端样式影响 */
|
||||
@media print {
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
.schedule-item-print,
|
||||
.print-page,
|
||||
.schedule-content {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 通用移动端表单优化 */
|
||||
@media screen and (max-width: 768px) {
|
||||
/* 模态框移动端优化 */
|
||||
.modal-overlay {
|
||||
padding: 0;
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
border-radius: 16px 16px 0 0 !important;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 16px 20px !important;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: inherit;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 18px !important;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 20px !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 16px 20px !important;
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
background: inherit;
|
||||
border-top: 1px solid var(--border-secondary) !important;
|
||||
flex-direction: column !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
/* 表单元素移动端优化 */
|
||||
.form-group {
|
||||
margin-bottom: 20px !important;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 15px !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
padding: 14px 16px !important;
|
||||
font-size: 16px !important; /* 防止iOS缩放 */
|
||||
border-radius: 10px !important;
|
||||
min-height: 44px !important; /* 触摸友好的最小高度 */
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
min-height: 120px !important;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr !important;
|
||||
gap: 20px !important;
|
||||
}
|
||||
|
||||
/* 按钮移动端优化 */
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-danger,
|
||||
.btn-warning {
|
||||
padding: 14px 20px !important;
|
||||
font-size: 16px !important;
|
||||
min-height: 48px !important;
|
||||
border-radius: 10px !important;
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.modal-footer .btn-primary,
|
||||
.modal-footer .btn-secondary {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.modal-footer .btn-secondary {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
/* 搜索输入框优化 */
|
||||
.search-input {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 16px !important;
|
||||
border-radius: 10px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
/* 工具栏按钮优化 */
|
||||
.create-btn,
|
||||
.refresh-btn,
|
||||
.upload-btn {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 15px !important;
|
||||
min-height: 44px !important;
|
||||
border-radius: 10px !important;
|
||||
}
|
||||
|
||||
/* 操作按钮优化 */
|
||||
.action-btn {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 18px !important;
|
||||
height: 18px !important;
|
||||
}
|
||||
|
||||
/* 分页按钮优化 */
|
||||
.page-btn {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 15px !important;
|
||||
min-height: 44px !important;
|
||||
border-radius: 8px !important;
|
||||
}
|
||||
|
||||
/* 选择框优化 */
|
||||
.checkbox,
|
||||
.radio {
|
||||
width: 20px !important;
|
||||
height: 20px !important;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
/* 标签和徽章优化 */
|
||||
.role-badge,
|
||||
.status-badge,
|
||||
.permission-tag {
|
||||
padding: 6px 10px !important;
|
||||
font-size: 12px !important;
|
||||
border-radius: 6px !important;
|
||||
}
|
||||
|
||||
/* 错误消息优化 */
|
||||
.error-message {
|
||||
padding: 14px 16px !important;
|
||||
font-size: 15px !important;
|
||||
border-radius: 10px !important;
|
||||
margin-top: 12px !important;
|
||||
}
|
||||
|
||||
/* 加载状态优化 */
|
||||
.loading-overlay {
|
||||
border-radius: 12px !important;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
font-size: 15px !important;
|
||||
margin-top: 12px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 小屏幕设备进一步优化 */
|
||||
@media screen and (max-width: 480px) {
|
||||
.modal-content {
|
||||
border-radius: 12px 12px 0 0 !important;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
font-size: 16px !important;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px !important;
|
||||
height: 32px !important;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 16px !important;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 16px !important;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea {
|
||||
padding: 12px 14px !important;
|
||||
font-size: 16px !important;
|
||||
min-height: 40px !important;
|
||||
}
|
||||
|
||||
.btn-primary,
|
||||
.btn-secondary,
|
||||
.btn-danger,
|
||||
.btn-warning {
|
||||
padding: 12px 16px !important;
|
||||
font-size: 15px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
width: 36px !important;
|
||||
height: 36px !important;
|
||||
}
|
||||
|
||||
.action-btn svg {
|
||||
width: 16px !important;
|
||||
height: 16px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 触摸设备特殊优化 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* 增加触摸目标大小 */
|
||||
.nav-item,
|
||||
.action-btn,
|
||||
.page-btn,
|
||||
.close-btn {
|
||||
min-width: 44px !important;
|
||||
min-height: 44px !important;
|
||||
}
|
||||
|
||||
/* 移除悬停效果 */
|
||||
.nav-item:hover,
|
||||
.action-btn:hover,
|
||||
.page-btn:hover,
|
||||
.close-btn:hover {
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* 添加触摸反馈 */
|
||||
.nav-item:active,
|
||||
.action-btn:active,
|
||||
.page-btn:active,
|
||||
.close-btn:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
}
|
||||
|
||||
/* 横屏模式优化 */
|
||||
@media screen and (max-width: 768px) and (orientation: landscape) {
|
||||
.modal-content {
|
||||
max-height: 85vh !important;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
padding: 12px 20px !important;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 16px 20px !important;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 12px 20px !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 移动端专用交互优化 */
|
||||
@media screen and (max-width: 768px) {
|
||||
/* 滑动手势支持 */
|
||||
.sidebar {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
.activity-list,
|
||||
.modal-body {
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-behavior: smooth;
|
||||
}
|
||||
|
||||
/* 触摸反馈 */
|
||||
.nav-item,
|
||||
.action-btn,
|
||||
.page-btn,
|
||||
.close-btn,
|
||||
.mobile-menu-btn,
|
||||
.user-card,
|
||||
.data-card,
|
||||
.stat-card {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* 防止双击缩放 */
|
||||
.nav-item,
|
||||
.action-btn,
|
||||
.page-btn,
|
||||
.close-btn,
|
||||
.mobile-menu-btn {
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* 长按菜单禁用 */
|
||||
.action-btn,
|
||||
.page-btn,
|
||||
.close-btn {
|
||||
-webkit-touch-callout: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
/* 拖拽优化 */
|
||||
.user-card,
|
||||
.data-card,
|
||||
.role-card {
|
||||
touch-action: pan-y;
|
||||
}
|
||||
|
||||
/* 输入框优化 */
|
||||
.form-input,
|
||||
.form-select,
|
||||
.form-textarea,
|
||||
.search-input {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
/* IOS特定优化 */
|
||||
.form-input[type='text'],
|
||||
.form-input[type='email'],
|
||||
.form-input[type='password'],
|
||||
.search-input {
|
||||
-webkit-text-size-adjust: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* 触摸设备特殊交互 */
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
/* 卡片点击效果 */
|
||||
.user-card:active,
|
||||
.data-card:active,
|
||||
.role-card:active,
|
||||
.stat-card:active {
|
||||
transform: scale(0.98);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* 按钮点击效果 */
|
||||
.nav-item:active,
|
||||
.action-btn:active,
|
||||
.page-btn:active,
|
||||
.close-btn:active,
|
||||
.mobile-menu-btn:active {
|
||||
transform: scale(0.95);
|
||||
transition: transform 0.1s ease;
|
||||
}
|
||||
|
||||
/* 移除悬停效果 */
|
||||
.nav-item:hover,
|
||||
.action-btn:hover,
|
||||
.page-btn:hover,
|
||||
.close-btn:hover,
|
||||
.user-card:hover,
|
||||
.data-card:hover,
|
||||
.role-card:hover,
|
||||
.stat-card:hover {
|
||||
transform: none !important;
|
||||
box-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 焦点样式优化 */
|
||||
.form-input:focus,
|
||||
.form-select:focus,
|
||||
.form-textarea:focus,
|
||||
.search-input:focus {
|
||||
transform: none;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.2) !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 下拉刷新支持 */
|
||||
@media screen and (max-width: 768px) {
|
||||
.overview-dashboard,
|
||||
.user-manager,
|
||||
.role-manager {
|
||||
overscroll-behavior-y: contain;
|
||||
}
|
||||
}
|
||||
|
||||
/* 安全区域适配 */
|
||||
@supports (padding: max(0px)) {
|
||||
@media screen and (max-width: 768px) {
|
||||
.mobile-header {
|
||||
padding-left: max(16px, env(safe-area-inset-left));
|
||||
padding-right: max(16px, env(safe-area-inset-right));
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
padding-left: max(0px, env(safe-area-inset-left));
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
margin-bottom: max(0px, env(safe-area-inset-bottom));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
/* PDF导出宽度样式 */
|
||||
|
||||
@media print {
|
||||
/* 重置所有可能影响宽度的样式 */
|
||||
* {
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* 强制覆盖移动端样式 */
|
||||
.schedule-item-print .item-content {
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .sequence-number {
|
||||
align-self: center !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .cover-section {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .song-info {
|
||||
width: auto !important;
|
||||
flex: 1 !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .requester-info,
|
||||
.schedule-item-print .votes-info,
|
||||
.schedule-item-print .playtime-info {
|
||||
width: auto !important;
|
||||
justify-content: flex-end !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
/* 确保页面容器宽度正确 */
|
||||
html,
|
||||
body {
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
}
|
||||
|
||||
/* 打印页面容器 - 固定合理宽度 */
|
||||
.print-page {
|
||||
width: 210mm !important;
|
||||
max-width: 210mm !important;
|
||||
min-width: auto !important;
|
||||
box-sizing: border-box !important;
|
||||
margin: 0 auto !important;
|
||||
padding: 20mm !important;
|
||||
background: white !important;
|
||||
color: black !important;
|
||||
}
|
||||
|
||||
/* 排期内容容器 */
|
||||
.schedule-content {
|
||||
width: 100% !important;
|
||||
max-width: 170mm !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* 排期项目 */
|
||||
.schedule-item-print {
|
||||
width: 100% !important;
|
||||
max-width: 170mm !important;
|
||||
box-sizing: border-box !important;
|
||||
margin-bottom: 8px !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* 排期项目内容 */
|
||||
.schedule-item-print .item-content {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
width: 100% !important;
|
||||
gap: 12px !important;
|
||||
padding: 8px !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
/* 序号 */
|
||||
.schedule-item-print .sequence-number {
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
flex-shrink: 0 !important;
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
justify-content: center !important;
|
||||
background: #f0f0f0 !important;
|
||||
border-radius: 50% !important;
|
||||
font-weight: bold !important;
|
||||
font-size: 14px !important;
|
||||
}
|
||||
|
||||
/* 封面 */
|
||||
.schedule-item-print .cover-section {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .song-cover,
|
||||
.schedule-item-print .cover-placeholder {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
/* 歌曲信息 */
|
||||
.schedule-item-print .song-info {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
margin-right: 12px !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .song-title {
|
||||
font-size: 16px !important;
|
||||
font-weight: 600 !important;
|
||||
color: #000 !important;
|
||||
margin-bottom: 2px !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .song-artist {
|
||||
font-size: 14px !important;
|
||||
color: #333 !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
/* 附加信息 */
|
||||
.schedule-item-print .requester-info,
|
||||
.schedule-item-print .votes-info,
|
||||
.schedule-item-print .playtime-info {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
font-size: 12px !important;
|
||||
color: #666 !important;
|
||||
white-space: nowrap !important;
|
||||
flex-shrink: 0 !important;
|
||||
margin-left: 8px !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .label {
|
||||
font-weight: 500 !important;
|
||||
margin-right: 4px !important;
|
||||
}
|
||||
|
||||
.schedule-item-print .value {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
/* 页面头部和尾部 */
|
||||
.page-header,
|
||||
.page-footer {
|
||||
width: 100% !important;
|
||||
box-sizing: border-box !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
/* 学校Logo */
|
||||
.school-logo-print {
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.logo {
|
||||
object-fit: contain !important;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
text-align: center !important;
|
||||
margin-bottom: 20px !important;
|
||||
padding-bottom: 10px !important;
|
||||
border-bottom: 2px solid #000 !important;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
text-align: center !important;
|
||||
margin-top: 20px !important;
|
||||
padding-top: 10px !important;
|
||||
border-top: 1px solid #ccc !important;
|
||||
font-size: 12px !important;
|
||||
color: #666 !important;
|
||||
}
|
||||
|
||||
/* 隐藏不需要打印的元素 */
|
||||
.settings-panel,
|
||||
.preview-header,
|
||||
.mobile-header,
|
||||
.sidebar,
|
||||
.mobile-overlay,
|
||||
.back-to-top-btn {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* 确保主内容区域正确显示 */
|
||||
.main-content {
|
||||
margin-left: 0 !important;
|
||||
padding: 0 !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* 移除所有阴影和边框效果 */
|
||||
* {
|
||||
box-shadow: none !important;
|
||||
text-shadow: none !important;
|
||||
}
|
||||
|
||||
/* 确保文字颜色正确 */
|
||||
* {
|
||||
color: inherit !important;
|
||||
}
|
||||
|
||||
/* 页面分页控制 */
|
||||
.print-page {
|
||||
page-break-after: always !important;
|
||||
}
|
||||
|
||||
.print-page:last-child {
|
||||
page-break-after: auto !important;
|
||||
}
|
||||
|
||||
/* 防止内容被截断 */
|
||||
.schedule-item-print {
|
||||
break-inside: avoid !important;
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* 优化分页效果 */
|
||||
.page-header {
|
||||
page-break-after: avoid !important;
|
||||
}
|
||||
|
||||
.page-footer {
|
||||
page-break-before: avoid !important;
|
||||
}
|
||||
|
||||
/* 确保歌曲信息完整显示 */
|
||||
.schedule-item-print .item-content {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* 防止封面图片被截断 */
|
||||
.schedule-item-print .cover-section {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* 防止歌曲标题和艺术家信息被截断 */
|
||||
.schedule-item-print .song-info {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
|
||||
/* 防止附加信息被截断 */
|
||||
.schedule-item-print .requester-info,
|
||||
.schedule-item-print .votes-info,
|
||||
.schedule-item-print .playtime-info {
|
||||
page-break-inside: avoid !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 专门针对PDF导出的样式 */
|
||||
@page {
|
||||
margin: 20mm;
|
||||
size: A4;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/* SF Pro 字体导入 */
|
||||
@import url('https://cdn.jsdelivr.net/npm/sf-font@1.0.0/stylesheet.min.css');
|
||||
|
||||
/* SF Symbols 图标类 */
|
||||
.sf-symbol {
|
||||
font-family:
|
||||
'SF Pro Icons',
|
||||
'SF Symbols',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
sans-serif;
|
||||
font-weight: 500;
|
||||
font-style: normal;
|
||||
font-variant: normal;
|
||||
text-transform: none;
|
||||
line-height: 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
.sf-symbol.semibold {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* SF Pro 文本类 */
|
||||
.sf-pro {
|
||||
font-family:
|
||||
'SF Pro',
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
Roboto,
|
||||
Helvetica,
|
||||
Arial,
|
||||
sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
/* 温和的浏览器主题保护样式 */
|
||||
/* 只保护关键元素,不干扰正常的CSS */
|
||||
|
||||
/* 全局颜色方案声明 */
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
}
|
||||
|
||||
/* 只保护表单元素的外观 */
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
input[type='password'],
|
||||
input[type='search'],
|
||||
input[type='url'],
|
||||
input[type='tel'],
|
||||
input[type='number'],
|
||||
input[type='date'],
|
||||
input[type='time'],
|
||||
input[type='datetime-local'],
|
||||
textarea,
|
||||
select {
|
||||
/* 禁用浏览器默认样式 */
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
|
||||
/* 禁用浏览器的颜色方案自动调整 */
|
||||
color-scheme: none;
|
||||
-webkit-color-scheme: none;
|
||||
}
|
||||
|
||||
/* 保护按钮外观 */
|
||||
button {
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
appearance: none;
|
||||
}
|
||||
|
||||
/* 保护选中文本颜色 */
|
||||
::selection {
|
||||
background-color: rgba(79, 70, 229, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
::-moz-selection {
|
||||
background-color: rgba(79, 70, 229, 0.3);
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* 保护占位符颜色 */
|
||||
::placeholder {
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 在深色模式偏好下保护表单元素 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
input[type='text'],
|
||||
input[type='email'],
|
||||
input[type='password'],
|
||||
input[type='search'],
|
||||
input[type='url'],
|
||||
input[type='tel'],
|
||||
input[type='number'],
|
||||
textarea,
|
||||
select {
|
||||
color-scheme: none;
|
||||
-webkit-color-scheme: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高对比度模式下的调整 */
|
||||
@media (prefers-contrast: high) {
|
||||
input,
|
||||
textarea,
|
||||
select,
|
||||
button {
|
||||
border-width: 2px;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
/* 页面过渡动画 */
|
||||
|
||||
/* 通用页面过渡 */
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.page-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateY(-20px) scale(1.02);
|
||||
}
|
||||
|
||||
.page-view {
|
||||
animation: page-view-enter 0.55s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
will-change: transform, opacity, filter;
|
||||
}
|
||||
|
||||
.page-view[data-route='index'] {
|
||||
animation-duration: 0.75s;
|
||||
animation-timing-function: cubic-bezier(0.16, 1, 0.3, 1);
|
||||
animation-name: page-view-index-enter;
|
||||
}
|
||||
|
||||
@keyframes page-view-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(20px) scale(0.98);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-view-index-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.96);
|
||||
filter: blur(6px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0) scale(1);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-view-mobile-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* 首页特殊过渡 */
|
||||
.page-enter-active[data-route='index'],
|
||||
.page-leave-active[data-route='index'] {
|
||||
transition: all 0.6s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.page-enter-from[data-route='index'] {
|
||||
opacity: 0;
|
||||
transform: translateY(30px) scale(0.96);
|
||||
filter: blur(6px);
|
||||
}
|
||||
|
||||
.page-leave-to[data-route='index'] {
|
||||
opacity: 0;
|
||||
transform: translateY(-30px) scale(1.04);
|
||||
filter: blur(6px);
|
||||
}
|
||||
|
||||
/* 布局过渡 */
|
||||
.layout-enter-active,
|
||||
.layout-leave-active {
|
||||
transition: all 0.3s ease-in-out;
|
||||
}
|
||||
|
||||
.layout-enter-from,
|
||||
.layout-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
/* 加载动画 */
|
||||
.page-loading {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: linear-gradient(135deg, #0a0a0a 0%, #1a1a1a 100%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||
border-top: 3px solid #4f46e5;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* 特殊效果:从首页到后台的滑动效果 */
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active {
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slide-left-enter-from {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-left-leave-to {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 特殊效果:从后台到首页的滑动效果 */
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition: all 0.5s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.slide-right-enter-from {
|
||||
transform: translateX(-100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.slide-right-leave-to {
|
||||
transform: translateX(100%);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 淡入淡出效果 */
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition: all 0.4s ease-in-out;
|
||||
}
|
||||
|
||||
.fade-enter-from,
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
/* 缩放效果 */
|
||||
.scale-enter-active,
|
||||
.scale-leave-active {
|
||||
transition: all 0.4s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||
}
|
||||
|
||||
.scale-enter-from {
|
||||
opacity: 0;
|
||||
transform: scale(0.9);
|
||||
}
|
||||
|
||||
.scale-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
/* 移动端优化 */
|
||||
@media (max-width: 768px) {
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition-duration: 0.3s;
|
||||
}
|
||||
|
||||
.page-view,
|
||||
.page-view[data-route='index'] {
|
||||
animation-duration: 0.3s;
|
||||
animation-name: page-view-mobile-enter;
|
||||
will-change: opacity;
|
||||
}
|
||||
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active {
|
||||
transition-duration: 0.3s;
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
transform: translateY(15px) scale(0.99);
|
||||
}
|
||||
}
|
||||
|
||||
/* 减少动画效果(用户偏好) */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.page-enter-active,
|
||||
.page-leave-active,
|
||||
.layout-enter-active,
|
||||
.layout-leave-active,
|
||||
.slide-left-enter-active,
|
||||
.slide-left-leave-active,
|
||||
.slide-right-enter-active,
|
||||
.slide-right-leave-active,
|
||||
.scale-enter-active,
|
||||
.scale-leave-active,
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition-duration: 0.1s;
|
||||
}
|
||||
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
transform: none;
|
||||
filter: none;
|
||||
}
|
||||
|
||||
.page-view,
|
||||
.page-view[data-route='index'] {
|
||||
animation-duration: 0.1s;
|
||||
animation-name: page-view-reduced-enter;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes page-view-reduced-enter {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
/* 全局CSS变量 - 统一配色方案 */
|
||||
:root {
|
||||
/* 主色调 */
|
||||
--primary: #3b82f6;
|
||||
--primary-hover: #2563eb;
|
||||
--primary-light: rgba(59, 130, 246, 0.1);
|
||||
--primary-border: rgba(59, 130, 246, 0.2);
|
||||
|
||||
/* 成功色 */
|
||||
--success: #10b981;
|
||||
--success-hover: #059669;
|
||||
--success-light: rgba(16, 185, 129, 0.1);
|
||||
--success-border: rgba(16, 185, 129, 0.2);
|
||||
|
||||
/* 警告色 */
|
||||
--warning: #f59e0b;
|
||||
--warning-hover: #d97706;
|
||||
--warning-light: rgba(245, 158, 11, 0.1);
|
||||
--warning-border: rgba(245, 158, 11, 0.2);
|
||||
|
||||
/* 错误色 */
|
||||
--error: #ef4444;
|
||||
--error-hover: #dc2626;
|
||||
--error-light: rgba(239, 68, 68, 0.1);
|
||||
--error-border: rgba(239, 68, 68, 0.2);
|
||||
|
||||
/* 信息色 */
|
||||
--info: #3b82f6;
|
||||
--info-hover: #2563eb;
|
||||
--info-light: rgba(59, 130, 246, 0.1);
|
||||
--info-border: rgba(59, 130, 246, 0.2);
|
||||
|
||||
/* 背景色 */
|
||||
--bg-primary: #111111;
|
||||
--bg-secondary: #1a1a1a;
|
||||
--bg-tertiary: #2a2a2a;
|
||||
--bg-quaternary: #3a3a3a;
|
||||
--bg-hover: #1f1f1f;
|
||||
|
||||
/* 文字颜色 */
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #cccccc;
|
||||
--text-tertiary: #888888;
|
||||
--text-quaternary: #666666;
|
||||
--text-disabled: #444444;
|
||||
|
||||
/* 边框颜色 */
|
||||
--border-primary: #1f1f1f;
|
||||
--border-secondary: #2a2a2a;
|
||||
--border-tertiary: #3a3a3a;
|
||||
--border-quaternary: #4a4a4a;
|
||||
|
||||
/* 阴影 */
|
||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
|
||||
|
||||
/* 圆角 */
|
||||
--radius-sm: 4px;
|
||||
--radius-md: 6px;
|
||||
--radius-lg: 8px;
|
||||
--radius-xl: 12px;
|
||||
--radius-2xl: 16px;
|
||||
|
||||
/* 间距 */
|
||||
--spacing-xs: 4px;
|
||||
--spacing-sm: 8px;
|
||||
--spacing-md: 12px;
|
||||
--spacing-lg: 16px;
|
||||
--spacing-xl: 20px;
|
||||
--spacing-2xl: 24px;
|
||||
--spacing-3xl: 32px;
|
||||
|
||||
/* 字体大小 */
|
||||
--text-xs: 12px;
|
||||
--text-sm: 14px;
|
||||
--text-base: 16px;
|
||||
--text-lg: 18px;
|
||||
--text-xl: 20px;
|
||||
--text-2xl: 24px;
|
||||
--text-3xl: 30px;
|
||||
|
||||
/* 字体粗细 */
|
||||
--font-normal: 400;
|
||||
--font-medium: 500;
|
||||
--font-semibold: 600;
|
||||
--font-bold: 700;
|
||||
|
||||
/* 过渡动画 */
|
||||
--transition-fast: 0.15s ease;
|
||||
--transition-normal: 0.2s ease;
|
||||
--transition-slow: 0.3s ease;
|
||||
|
||||
/* Z-index层级 */
|
||||
--z-dropdown: 100;
|
||||
--z-sticky: 200;
|
||||
--z-fixed: 300;
|
||||
--z-modal-backdrop: 400;
|
||||
--z-modal: 500;
|
||||
--z-popover: 600;
|
||||
--z-tooltip: 700;
|
||||
--z-toast: 800;
|
||||
|
||||
/* 特殊状态颜色 */
|
||||
--status-pending: #fbbf24;
|
||||
--status-pending-light: rgba(251, 191, 36, 0.1);
|
||||
--status-approved: #10b981;
|
||||
--status-approved-light: rgba(16, 185, 129, 0.1);
|
||||
--status-played: #6b7280;
|
||||
--status-played-light: rgba(107, 114, 128, 0.1);
|
||||
|
||||
/* 投票相关颜色 */
|
||||
--vote-heart: #ef4444;
|
||||
--vote-heart-hover: #dc2626;
|
||||
--vote-count: var(--text-tertiary);
|
||||
|
||||
/* 拖拽状态颜色 */
|
||||
--drag-over: var(--primary-light);
|
||||
--drag-over-border: var(--primary);
|
||||
--dragging-opacity: 0.5;
|
||||
|
||||
/* 表格颜色 */
|
||||
--table-header-bg: var(--bg-tertiary);
|
||||
--table-row-hover: var(--bg-hover);
|
||||
--table-border: var(--border-secondary);
|
||||
--table-selected: var(--primary-light);
|
||||
|
||||
/* 卡片颜色 */
|
||||
--card-bg: var(--bg-secondary);
|
||||
--card-border: var(--border-secondary);
|
||||
--card-hover-border: var(--border-tertiary);
|
||||
--card-shadow: var(--shadow-md);
|
||||
|
||||
/* 按钮颜色 */
|
||||
--btn-primary-bg: var(--primary);
|
||||
--btn-primary-hover: var(--primary-hover);
|
||||
--btn-primary-text: var(--text-primary);
|
||||
--btn-primary-border: var(--primary-border);
|
||||
|
||||
--btn-secondary-bg: #3a3a3a;
|
||||
--btn-secondary-hover: #4a4a4a;
|
||||
--btn-secondary-text: var(--text-primary);
|
||||
--btn-secondary-border: #555555;
|
||||
|
||||
--btn-success-bg: var(--success);
|
||||
--btn-success-hover: var(--success-hover);
|
||||
--btn-success-text: var(--text-primary);
|
||||
--btn-success-border: var(--success-border);
|
||||
|
||||
--btn-warning-bg: var(--warning);
|
||||
--btn-warning-hover: var(--warning-hover);
|
||||
--btn-warning-text: var(--text-primary);
|
||||
--btn-warning-border: var(--warning-border);
|
||||
|
||||
--btn-error-bg: var(--error);
|
||||
--btn-error-hover: var(--error-hover);
|
||||
--btn-error-text: var(--text-primary);
|
||||
--btn-error-border: var(--error-border);
|
||||
|
||||
--btn-disabled-bg: var(--bg-tertiary);
|
||||
--btn-disabled-text: var(--text-disabled);
|
||||
|
||||
/* 输入框颜色 */
|
||||
--input-bg: var(--bg-tertiary);
|
||||
--input-border: var(--border-tertiary);
|
||||
--input-border-focus: var(--primary);
|
||||
--input-text: var(--text-primary);
|
||||
--input-placeholder: var(--text-quaternary);
|
||||
--input-shadow-focus: 0 0 0 3px var(--primary-light);
|
||||
|
||||
/* 模态框颜色 */
|
||||
--modal-backdrop: rgba(0, 0, 0, 0.7);
|
||||
--modal-bg: var(--bg-secondary);
|
||||
--modal-border: var(--border-secondary);
|
||||
--modal-header-border: var(--border-secondary);
|
||||
--modal-footer-border: var(--border-secondary);
|
||||
|
||||
/* 通知颜色 */
|
||||
--notification-success-bg: var(--success-light);
|
||||
--notification-success-border: var(--success-border);
|
||||
--notification-success-text: var(--success);
|
||||
|
||||
--notification-warning-bg: var(--warning-light);
|
||||
--notification-warning-border: var(--warning-border);
|
||||
--notification-warning-text: var(--warning);
|
||||
|
||||
--notification-error-bg: var(--error-light);
|
||||
--notification-error-border: var(--error-border);
|
||||
--notification-error-text: var(--error);
|
||||
|
||||
--notification-info-bg: var(--info-light);
|
||||
--notification-info-border: var(--info-border);
|
||||
--notification-info-text: var(--info);
|
||||
|
||||
/* 加载动画颜色 */
|
||||
--spinner-primary: var(--primary);
|
||||
--spinner-secondary: var(--bg-tertiary);
|
||||
|
||||
/* 滚动条颜色 */
|
||||
--scrollbar-track: var(--bg-tertiary);
|
||||
--scrollbar-thumb: var(--bg-quaternary);
|
||||
--scrollbar-thumb-hover: #4a4a4a;
|
||||
}
|
||||
|
||||
/* 深色主题特定调整 */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
/* 在深色模式下可以进行微调 */
|
||||
}
|
||||
}
|
||||
|
||||
/* 高对比度模式支持 */
|
||||
@media (prefers-contrast: high) {
|
||||
:root {
|
||||
--border-primary: #444444;
|
||||
--border-secondary: #555555;
|
||||
--border-tertiary: #666666;
|
||||
--text-tertiary: #aaaaaa;
|
||||
--text-quaternary: #888888;
|
||||
}
|
||||
}
|
||||
|
||||
/* 减少动画模式支持 */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
:root {
|
||||
--transition-fast: 0s;
|
||||
--transition-normal: 0s;
|
||||
--transition-slow: 0s;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局滚动条样式 */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--scrollbar-thumb) var(--scrollbar-track);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-track {
|
||||
background: var(--scrollbar-track);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb {
|
||||
background: var(--scrollbar-thumb);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
*::-webkit-scrollbar-thumb:hover {
|
||||
background: var(--scrollbar-thumb-hover);
|
||||
}
|
||||
|
||||
/* 全局选择文本样式 */
|
||||
::selection {
|
||||
background: var(--primary-light);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* 全局焦点样式 */
|
||||
*:focus {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
*:focus:not(:focus-visible) {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 全局禁用状态样式 */
|
||||
*:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* 全局链接样式 */
|
||||
a {
|
||||
color: var(--primary);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: var(--primary-hover);
|
||||
}
|
||||
|
||||
/* 全局按钮重置 */
|
||||
button {
|
||||
border: none;
|
||||
background: none;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
button,
|
||||
a,
|
||||
input,
|
||||
textarea {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
@media (hover: none) and (pointer: coarse) {
|
||||
*:focus {
|
||||
outline: none !important;
|
||||
}
|
||||
button,
|
||||
a {
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* 全局输入框重置 */
|
||||
input,
|
||||
textarea,
|
||||
select {
|
||||
font-family: inherit;
|
||||
font-size: inherit;
|
||||
}
|
||||
|
||||
/* 全局图片样式 */
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
/* 全局表格样式 */
|
||||
table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
/* 全局代码样式 */
|
||||
code,
|
||||
pre {
|
||||
font-family: 'Fira Code', 'Monaco', 'Consolas', monospace;
|
||||
background: var(--bg-tertiary);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
code {
|
||||
padding: 2px 4px;
|
||||
font-size: 0.875em;
|
||||
}
|
||||
|
||||
pre {
|
||||
padding: var(--spacing-lg);
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
pre code {
|
||||
padding: 0;
|
||||
background: none;
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
/* Year Review Global Styles */
|
||||
|
||||
/* Animations */
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes reverse-spin {
|
||||
from {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes bounce {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(-25%);
|
||||
animation-timing-function: cubic-bezier(0.8, 0, 1, 1);
|
||||
}
|
||||
50% {
|
||||
transform: none;
|
||||
animation-timing-function: cubic-bezier(0, 0, 0.2, 1);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%,
|
||||
100% {
|
||||
transform: translateY(0);
|
||||
}
|
||||
50% {
|
||||
transform: translateY(-20px);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
.animate-spin-slow {
|
||||
animation: spin 12s linear infinite;
|
||||
}
|
||||
|
||||
.animate-reverse-spin-slow {
|
||||
animation: reverse-spin 15s linear infinite;
|
||||
}
|
||||
|
||||
.animate-pulse {
|
||||
animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
|
||||
}
|
||||
|
||||
.animate-bounce {
|
||||
animation: bounce 1s infinite;
|
||||
}
|
||||
|
||||
.animate-float {
|
||||
animation: float 6s ease-in-out infinite;
|
||||
}
|
||||
|
||||
/* Utilities */
|
||||
.text-transparent {
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
.bg-clip-text {
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
}
|
||||
|
||||
.backdrop-blur-md {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
.backdrop-blur-sm {
|
||||
backdrop-filter: blur(4px);
|
||||
-webkit-backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
/* Gradients */
|
||||
.bg-gradient-intro {
|
||||
background: linear-gradient(135deg, #111827 0%, #000000 50%, #111827 100%);
|
||||
}
|
||||
|
||||
.bg-gradient-text-intro {
|
||||
background-image: linear-gradient(to right, #ffffff, #e5e7eb, #6b7280);
|
||||
}
|
||||
|
||||
.bg-gradient-artist {
|
||||
background: linear-gradient(to bottom right, #e9d5ff, #f472b6, #fb923c);
|
||||
}
|
||||
|
||||
.bg-gradient-numbers {
|
||||
background-image: linear-gradient(to bottom, #60a5fa, #1d4ed8);
|
||||
}
|
||||
|
||||
/* Slide Transitions */
|
||||
.slide-up-enter-active,
|
||||
.slide-up-leave-active,
|
||||
.slide-down-enter-active,
|
||||
.slide-down-leave-active {
|
||||
transition: all 0.5s ease-in-out;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide-up-enter-from {
|
||||
transform: translateY(100%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.slide-up-leave-to {
|
||||
transform: translateY(-20%); /* Parallax effect */
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.slide-down-enter-from {
|
||||
transform: translateY(-100%);
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.slide-down-leave-to {
|
||||
transform: translateY(20%); /* Parallax effect */
|
||||
opacity: 0.5;
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
/* Content Entry Animations */
|
||||
.entry-enter-active {
|
||||
transition: all 0.8s cubic-bezier(0.16, 1, 0.3, 1);
|
||||
}
|
||||
|
||||
.entry-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateY(40px);
|
||||
}
|
||||
|
||||
.entry-enter-to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.delay-100 {
|
||||
transition-delay: 100ms;
|
||||
}
|
||||
.delay-200 {
|
||||
transition-delay: 200ms;
|
||||
}
|
||||
.delay-300 {
|
||||
transition-delay: 300ms;
|
||||
}
|
||||
.delay-400 {
|
||||
transition-delay: 400ms;
|
||||
}
|
||||
.delay-500 {
|
||||
transition-delay: 500ms;
|
||||
}
|
||||
@@ -0,0 +1,314 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 歌词渲染组件 - 基于 @applemusic-like-lyrics/core
|
||||
*/
|
||||
import {
|
||||
type BaseRenderer,
|
||||
LyricPlayer as CoreLyricPlayer,
|
||||
type LyricLine,
|
||||
type LyricLineMouseEvent,
|
||||
type LyricPlayerBase,
|
||||
type spring
|
||||
} from '@applemusic-like-lyrics/core'
|
||||
import {
|
||||
type PropType,
|
||||
type Ref,
|
||||
type ShallowRef,
|
||||
ref,
|
||||
computed,
|
||||
onMounted,
|
||||
onUnmounted,
|
||||
watch
|
||||
} from 'vue'
|
||||
import '@applemusic-like-lyrics/core/style.css'
|
||||
|
||||
/**
|
||||
* Props 定义
|
||||
*/
|
||||
const props = defineProps({
|
||||
/**
|
||||
* 是否禁用歌词播放组件
|
||||
*/
|
||||
disabled: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* 是否演出部分效果
|
||||
*/
|
||||
playing: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* 设置歌词行的对齐方式
|
||||
*/
|
||||
alignAnchor: {
|
||||
type: String as PropType<'top' | 'bottom' | 'center'>,
|
||||
default: 'center'
|
||||
},
|
||||
/**
|
||||
* 设置默认的歌词行对齐位置
|
||||
*/
|
||||
alignPosition: {
|
||||
type: Number,
|
||||
default: 0.5
|
||||
},
|
||||
/**
|
||||
* 设置是否使用物理弹簧算法
|
||||
*/
|
||||
enableSpring: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* 设置是否启用歌词行的模糊效果
|
||||
*/
|
||||
enableBlur: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* 设置是否启用缩放效果
|
||||
*/
|
||||
enableScale: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
/**
|
||||
* 设置是否隐藏已经播放过的歌词行
|
||||
*/
|
||||
hidePassedLines: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
/**
|
||||
* 设置当前播放歌词
|
||||
*/
|
||||
lyricLines: {
|
||||
type: Object as PropType<LyricLine[]>,
|
||||
required: false
|
||||
},
|
||||
/**
|
||||
* 设置当前播放进度
|
||||
*/
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
/**
|
||||
* 设置文字动画的渐变宽度
|
||||
*/
|
||||
wordFadeWidth: {
|
||||
type: Number,
|
||||
default: 0.5
|
||||
},
|
||||
/**
|
||||
* X 轴弹簧参数
|
||||
*/
|
||||
linePosXSpringParams: {
|
||||
type: Object as PropType<Partial<spring.SpringParams>>,
|
||||
required: false
|
||||
},
|
||||
/**
|
||||
* Y 轴弹簧参数
|
||||
*/
|
||||
linePosYSpringParams: {
|
||||
type: Object as PropType<Partial<spring.SpringParams>>,
|
||||
required: false
|
||||
},
|
||||
/**
|
||||
* 缩放弹簧参数
|
||||
*/
|
||||
lineScaleSpringParams: {
|
||||
type: Object as PropType<Partial<spring.SpringParams>>,
|
||||
required: false
|
||||
},
|
||||
/**
|
||||
* 渲染器类
|
||||
*/
|
||||
lyricPlayer: {
|
||||
type: Object as PropType<{
|
||||
new (...args: ConstructorParameters<typeof BaseRenderer>): BaseRenderer
|
||||
}>,
|
||||
required: false
|
||||
}
|
||||
})
|
||||
|
||||
/**
|
||||
* 歌词播放组件的事件
|
||||
*/
|
||||
const emit = defineEmits<{
|
||||
lineClick: [event: LyricLineMouseEvent]
|
||||
lineContextmenu: [event: LyricLineMouseEvent]
|
||||
}>()
|
||||
|
||||
/**
|
||||
* 歌词播放组件的引用
|
||||
*/
|
||||
export interface LyricPlayerRef {
|
||||
/**
|
||||
* 歌词播放实例
|
||||
*/
|
||||
lyricPlayer: Ref<LyricPlayerBase | undefined>
|
||||
/**
|
||||
* 将歌词播放实例的元素包裹起来的 DIV 元素实例
|
||||
*/
|
||||
wrapperEl: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
}
|
||||
|
||||
// 模板引用
|
||||
const wrapperRef = ref<HTMLDivElement | null>(null)
|
||||
// 歌词播放实例
|
||||
const playerRef = ref<CoreLyricPlayer>()
|
||||
|
||||
// 事件处理器
|
||||
const lineClickHandler = (e: Event) => emit('lineClick', e as LyricLineMouseEvent)
|
||||
const lineContextMenuHandler = (e: Event) => emit('lineContextmenu', e as LyricLineMouseEvent)
|
||||
|
||||
// 底部行元素 (用于 Teleport)
|
||||
const bottomLineEl = computed(() => playerRef.value?.getBottomLineElement())
|
||||
|
||||
// 组件挂载时初始化
|
||||
onMounted(() => {
|
||||
const wrapper = wrapperRef.value
|
||||
if (wrapper) {
|
||||
playerRef.value = new CoreLyricPlayer()
|
||||
wrapper.appendChild(playerRef.value.getElement())
|
||||
playerRef.value.addEventListener('line-click', lineClickHandler)
|
||||
playerRef.value.addEventListener('line-contextmenu', lineContextMenuHandler)
|
||||
}
|
||||
})
|
||||
|
||||
// 组件卸载时清理
|
||||
onUnmounted(() => {
|
||||
const player = playerRef.value
|
||||
if (player) {
|
||||
player.removeEventListener('line-click', lineClickHandler)
|
||||
player.removeEventListener('line-contextmenu', lineContextMenuHandler)
|
||||
player.dispose()
|
||||
}
|
||||
})
|
||||
|
||||
// 动画帧更新
|
||||
watchEffect((onCleanup) => {
|
||||
if (!props.disabled) {
|
||||
let canceled = false
|
||||
let lastTime = -1
|
||||
const onFrame = (time: number) => {
|
||||
if (canceled) return
|
||||
if (lastTime !== -1) {
|
||||
playerRef.value?.update(time - lastTime)
|
||||
}
|
||||
lastTime = time
|
||||
requestAnimationFrame(onFrame)
|
||||
}
|
||||
requestAnimationFrame(onFrame)
|
||||
onCleanup(() => {
|
||||
canceled = true
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// 播放/暂停状态
|
||||
watchEffect(() => {
|
||||
if (props.playing !== undefined) {
|
||||
if (props.playing) {
|
||||
playerRef.value?.resume()
|
||||
} else {
|
||||
playerRef.value?.pause()
|
||||
}
|
||||
} else playerRef.value?.resume()
|
||||
})
|
||||
|
||||
// 对齐锚点
|
||||
watchEffect(() => {
|
||||
if (props.alignAnchor !== undefined) playerRef.value?.setAlignAnchor(props.alignAnchor)
|
||||
})
|
||||
|
||||
// 隐藏已播放歌词行
|
||||
watchEffect(() => {
|
||||
if (props.hidePassedLines !== undefined)
|
||||
playerRef.value?.setHidePassedLines(props.hidePassedLines)
|
||||
})
|
||||
|
||||
// 对齐位置
|
||||
watchEffect(() => {
|
||||
if (props.alignPosition !== undefined) playerRef.value?.setAlignPosition(props.alignPosition)
|
||||
})
|
||||
|
||||
// 弹簧动画
|
||||
watchEffect(() => {
|
||||
playerRef.value?.setEnableSpring(props.enableSpring)
|
||||
})
|
||||
|
||||
// 模糊效果
|
||||
watchEffect(() => {
|
||||
playerRef.value?.setEnableBlur(props.enableBlur)
|
||||
})
|
||||
|
||||
// 缩放效果
|
||||
watchEffect(() => {
|
||||
playerRef.value?.setEnableScale(props.enableScale)
|
||||
})
|
||||
|
||||
// 歌词行数据
|
||||
watchEffect(() => {
|
||||
if (props.lyricLines !== undefined) playerRef.value?.setLyricLines(props.lyricLines)
|
||||
})
|
||||
|
||||
// 当前播放时间
|
||||
watch(
|
||||
() => props.currentTime,
|
||||
(time, oldTime) => {
|
||||
if (time === undefined) return
|
||||
const isSeek = oldTime !== undefined && Math.abs(time - oldTime) > 1000
|
||||
playerRef.value?.setCurrentTime(time, isSeek)
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 渐变宽度
|
||||
watchEffect(() => {
|
||||
if (props.wordFadeWidth !== undefined) playerRef.value?.setWordFadeWidth(props.wordFadeWidth)
|
||||
})
|
||||
|
||||
// X 轴弹簧参数
|
||||
watchEffect(() => {
|
||||
if (props.linePosXSpringParams !== undefined)
|
||||
playerRef.value?.setLinePosXSpringParams(props.linePosXSpringParams)
|
||||
})
|
||||
|
||||
// Y 轴弹簧参数
|
||||
watchEffect(() => {
|
||||
if (props.linePosYSpringParams !== undefined)
|
||||
playerRef.value?.setLinePosYSpringParams(props.linePosYSpringParams)
|
||||
})
|
||||
|
||||
// 缩放弹簧参数
|
||||
watchEffect(() => {
|
||||
if (props.lineScaleSpringParams !== undefined)
|
||||
playerRef.value?.setLineScaleSpringParams(props.lineScaleSpringParams)
|
||||
})
|
||||
|
||||
// 暴露给父组件
|
||||
defineExpose<LyricPlayerRef>({
|
||||
lyricPlayer: playerRef as unknown as Ref<LyricPlayerBase | undefined>,
|
||||
wrapperEl: wrapperRef as unknown as Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="wrapperRef" class="amll-wrapper">
|
||||
<Teleport v-if="bottomLineEl" :to="bottomLineEl">
|
||||
<slot name="bottom-line" />
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.amll-wrapper {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,985 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<div
|
||||
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-xl overflow-hidden shadow-2xl flex flex-col max-h-[90vh]"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="p-8 pb-4 flex items-center justify-between border-b border-zinc-800/50">
|
||||
<div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-purple-600/10 flex items-center justify-center text-purple-500"
|
||||
>
|
||||
<Layers :size="20" />
|
||||
</div>
|
||||
批量更新学生信息
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 mt-1 ml-13">
|
||||
快速调整学生年级、班级或通过 Excel 批量修改
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="p-3 bg-zinc-800/50 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-xl transition-all"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 overflow-y-auto p-8 pt-6 custom-scrollbar space-y-8">
|
||||
<!-- 更新方式选择 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<label
|
||||
:class="[
|
||||
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
|
||||
updateType === 'grade-only'
|
||||
? 'bg-purple-500/5 border-purple-500/50 ring-4 ring-purple-500/10'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
>
|
||||
<input v-model="updateType" type="radio" value="grade-only" class="sr-only" >
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
|
||||
updateType === 'grade-only'
|
||||
? 'bg-purple-500 text-white'
|
||||
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
|
||||
]"
|
||||
>
|
||||
<Calendar :size="18" />
|
||||
</div>
|
||||
<div
|
||||
v-if="updateType === 'grade-only'"
|
||||
class="w-5 h-5 rounded-full bg-purple-500 flex items-center justify-center"
|
||||
>
|
||||
<Check :size="12" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
|
||||
>仅更新年级</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
|
||||
>批量更新选中学生的年级,保持班级不变</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<label
|
||||
:class="[
|
||||
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
|
||||
updateType === 'excel-batch'
|
||||
? 'bg-emerald-500/5 border-emerald-500/50 ring-4 ring-emerald-500/10'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
>
|
||||
<input v-model="updateType" type="radio" value="excel-batch" class="sr-only" >
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
|
||||
updateType === 'excel-batch'
|
||||
? 'bg-emerald-500 text-white'
|
||||
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
|
||||
]"
|
||||
>
|
||||
<FileSpreadsheet :size="18" />
|
||||
</div>
|
||||
<div
|
||||
v-if="updateType === 'excel-batch'"
|
||||
class="w-5 h-5 rounded-full bg-emerald-500 flex items-center justify-center"
|
||||
>
|
||||
<Check :size="12" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
|
||||
>Excel 批量更新</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
|
||||
>通过 Excel 文件精确匹配并批量修改学生信息</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<label
|
||||
:class="[
|
||||
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
|
||||
updateType === 'status-batch'
|
||||
? 'bg-amber-500/5 border-amber-500/50 ring-4 ring-amber-500/10'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
>
|
||||
<input v-model="updateType" type="radio" value="status-batch" class="sr-only" >
|
||||
<div class="flex items-center justify-between mb-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
|
||||
updateType === 'status-batch'
|
||||
? 'bg-amber-500 text-white'
|
||||
: 'bg-zinc-800 text-zinc-500 group-hover:text-zinc-300'
|
||||
]"
|
||||
>
|
||||
<ShieldAlert :size="18" />
|
||||
</div>
|
||||
<div
|
||||
v-if="updateType === 'status-batch'"
|
||||
class="w-5 h-5 rounded-full bg-amber-500 flex items-center justify-center"
|
||||
>
|
||||
<Check :size="12" class="text-white" />
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
|
||||
>设置账户状态</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
|
||||
>批量设置选中学生的账户状态</span
|
||||
>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- 学生选择面板 (年级更新和状态更新共用) -->
|
||||
<div
|
||||
v-if="['grade-only', 'status-batch'].includes(updateType)"
|
||||
class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
<div class="p-6 bg-zinc-950/50 border border-zinc-800/50 rounded-xl space-y-6">
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest"
|
||||
>
|
||||
<Filter :size="14" class="text-purple-500" />
|
||||
学生范围筛选
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>当前年级</label
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="gradeFilter"
|
||||
:options="gradeOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="全部年级"
|
||||
class-name="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>当前班级</label
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="classFilter"
|
||||
:options="classOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="全部班级"
|
||||
class-name="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between ml-1">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||
>选择学生 ({{ selectedUserIds.length }}/{{ filteredStudents.length }})</label
|
||||
>
|
||||
<button
|
||||
class="text-[10px] font-black text-purple-400 hover:text-purple-300 uppercase tracking-widest transition-colors"
|
||||
@click="toggleSelectAll"
|
||||
>
|
||||
{{ isAllSelected ? '取消全选' : '选择当前全部' }}
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="max-h-48 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-950 p-2 custom-scrollbar"
|
||||
>
|
||||
<div
|
||||
v-if="filteredStudents.length === 0"
|
||||
class="py-10 text-center text-xs text-zinc-600 font-medium"
|
||||
>
|
||||
没有匹配条件的学生
|
||||
</div>
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 gap-1">
|
||||
<label
|
||||
v-for="student in filteredStudents"
|
||||
:key="student.id"
|
||||
class="flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-900/50 cursor-pointer transition-colors group"
|
||||
>
|
||||
<input
|
||||
v-model="selectedUserIds"
|
||||
:value="student.id"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded-md border-zinc-700 bg-zinc-950 text-purple-600 focus:ring-purple-500/20"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
class="text-xs font-bold text-zinc-200 group-hover:text-purple-400 transition-colors"
|
||||
>{{ student.name }}</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-600 font-mono">{{
|
||||
student.username
|
||||
}}</span>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 目标年级设置 -->
|
||||
<div v-if="updateType === 'grade-only'" class="p-6 bg-purple-500/5 border border-purple-500/20 rounded-xl space-y-6">
|
||||
<div
|
||||
class="flex items-center gap-2 text-xs font-black text-purple-400 uppercase tracking-widest"
|
||||
>
|
||||
<Save :size="14" />
|
||||
目标设置
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 items-end">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>目标年级</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<Calendar
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700 group-focus-within:text-purple-500 transition-colors"
|
||||
:size="16"
|
||||
/>
|
||||
<input
|
||||
v-model="targetGrade"
|
||||
type="text"
|
||||
placeholder="例如: 2025"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-purple-500/30 transition-all text-zinc-200"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<label
|
||||
class="flex items-center gap-3 p-3 bg-zinc-950 border border-zinc-800 rounded-lg cursor-pointer hover:border-zinc-700 transition-all"
|
||||
>
|
||||
<input
|
||||
v-model="keepClass"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded-md border-zinc-700 bg-zinc-950 text-purple-600 focus:ring-purple-500/20"
|
||||
>
|
||||
<span class="text-xs font-bold text-zinc-300">保持原有班级不变</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 目标状态设置 -->
|
||||
<div v-if="updateType === 'status-batch'" class="p-6 bg-amber-500/5 border border-amber-500/20 rounded-xl space-y-6">
|
||||
<div class="flex items-center gap-2 text-xs font-black text-amber-400 uppercase tracking-widest">
|
||||
<Save :size="14" />
|
||||
目标状态设置
|
||||
</div>
|
||||
<div class="grid grid-cols-1 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1">选择账户状态</label>
|
||||
<CustomSelect
|
||||
v-model="targetStatus"
|
||||
:options="statusOptions"
|
||||
label-key="label"
|
||||
value-key="value"
|
||||
placeholder="请选择目标状态"
|
||||
class-name="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-500 uppercase tracking-widest ml-1">变更原因说明</label>
|
||||
<div class="relative group">
|
||||
<MessageSquare class="absolute left-4 top-3 text-zinc-700 group-focus-within:text-amber-500 transition-colors" :size="16" />
|
||||
<textarea
|
||||
v-model="statusReason"
|
||||
rows="2"
|
||||
placeholder="例如: 2025届学生统一毕业"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-500/30 transition-all text-zinc-200 resize-none"
|
||||
></textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Excel 批量更新面板 -->
|
||||
<div
|
||||
v-if="updateType === 'excel-batch'"
|
||||
class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
|
||||
>
|
||||
<!-- 文件上传区 -->
|
||||
<div
|
||||
:class="[
|
||||
'relative group cursor-pointer transition-all',
|
||||
isDragOver ? 'scale-[0.99]' : ''
|
||||
]"
|
||||
@drop="handleDrop"
|
||||
@dragover.prevent
|
||||
@dragenter.prevent="isDragOver = true"
|
||||
@dragleave.prevent="isDragOver = false"
|
||||
@click="$refs.fileInput.click()"
|
||||
>
|
||||
<input
|
||||
ref="fileInput"
|
||||
accept=".xlsx,.xls"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'w-full py-12 border-2 border-dashed rounded-xl transition-all flex flex-col items-center justify-center gap-4',
|
||||
isDragOver
|
||||
? 'border-emerald-500 bg-emerald-500/10'
|
||||
: 'border-zinc-800 hover:border-emerald-500/50 hover:bg-emerald-500/5'
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'w-16 h-16 rounded-lg bg-zinc-900 flex items-center justify-center transition-colors shadow-xl',
|
||||
isDragOver ? 'text-emerald-500' : 'text-zinc-600 group-hover:text-emerald-500'
|
||||
]"
|
||||
>
|
||||
<Upload :size="32" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<p class="text-base font-black text-zinc-200 tracking-tight">
|
||||
拖拽 Excel 文件到此处
|
||||
</p>
|
||||
<p class="text-xs text-zinc-500 mt-1">
|
||||
或 <span class="text-emerald-500 font-bold">点击选择文件</span> (支持 .xlsx /
|
||||
.xls)
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 模板与说明 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<div
|
||||
class="md:col-span-2 p-5 bg-zinc-950 border border-zinc-800 rounded-xl space-y-3"
|
||||
>
|
||||
<div
|
||||
class="flex items-center gap-2 text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||
>
|
||||
<Info :size="12" />
|
||||
文件格式规范
|
||||
</div>
|
||||
<ul class="text-[10px] text-zinc-600 space-y-1.5 font-medium leading-relaxed">
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
|
||||
第一行为表头:用户名、姓名、年级、班级、新用户名
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
|
||||
<span class="text-zinc-400 font-bold">用户名</span> 列用于精确匹配现有账户
|
||||
</li>
|
||||
<li class="flex items-start gap-2">
|
||||
<div class="w-1 h-1 rounded-full bg-zinc-700 mt-1.5" />
|
||||
字段留空则保持原值不变,不进行覆盖更新
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<button
|
||||
class="p-5 bg-emerald-500/5 border border-emerald-500/20 hover:border-emerald-500/40 rounded-xl transition-all flex flex-col items-center justify-center gap-2 group"
|
||||
@click="downloadTemplate"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 rounded-lg bg-emerald-500 text-white flex items-center justify-center shadow-lg shadow-emerald-900/20 group-hover:scale-110 transition-transform"
|
||||
>
|
||||
<Download :size="20" />
|
||||
</div>
|
||||
<span class="text-[10px] font-black text-emerald-500 uppercase tracking-widest"
|
||||
>获取模板文件</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 预览表格 -->
|
||||
<div v-if="excelPreviewData.length > 0" class="space-y-4">
|
||||
<div class="flex items-center justify-between ml-1">
|
||||
<label class="text-xs font-black text-zinc-400 uppercase tracking-widest"
|
||||
>数据预览 ({{ excelPreviewData.length }}条)</label
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-emerald-500" />
|
||||
<span class="text-[10px] text-zinc-500 font-bold">待更新</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-1.5">
|
||||
<div class="w-2 h-2 rounded-full bg-red-500" />
|
||||
<span class="text-[10px] text-zinc-500 font-bold">错误</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-xl border border-zinc-800 bg-zinc-950 overflow-hidden shadow-xl">
|
||||
<div class="overflow-x-auto custom-scrollbar">
|
||||
<table class="w-full text-left border-collapse">
|
||||
<thead
|
||||
class="bg-zinc-900/80 text-[10px] font-black text-zinc-500 uppercase tracking-widest border-b border-zinc-800"
|
||||
>
|
||||
<tr>
|
||||
<th class="px-5 py-4 whitespace-nowrap">匹配用户</th>
|
||||
<th class="px-5 py-4 whitespace-nowrap">当前信息</th>
|
||||
<th class="px-5 py-4 whitespace-nowrap">更新后</th>
|
||||
<th class="px-5 py-4 whitespace-nowrap text-right">匹配状态</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="divide-y divide-zinc-900">
|
||||
<tr
|
||||
v-for="(row, index) in excelPreviewData.slice(0, 10)"
|
||||
:key="index"
|
||||
:class="[
|
||||
row.error ? 'bg-red-500/5' : 'hover:bg-zinc-900/30 transition-colors'
|
||||
]"
|
||||
>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex flex-col">
|
||||
<span
|
||||
:class="[
|
||||
'text-xs font-bold',
|
||||
row.error ? 'text-red-400' : 'text-zinc-200'
|
||||
]"
|
||||
>{{ row.username }}</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-600 font-medium">{{
|
||||
row.name || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="text-[10px] text-zinc-500 font-medium">
|
||||
{{ row.currentGrade || '-' }}年级 {{ row.currentClass || '-' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-xs font-bold text-emerald-400">{{
|
||||
row.newGrade || row.currentGrade || '-'
|
||||
}}</span>
|
||||
<span class="text-[10px] text-zinc-700">/</span>
|
||||
<span class="text-xs font-bold text-emerald-400">{{
|
||||
row.newClass || row.currentClass || '-'
|
||||
}}</span>
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-5 py-4 text-right">
|
||||
<span
|
||||
v-if="row.error"
|
||||
class="px-2 py-0.5 bg-red-500/10 text-red-500 text-[10px] font-black rounded uppercase tracking-tighter border border-red-500/20"
|
||||
>
|
||||
{{ row.error }}
|
||||
</span>
|
||||
<span
|
||||
v-else
|
||||
class="px-2 py-0.5 bg-emerald-500/10 text-emerald-500 text-[10px] font-black rounded uppercase tracking-tighter border border-emerald-500/20"
|
||||
>
|
||||
已就绪
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div
|
||||
v-if="excelPreviewData.length > 10"
|
||||
class="p-4 text-center border-t border-zinc-900 bg-zinc-900/20 text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
|
||||
>
|
||||
以及另外 {{ excelPreviewData.length - 10 }} 条记录已在队列中
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误提示 -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-400 text-xs animate-in shake duration-300"
|
||||
>
|
||||
<AlertCircle :size="16" />
|
||||
{{ error }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部按钮 -->
|
||||
<div class="p-8 pt-4 border-t border-zinc-800/50 bg-zinc-900/50 flex gap-3">
|
||||
<button
|
||||
class="flex-1 px-6 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
取消操作
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading || !canUpdate"
|
||||
:class="[
|
||||
'flex-[2] px-6 py-4 text-white text-xs font-black rounded-2xl transition-all uppercase tracking-widest flex items-center justify-center gap-2 disabled:opacity-50 shadow-lg active:scale-95',
|
||||
updateType === 'excel-batch'
|
||||
? 'bg-emerald-600 hover:bg-emerald-500 shadow-emerald-900/20'
|
||||
: updateType === 'status-batch'
|
||||
? 'bg-amber-600 hover:bg-amber-500 shadow-amber-900/20'
|
||||
: 'bg-purple-600 hover:bg-purple-500 shadow-purple-900/20'
|
||||
]"
|
||||
@click="performUpdate"
|
||||
>
|
||||
<RefreshCw v-if="loading" class="animate-spin" :size="16" />
|
||||
<Save v-else :size="16" />
|
||||
{{ loading ? '正在提交更新...' : '确认并开始更新' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import {
|
||||
Layers,
|
||||
X,
|
||||
Calendar,
|
||||
FileSpreadsheet,
|
||||
Check,
|
||||
Filter,
|
||||
ChevronDown,
|
||||
Save,
|
||||
Upload,
|
||||
Download,
|
||||
Info,
|
||||
AlertCircle,
|
||||
RefreshCw,
|
||||
CheckCircle2,
|
||||
ShieldAlert,
|
||||
MessageSquare
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
users: Array
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close', 'update-success'])
|
||||
|
||||
// 响应式数据
|
||||
const updateType = ref('grade-only')
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// 仅更新年级相关
|
||||
const gradeFilter = ref('')
|
||||
const classFilter = ref('')
|
||||
const selectedUserIds = ref([])
|
||||
const targetGrade = ref('')
|
||||
const keepClass = ref(true)
|
||||
|
||||
// 状态批量更新相关
|
||||
const targetStatus = ref('')
|
||||
const statusReason = ref('')
|
||||
|
||||
const statusOptions = [
|
||||
{ label: '正常访问', value: 'active' },
|
||||
{ label: '限制访问 (毕业生)', value: 'graduate' },
|
||||
{ label: '限制访问 (退学)', value: 'withdrawn' }
|
||||
]
|
||||
|
||||
// Excel批量更新相关
|
||||
const isDragOver = ref(false)
|
||||
const excelPreviewData = ref([])
|
||||
const fileInput = ref(null)
|
||||
|
||||
// 所有用户的年级班级信息
|
||||
const allGrades = ref([])
|
||||
const allClasses = ref([])
|
||||
// 所有学生用户数据
|
||||
const allStudents = ref([])
|
||||
|
||||
// 服务
|
||||
const auth = useAuth()
|
||||
|
||||
// 计算属性
|
||||
const students = computed(() => {
|
||||
return allStudents.value.length > 0
|
||||
? allStudents.value
|
||||
: props.users.filter((user) => user.role === 'USER')
|
||||
})
|
||||
|
||||
const availableGrades = computed(() => {
|
||||
return allGrades.value.length > 0
|
||||
? allGrades.value
|
||||
: [...new Set(students.value.map((s) => s.grade).filter(Boolean))].sort()
|
||||
})
|
||||
|
||||
const availableClasses = computed(() => {
|
||||
return allClasses.value.length > 0
|
||||
? allClasses.value
|
||||
: [...new Set(students.value.map((s) => s.class).filter(Boolean))].sort()
|
||||
})
|
||||
|
||||
const gradeOptions = computed(() => {
|
||||
return [
|
||||
{ label: '全部年级', value: '' },
|
||||
...availableGrades.value.map((g) => ({ label: g, value: g }))
|
||||
]
|
||||
})
|
||||
|
||||
const classOptions = computed(() => {
|
||||
return [
|
||||
{ label: '全部班级', value: '' },
|
||||
...availableClasses.value.map((c) => ({ label: c, value: c }))
|
||||
]
|
||||
})
|
||||
|
||||
const filteredStudents = computed(() => {
|
||||
let filtered = students.value
|
||||
|
||||
if (gradeFilter.value) {
|
||||
filtered = filtered.filter((s) => s.grade === gradeFilter.value)
|
||||
}
|
||||
|
||||
if (classFilter.value) {
|
||||
filtered = filtered.filter((s) => s.class === classFilter.value)
|
||||
}
|
||||
|
||||
return filtered
|
||||
})
|
||||
|
||||
const isAllSelected = computed(() => {
|
||||
return (
|
||||
filteredStudents.value.length > 0 &&
|
||||
selectedUserIds.value.length === filteredStudents.value.length
|
||||
)
|
||||
})
|
||||
|
||||
const canUpdate = computed(() => {
|
||||
if (updateType.value === 'grade-only') {
|
||||
return selectedUserIds.value.length > 0 && targetGrade.value.trim()
|
||||
} else if (updateType.value === 'excel-batch') {
|
||||
return excelPreviewData.value.length > 0 && excelPreviewData.value.some((row) => !row.error)
|
||||
} else if (updateType.value === 'status-batch') {
|
||||
return selectedUserIds.value.length > 0 && targetStatus.value && statusReason.value.trim()
|
||||
}
|
||||
return false
|
||||
})
|
||||
|
||||
// 方法
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected.value) {
|
||||
selectedUserIds.value = []
|
||||
} else {
|
||||
selectedUserIds.value = filteredStudents.value.map((s) => s.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file) {
|
||||
processExcelFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDrop = (event) => {
|
||||
event.preventDefault()
|
||||
isDragOver.value = false
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && (file.name.endsWith('.xlsx') || file.name.endsWith('.xls'))) {
|
||||
processExcelFile(file)
|
||||
}
|
||||
}
|
||||
|
||||
const processExcelFile = async (file) => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
// 确保学生数据已加载
|
||||
if (students.value.length === 0) {
|
||||
console.log('学生数据为空,重新获取数据...')
|
||||
await fetchAllStudents()
|
||||
// 等待一小段时间确保数据更新
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
|
||||
// 动态加载XLSX库
|
||||
if (typeof window.XLSX === 'undefined') {
|
||||
await loadXLSX()
|
||||
}
|
||||
|
||||
const reader = new FileReader()
|
||||
reader.onload = (e) => {
|
||||
try {
|
||||
const data = new Uint8Array(e.target.result)
|
||||
const workbook = window.XLSX.read(data, { type: 'array' })
|
||||
const sheetName = workbook.SheetNames[0]
|
||||
const worksheet = workbook.Sheets[sheetName]
|
||||
const jsonData = window.XLSX.utils.sheet_to_json(worksheet)
|
||||
|
||||
parseExcelData(jsonData)
|
||||
} catch (parseError) {
|
||||
console.error('解析Excel文件失败:', parseError)
|
||||
error.value = 'Excel文件格式错误,请检查文件格式'
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
reader.onerror = () => {
|
||||
console.error('读取文件失败')
|
||||
error.value = '读取文件失败,请重试'
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
reader.readAsArrayBuffer(file)
|
||||
} catch (err) {
|
||||
console.error('处理Excel文件失败:', err)
|
||||
const errorMessage = err && err.message ? err.message : '未知错误,请检查文件格式或网络连接'
|
||||
error.value = '处理Excel文件失败: ' + errorMessage
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const parseExcelData = (jsonData) => {
|
||||
const previewData = []
|
||||
const userMap = new Map()
|
||||
|
||||
// 创建用户映射,同时处理用户名标准化
|
||||
students.value.forEach((user) => {
|
||||
if (user.username) {
|
||||
const normalizedUsername = user.username.trim().toLowerCase()
|
||||
userMap.set(normalizedUsername, user)
|
||||
userMap.set(user.username, user)
|
||||
}
|
||||
})
|
||||
|
||||
jsonData.forEach((row, index) => {
|
||||
const username = (row['用户名'] || row['username'] || '').toString().trim()
|
||||
const name = row['姓名'] || row['name'] || ''
|
||||
const newGrade = row['年级'] || row['grade'] ? String(row['年级'] || row['grade']).trim() : ''
|
||||
const newClass = row['班级'] || row['class'] ? String(row['班级'] || row['class']).trim() : ''
|
||||
const newUsername = row['新用户名'] || row['new_username'] || ''
|
||||
|
||||
if (!username) {
|
||||
previewData.push({
|
||||
username: '',
|
||||
name: name,
|
||||
newGrade: newGrade,
|
||||
newClass: newClass,
|
||||
newUsername: newUsername,
|
||||
error: '用户名不能为空'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
const existingUser =
|
||||
userMap.get(username) ||
|
||||
userMap.get(username.toLowerCase()) ||
|
||||
userMap.get(username.toUpperCase())
|
||||
|
||||
if (!existingUser) {
|
||||
previewData.push({
|
||||
username: username,
|
||||
name: name,
|
||||
newGrade: newGrade,
|
||||
newClass: newClass,
|
||||
newUsername: newUsername,
|
||||
error: '用户不存在'
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
previewData.push({
|
||||
userId: existingUser.id,
|
||||
username: username,
|
||||
name: existingUser.name,
|
||||
currentGrade: existingUser.grade,
|
||||
currentClass: existingUser.class,
|
||||
newGrade: newGrade,
|
||||
newClass: newClass,
|
||||
newUsername: newUsername
|
||||
})
|
||||
})
|
||||
|
||||
excelPreviewData.value = previewData
|
||||
loading.value = false
|
||||
}
|
||||
|
||||
const loadXLSX = async () => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (typeof window.XLSX !== 'undefined') {
|
||||
resolve()
|
||||
return
|
||||
}
|
||||
|
||||
const script = document.createElement('script')
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/xlsx@0.18.5/dist/xlsx.full.min.js'
|
||||
script.onload = resolve
|
||||
script.onerror = (error) => {
|
||||
console.error('加载XLSX库失败:', error)
|
||||
reject(new Error('无法加载Excel处理库,请检查网络连接'))
|
||||
}
|
||||
document.head.appendChild(script)
|
||||
})
|
||||
}
|
||||
|
||||
const downloadTemplate = () => {
|
||||
const templateData = [
|
||||
{ 用户名: 'student001', 姓名: '张三', 年级: '2025', 班级: '1班', 新用户名: 'new_student001' },
|
||||
{ 用户名: 'student002', 姓名: '李四', 年级: '2025', 班级: '2班', 新用户名: '' }
|
||||
]
|
||||
|
||||
const ws = window.XLSX.utils.json_to_sheet(templateData)
|
||||
const wb = window.XLSX.utils.book_new()
|
||||
window.XLSX.utils.book_append_sheet(wb, ws, '学生信息')
|
||||
window.XLSX.writeFile(wb, '学生信息批量更新模板.xlsx')
|
||||
}
|
||||
|
||||
const performUpdate = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
if (updateType.value === 'grade-only') {
|
||||
await performGradeUpdate()
|
||||
} else if (updateType.value === 'excel-batch') {
|
||||
await performExcelUpdate()
|
||||
} else if (updateType.value === 'status-batch') {
|
||||
await performStatusUpdate()
|
||||
}
|
||||
|
||||
emit('update-success')
|
||||
emit('close')
|
||||
} catch (err) {
|
||||
console.error('批量更新失败:', err)
|
||||
error.value = '批量更新失败: ' + err.message
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const performGradeUpdate = async () => {
|
||||
const response = await $fetch('/api/admin/users/batch-grade-update', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
userIds: selectedUserIds.value,
|
||||
targetGrade: targetGrade.value.trim(),
|
||||
keepClass: keepClass.value
|
||||
},
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || '批量更新失败')
|
||||
}
|
||||
}
|
||||
|
||||
const performExcelUpdate = async () => {
|
||||
const validUpdates = excelPreviewData.value.filter((row) => !row.error && row.userId)
|
||||
|
||||
if (validUpdates.length === 0) {
|
||||
throw new Error('没有有效的更新数据')
|
||||
}
|
||||
|
||||
// 分批更新,避免请求过大
|
||||
const batchSize = 50
|
||||
for (let i = 0; i < validUpdates.length; i += batchSize) {
|
||||
const batch = validUpdates.slice(i, i + batchSize)
|
||||
const updates = batch.map((row) => ({
|
||||
userId: row.userId,
|
||||
grade: row.newGrade ? String(row.newGrade).trim() : undefined,
|
||||
class: row.newClass ? String(row.newClass).trim() : undefined,
|
||||
username: row.newUsername ? String(row.newUsername).trim() : undefined
|
||||
}))
|
||||
|
||||
await $fetch('/api/admin/users/batch-update', {
|
||||
method: 'POST',
|
||||
body: { updates },
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const performStatusUpdate = async () => {
|
||||
const response = await $fetch('/api/admin/users/batch-status', {
|
||||
method: 'PUT',
|
||||
body: {
|
||||
userIds: selectedUserIds.value,
|
||||
status: targetStatus.value,
|
||||
reason: statusReason.value.trim()
|
||||
},
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || '批量更新状态失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 获取所有学生用户数据
|
||||
const fetchAllStudents = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/admin/users', {
|
||||
method: 'GET',
|
||||
query: {
|
||||
page: 1,
|
||||
limit: 10000,
|
||||
role: 'USER'
|
||||
},
|
||||
...auth.getAuthConfig()
|
||||
})
|
||||
|
||||
if (response.success && response.users) {
|
||||
const users = response.users
|
||||
allStudents.value = users
|
||||
|
||||
const grades = [...new Set(users.map((u) => u.grade).filter(Boolean))].sort()
|
||||
const classes = [...new Set(users.map((u) => u.class).filter(Boolean))].sort()
|
||||
|
||||
allGrades.value = grades
|
||||
allClasses.value = classes
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取所有学生数据失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 监听显示状态
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
fetchAllStudents()
|
||||
// 重置状态
|
||||
selectedUserIds.value = []
|
||||
excelPreviewData.value = []
|
||||
targetStatus.value = ''
|
||||
statusReason.value = ''
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #4a5568;
|
||||
border-radius: 3px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #718096;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,575 @@
|
||||
<template>
|
||||
<div
|
||||
class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2 animate-in fade-in slide-in-from-bottom-4 duration-700"
|
||||
>
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
|
||||
歌曲黑名单管理
|
||||
</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1 font-medium">
|
||||
禁止特定歌曲或包含关键词的歌曲被点播,维护广播内容的健康与合规
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加黑名单项表单 -->
|
||||
<section class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-8 shadow-xl">
|
||||
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] mb-6 px-1">
|
||||
添加黑名单项
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6 items-end">
|
||||
<div class="xl:col-span-2 space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>封禁类型</label
|
||||
>
|
||||
<div class="flex p-1 bg-zinc-950 border border-zinc-800 rounded-lg">
|
||||
<button
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="newItem.type === 'SONG' ? 'bg-zinc-800 text-blue-400' : 'text-zinc-600'"
|
||||
@click="newItem.type = 'SONG'"
|
||||
>
|
||||
<Music :size="12" /> 具体歌曲
|
||||
</button>
|
||||
<button
|
||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
||||
:class="newItem.type === 'KEYWORD' ? 'bg-zinc-800 text-purple-400' : 'text-zinc-600'"
|
||||
@click="newItem.type = 'KEYWORD'"
|
||||
>
|
||||
<Type :size="12" /> 关键词
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="xl:col-span-4 space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
|
||||
{{ newItem.type === 'SONG' ? '歌曲名称 - 艺术家' : '关键词内容' }}
|
||||
</label>
|
||||
<input
|
||||
v-model="newItem.value"
|
||||
type="text"
|
||||
:placeholder="
|
||||
newItem.type === 'SONG' ? '例如: 歌曲名 - 艺术家' : '例如: 敏感词、特定风格'
|
||||
"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="xl:col-span-4 space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>封禁原因 (可选)</label
|
||||
>
|
||||
<input
|
||||
v-model="newItem.reason"
|
||||
type="text"
|
||||
placeholder="简述加入黑名单的原因"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="xl:col-span-2">
|
||||
<button
|
||||
:disabled="!newItem.value || loading"
|
||||
class="w-full flex items-center justify-center gap-2 px-6 py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
@click="addBlacklistItem"
|
||||
>
|
||||
<Plus v-if="!loading" :size="16" />
|
||||
<div
|
||||
v-else
|
||||
class="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
/>
|
||||
{{ loading ? '处理中...' : '添加到黑名单' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 筛选和搜索 -->
|
||||
<div
|
||||
class="bg-zinc-900/20 border border-zinc-800 rounded-xl p-4 flex flex-col md:flex-row gap-4 items-center"
|
||||
>
|
||||
<div class="flex-1 relative w-full">
|
||||
<Search class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700" :size="18" />
|
||||
<input
|
||||
v-model="filters.search"
|
||||
type="text"
|
||||
placeholder="在黑名单中搜索..."
|
||||
class="w-full bg-zinc-950/50 border border-zinc-800 rounded-lg pl-12 pr-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
|
||||
@input="debounceSearch"
|
||||
>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full md:w-auto">
|
||||
<CustomSelect
|
||||
label="类型筛选"
|
||||
:value="typeFilterLabel"
|
||||
:options="['全部类型', '具体歌曲', '关键词']"
|
||||
class="flex-1 md:w-48"
|
||||
@change="handleTypeFilterChange"
|
||||
/>
|
||||
<button
|
||||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-600 hover:text-blue-400 transition-all active:scale-95"
|
||||
@click="loadBlacklist"
|
||||
>
|
||||
<Filter :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 黑名单列表 -->
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between px-2">
|
||||
<h4 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
|
||||
黑名单列表 ({{ pagination.total }} 项)
|
||||
</h4>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 gap-3">
|
||||
<TransitionGroup
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="transform scale-95 opacity-0"
|
||||
enter-to-class="transform scale-100 opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in absolute w-full"
|
||||
leave-from-class="transform scale-100 opacity-100"
|
||||
leave-to-class="transform scale-95 opacity-0"
|
||||
>
|
||||
<div
|
||||
v-for="item in blacklist"
|
||||
:key="item.id"
|
||||
class="group flex flex-col lg:flex-row lg:items-center gap-6 p-6 bg-zinc-900/30 border rounded-xl transition-all hover:border-zinc-700"
|
||||
:class="[item.isActive ? 'border-zinc-800' : 'border-zinc-800/40 opacity-60']"
|
||||
>
|
||||
<div class="flex-1 flex items-start gap-5">
|
||||
<div
|
||||
class="shrink-0 w-12 h-12 rounded-lg flex items-center justify-center border transition-all"
|
||||
:class="[
|
||||
item.type === 'SONG'
|
||||
? 'bg-blue-600/10 text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/5'
|
||||
: 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5'
|
||||
]"
|
||||
>
|
||||
<Music v-if="item.type === 'SONG'" :size="22" />
|
||||
<Type v-else :size="22" />
|
||||
</div>
|
||||
|
||||
<div class="space-y-1.5 min-w-0">
|
||||
<div class="flex items-center gap-3">
|
||||
<span
|
||||
class="text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded border"
|
||||
:class="[
|
||||
item.type === 'SONG'
|
||||
? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
|
||||
: 'bg-purple-600/10 text-purple-400 border-purple-500/20'
|
||||
]"
|
||||
>
|
||||
{{ item.type === 'SONG' ? '具体歌曲' : '关键词' }}
|
||||
</span>
|
||||
<h5 class="text-base font-black text-zinc-100 truncate tracking-tight">
|
||||
{{ item.value }}
|
||||
</h5>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-4">
|
||||
<div class="flex items-center gap-2 text-zinc-500 font-bold text-[11px]">
|
||||
<MessageSquare :size="12" class="text-zinc-700" />
|
||||
原因: {{ item.reason || '未填写原因' }}
|
||||
</div>
|
||||
<div
|
||||
class="flex items-center gap-2 text-zinc-600 font-bold text-[10px] uppercase tracking-tighter"
|
||||
>
|
||||
<Clock :size="11" class="text-zinc-800" />
|
||||
创建时间: {{ formatDate(item.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-end gap-3 pt-4 lg:pt-0 border-t lg:border-t-0 border-zinc-800/50"
|
||||
>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
|
||||
:class="[
|
||||
item.isActive
|
||||
? 'bg-zinc-800 text-zinc-500 hover:text-amber-500 hover:bg-amber-500/5 hover:border-amber-500/10 border border-transparent'
|
||||
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
|
||||
]"
|
||||
@click="toggleItemStatus(item)"
|
||||
>
|
||||
<Power :size="12" />
|
||||
{{ item.isActive ? '禁用' : '启用' }}
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-700 hover:text-red-500 hover:border-red-500/30 transition-all active:scale-95"
|
||||
@click="deleteItem(item)"
|
||||
>
|
||||
<Trash2 :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading && blacklist.length === 0"
|
||||
class="py-20 flex flex-col items-center justify-center space-y-4"
|
||||
>
|
||||
<div
|
||||
class="w-12 h-12 border-4 border-zinc-800 border-t-blue-500 rounded-full animate-spin"
|
||||
/>
|
||||
<p class="text-zinc-500 text-xs font-bold uppercase tracking-widest">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="blacklist.length === 0"
|
||||
class="py-20 flex flex-col items-center justify-center text-center space-y-4 bg-zinc-900/10 border border-zinc-800/40 border-dashed rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-lg bg-zinc-950 border border-zinc-800 flex items-center justify-center text-zinc-800 shadow-xl"
|
||||
>
|
||||
<Ban :size="32" />
|
||||
</div>
|
||||
<div class="space-y-1 px-4">
|
||||
<h6 class="text-sm font-bold text-zinc-600">未找到匹配的黑名单项</h6>
|
||||
<p class="text-[10px] text-zinc-700 font-bold uppercase tracking-widest">
|
||||
请尝试调整筛选条件或添加新项目
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 分页控制 -->
|
||||
<div v-if="pagination.pages > 1" class="flex items-center justify-center gap-4 mt-8">
|
||||
<button
|
||||
:disabled="pagination.page <= 1 || loading"
|
||||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 hover:text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
|
||||
@click="changePage(pagination.page - 1)"
|
||||
>
|
||||
<ChevronLeft :size="20" />
|
||||
</button>
|
||||
<div
|
||||
class="px-6 py-3 bg-zinc-900/50 border border-zinc-800 rounded-lg text-[11px] font-black text-zinc-400 uppercase tracking-widest"
|
||||
>
|
||||
第 {{ pagination.page }} 页 / 共 {{ pagination.pages }} 页
|
||||
</div>
|
||||
<button
|
||||
:disabled="pagination.page >= pagination.pages || loading"
|
||||
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 hover:text-zinc-100 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
|
||||
@click="changePage(pagination.page + 1)"
|
||||
>
|
||||
<ChevronRight :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDeleteDialog"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden animate-in zoom-in-95 duration-300"
|
||||
@click.stop
|
||||
>
|
||||
<div class="flex flex-col items-center py-8 space-y-6 text-center px-8">
|
||||
<div
|
||||
class="w-16 h-16 rounded-lg bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/20 shadow-2xl shadow-red-900/10"
|
||||
>
|
||||
<Trash2 :size="28" />
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-lg font-bold text-zinc-100">确认从黑名单移除</h4>
|
||||
<p class="text-xs text-zinc-500 leading-relaxed">
|
||||
确定要移除
|
||||
<span class="text-zinc-300 font-bold">"{{ deleteTargetItem?.value }}"</span>
|
||||
吗?移除后,该项目将恢复为正常状态。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full pt-4">
|
||||
<button
|
||||
class="flex-1 px-4 py-4 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase tracking-widest"
|
||||
@click="showDeleteDialog = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="flex-1 px-4 py-4 bg-red-600 hover:bg-red-500 text-white text-[10px] font-black rounded-lg shadow-xl shadow-red-900/20 transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50"
|
||||
@click="confirmDelete"
|
||||
>
|
||||
{{ loading ? '删除中...' : '确认移除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, reactive, ref, computed } from 'vue'
|
||||
import {
|
||||
ShieldAlert,
|
||||
Music,
|
||||
Type,
|
||||
Plus,
|
||||
Search,
|
||||
Filter,
|
||||
MessageSquare,
|
||||
Clock,
|
||||
Power,
|
||||
Trash2,
|
||||
Ban,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
|
||||
const blacklist = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
// 删除对话框相关
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTargetItem = ref(null)
|
||||
|
||||
const newItem = reactive({
|
||||
type: 'SONG',
|
||||
value: '',
|
||||
reason: ''
|
||||
})
|
||||
|
||||
const filters = reactive({
|
||||
type: '',
|
||||
search: ''
|
||||
})
|
||||
|
||||
const pagination = reactive({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
pages: 0
|
||||
})
|
||||
|
||||
const typeFilterLabel = computed(() => {
|
||||
if (filters.type === 'SONG') return '具体歌曲'
|
||||
if (filters.type === 'KEYWORD') return '关键词'
|
||||
return '全部类型'
|
||||
})
|
||||
|
||||
let searchTimeout = null
|
||||
|
||||
// 加载黑名单
|
||||
const loadBlacklist = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: pagination.page.toString(),
|
||||
limit: pagination.limit.toString(),
|
||||
...(filters.type && { type: filters.type }),
|
||||
...(filters.search && { search: filters.search })
|
||||
})
|
||||
|
||||
const response = await $fetch(`/api/admin/blacklist?${params}`, {
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
|
||||
blacklist.value = response.blacklist
|
||||
Object.assign(pagination, response.pagination)
|
||||
} catch (err) {
|
||||
error.value = '获取黑名单失败'
|
||||
console.error('获取黑名单失败:', err)
|
||||
showNotification('获取黑名单失败: ' + (err.data?.message || err.message), 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理类型筛选变更
|
||||
const handleTypeFilterChange = (label) => {
|
||||
if (label === '具体歌曲') filters.type = 'SONG'
|
||||
else if (label === '关键词') filters.type = 'KEYWORD'
|
||||
else filters.type = ''
|
||||
|
||||
pagination.page = 1
|
||||
loadBlacklist()
|
||||
}
|
||||
|
||||
// 添加黑名单项
|
||||
const addBlacklistItem = async () => {
|
||||
if (!newItem.value.trim()) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
await $fetch('/api/admin/blacklist', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
type: newItem.type,
|
||||
value: newItem.value.trim(),
|
||||
reason: newItem.reason.trim() || null
|
||||
},
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
|
||||
showNotification('黑名单项添加成功', 'success')
|
||||
|
||||
// 重置表单
|
||||
newItem.value = ''
|
||||
newItem.reason = ''
|
||||
|
||||
// 重新加载列表
|
||||
pagination.page = 1
|
||||
await loadBlacklist()
|
||||
} catch (err) {
|
||||
error.value = err.data?.message || '添加黑名单项失败'
|
||||
console.error('添加黑名单项失败:', err)
|
||||
showNotification('添加失败: ' + error.value, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换项目状态
|
||||
const toggleItemStatus = async (item) => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/admin/blacklist/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
isActive: !item.isActive
|
||||
},
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
|
||||
item.isActive = !item.isActive
|
||||
showNotification(`黑名单项已${item.isActive ? '启用' : '禁用'}`, 'success')
|
||||
} catch (err) {
|
||||
error.value = '更新状态失败'
|
||||
console.error('更新状态失败:', err)
|
||||
showNotification('更新状态失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 删除项目
|
||||
const deleteItem = (item) => {
|
||||
deleteTargetItem.value = item
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetItem.value) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
await $fetch(`/api/admin/blacklist/${deleteTargetItem.value.id}`, {
|
||||
method: 'DELETE',
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
|
||||
showNotification('黑名单项删除成功', 'success')
|
||||
await loadBlacklist()
|
||||
} catch (err) {
|
||||
error.value = '删除失败'
|
||||
console.error('删除失败:', err)
|
||||
showNotification('删除失败: ' + err.message, 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
showDeleteDialog.value = false
|
||||
deleteTargetItem.value = null
|
||||
}
|
||||
}
|
||||
|
||||
// 搜索防抖
|
||||
const debounceSearch = () => {
|
||||
clearTimeout(searchTimeout)
|
||||
searchTimeout = setTimeout(() => {
|
||||
pagination.page = 1
|
||||
loadBlacklist()
|
||||
}, 500)
|
||||
}
|
||||
|
||||
// 切换页面
|
||||
const changePage = (page) => {
|
||||
pagination.page = page
|
||||
loadBlacklist()
|
||||
}
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return '-'
|
||||
return new Date(dateString).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadBlacklist()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.animate-in {
|
||||
animation-duration: 0.5s;
|
||||
animation-fill-mode: both;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-spin {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* 隐藏滚动条但保持功能 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,883 @@
|
||||
<template>
|
||||
<div class="max-w-[1200px] mx-auto space-y-8 pb-20 px-2">
|
||||
<!-- 页面标题 -->
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">数据库操作</h2>
|
||||
<p class="text-xs text-zinc-500 font-medium">
|
||||
执行系统底层维护任务,包括备份、恢复及全局数据重置
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- 操作卡片网格 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div
|
||||
v-for="card in cards"
|
||||
:key="card.id"
|
||||
:class="[
|
||||
'group relative bg-zinc-900/40 border border-zinc-800 rounded-2xl p-8 transition-all hover:border-zinc-700 hover:shadow-2xl hover:shadow-black/40',
|
||||
card.isDanger ? 'hover:border-rose-500/20' : ''
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col h-full space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<div
|
||||
:class="[
|
||||
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 transition-all',
|
||||
card.isDanger
|
||||
? 'text-rose-500 border-rose-500/10'
|
||||
: `text-${card.color}-500 border-${card.color}-500/10 shadow-lg`
|
||||
]"
|
||||
>
|
||||
<component :is="card.icon" class="w-6 h-6" />
|
||||
</div>
|
||||
<span
|
||||
v-if="card.isDanger"
|
||||
class="px-2 py-0.5 bg-rose-500/10 text-rose-500 text-[9px] font-black uppercase tracking-widest border border-rose-500/20 rounded"
|
||||
>高风险操作</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 space-y-2">
|
||||
<h3 class="text-lg font-bold text-zinc-100 group-hover:text-blue-400 transition-colors">
|
||||
{{ card.title }}
|
||||
</h3>
|
||||
<p class="text-xs text-zinc-500 leading-relaxed font-medium">
|
||||
{{ card.desc }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="isLoading(card.id)"
|
||||
class="w-full py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all active:scale-95 bg-zinc-950 border border-zinc-800 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 hover:border-zinc-700"
|
||||
:class="
|
||||
card.isDanger
|
||||
? 'bg-zinc-950 border border-rose-900/30 text-rose-500 hover:bg-rose-600 hover:text-white hover:border-rose-600 shadow-lg shadow-rose-900/5'
|
||||
: ''
|
||||
"
|
||||
@click="openModal(card.id)"
|
||||
>
|
||||
<span v-if="isLoading(card.id)">执行中...</span>
|
||||
<span v-else>{{ card.btnText }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 背景装饰 -->
|
||||
<div
|
||||
:class="[
|
||||
'absolute -right-4 -bottom-4 w-32 h-32 blur-3xl opacity-0 group-hover:opacity-100 transition-opacity rounded-full pointer-events-none',
|
||||
card.isDanger ? 'bg-rose-500/5' : `bg-${card.color}-500/5`
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 维护建议 -->
|
||||
<div class="bg-blue-600/5 border border-blue-500/10 rounded-xl p-5 flex items-start gap-4">
|
||||
<AlertCircle class="text-blue-500 shrink-0 mt-0.5 w-[18px] h-[18px]" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-[11px] font-bold text-zinc-300">数据库维护建议</p>
|
||||
<p class="text-[10px] text-zinc-500 leading-relaxed">
|
||||
建议每周进行一次全量备份。在执行“恢复备份”或“重置数据库”前,请务必先创建一份当前的数据备份,以免误操作造成不可挽回的损失。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 创建备份模态框 -->
|
||||
<div
|
||||
v-if="activeModal === 'backup'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">创建数据库备份</h3>
|
||||
<button
|
||||
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="space-y-2">
|
||||
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
|
||||
选择包含的内容
|
||||
</p>
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
v-for="(item, i) in backupOptions"
|
||||
:key="i"
|
||||
class="flex items-start gap-4 p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl cursor-pointer hover:border-zinc-700 transition-all group"
|
||||
>
|
||||
<div class="shrink-0 mt-0.5">
|
||||
<input
|
||||
v-model="createForm[item.key]"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<p
|
||||
class="text-xs font-bold text-zinc-200 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ item.label }}
|
||||
</p>
|
||||
<p class="text-[10px] text-zinc-600 font-medium mt-0.5">{{ item.desc }}</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-xl">
|
||||
<p class="text-[10px] text-zinc-500 text-center italic">
|
||||
备份文件将以 .json 格式生成并自动下载
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="createLoading"
|
||||
class="px-8 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="createBackup"
|
||||
>
|
||||
{{ createLoading ? '正在导出...' : '开始导出' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 恢复备份模态框 -->
|
||||
<div
|
||||
v-if="activeModal === 'restore'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">恢复数据库备份</h3>
|
||||
<button
|
||||
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div
|
||||
class="border-2 border-dashed border-zinc-800 rounded-2xl p-10 flex flex-col items-center justify-center text-center group hover:border-emerald-500/50 hover:bg-emerald-500/5 transition-all cursor-pointer"
|
||||
@click="$refs.fileInput.click()"
|
||||
@dragover.prevent
|
||||
@drop.prevent="handleFileDrop"
|
||||
>
|
||||
<Upload
|
||||
class="w-8 h-8 text-zinc-700 mb-4 group-hover:text-emerald-500 transition-colors"
|
||||
/>
|
||||
<h5 class="text-sm font-bold text-zinc-300">
|
||||
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
|
||||
</h5>
|
||||
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
|
||||
仅支持 VoiceHub 导出的 .json 格式
|
||||
</p>
|
||||
<input
|
||||
ref="fileInput"
|
||||
accept=".json"
|
||||
class="hidden"
|
||||
type="file"
|
||||
@change="handleFileSelect"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>恢复模式</label
|
||||
>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<button
|
||||
:class="[
|
||||
'p-4 border rounded-xl text-left transition-all',
|
||||
restoreForm.mode === 'merge'
|
||||
? 'bg-zinc-950 border-emerald-500/30'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
@click="restoreForm.mode = 'merge'"
|
||||
>
|
||||
<h6
|
||||
:class="[
|
||||
'text-xs font-bold',
|
||||
restoreForm.mode === 'merge' ? 'text-emerald-400' : 'text-zinc-500'
|
||||
]"
|
||||
>
|
||||
增量模式
|
||||
</h6>
|
||||
<p class="text-[9px] text-zinc-600 uppercase mt-0.5">仅导入不重复的新记录</p>
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'p-4 border rounded-xl text-left transition-all',
|
||||
restoreForm.mode === 'replace'
|
||||
? 'bg-zinc-950 border-emerald-500/30'
|
||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
||||
]"
|
||||
@click="restoreForm.mode = 'replace'"
|
||||
>
|
||||
<h6
|
||||
:class="[
|
||||
'text-xs font-bold',
|
||||
restoreForm.mode === 'replace' ? 'text-emerald-400' : 'text-zinc-500'
|
||||
]"
|
||||
>
|
||||
覆盖模式
|
||||
</h6>
|
||||
<p class="text-[9px] text-zinc-600 uppercase mt-0.5">清空现有表后完整恢复</p>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="restoreForm.mode === 'replace' && hasSuperAdminInBackup"
|
||||
class="p-4 bg-zinc-950 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<label class="flex items-start gap-3 cursor-pointer">
|
||||
<input
|
||||
v-model="restoreForm.overwriteSuperAdmin"
|
||||
type="checkbox"
|
||||
class="mt-0.5 accent-emerald-500"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-zinc-200">覆盖备份中的超级管理员账号数据</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-1">
|
||||
关闭时将保留当前超级管理员账号及其第三方绑定、2FA等关联数据
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-4 bg-amber-500/5 border border-amber-500/10 rounded-xl flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle class="text-amber-500 shrink-0 mt-0.5 w-4 h-4" />
|
||||
<p class="text-[10px] text-zinc-500 leading-normal font-medium">
|
||||
注意:覆盖模式将永久清空当前数据库中对应的表内容。此操作将导致现有会话中断。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="uploadLoading || !selectedFile"
|
||||
class="px-8 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="restoreBackup"
|
||||
>
|
||||
{{ uploadLoading ? restoreProgress || '正在恢复...' : '确认并开始恢复' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置序列模态框 -->
|
||||
<div
|
||||
v-if="activeModal === 'reset-seq'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">重置数据表序列</h3>
|
||||
<button
|
||||
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>选择目标表</label
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="sequenceForm.table"
|
||||
:options="tableOptions"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-8 h-8 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center"
|
||||
>
|
||||
<AlertCircle class="w-4 h-4" />
|
||||
</div>
|
||||
<h6 class="text-xs font-bold text-zinc-300 uppercase tracking-widest">
|
||||
什么是重置序列?
|
||||
</h6>
|
||||
</div>
|
||||
<p class="text-[11px] text-zinc-500 leading-relaxed font-medium">
|
||||
如果您的数据表 ID
|
||||
出现了断档或在手动操作数据库后无法自增,重置序列可以将数据库底层的计数器更新为当前 ID
|
||||
最大值 +1,从而解决 ID 冲突导致的写入失败问题。此操作不会修改任何现有数据。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="sequenceLoading || !sequenceForm.table"
|
||||
class="px-8 py-2 bg-amber-600 hover:bg-amber-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="resetSequence"
|
||||
>
|
||||
{{ sequenceLoading ? '正在重置...' : '执行重置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 重置数据库模态框 -->
|
||||
<div
|
||||
v-if="activeModal === 'reset-db'"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||
<div
|
||||
class="relative bg-zinc-900 border border-zinc-800 rounded-3xl w-full max-w-lg overflow-hidden shadow-2xl animate-in fade-in zoom-in duration-200"
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-rose-500 tracking-tight">危险操作:重置数据库</h3>
|
||||
<button
|
||||
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
<X class="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-6">
|
||||
<div
|
||||
class="p-6 bg-rose-600/10 border border-rose-500/20 rounded-2xl flex flex-col items-center text-center"
|
||||
>
|
||||
<Trash2 class="text-rose-500 mb-4 w-12 h-12" />
|
||||
<h4 class="text-lg font-black text-rose-500 tracking-tight">
|
||||
您正在执行极其危险的操作!
|
||||
</h4>
|
||||
<p class="text-xs text-zinc-500 mt-2 font-medium leading-relaxed">
|
||||
重置操作将永久删除系统中的所有
|
||||
<span class="text-zinc-300 font-bold"
|
||||
>歌曲、投稿记录、排期文件、通知、日志及除您以外的用户账号</span
|
||||
>。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-3">
|
||||
<label
|
||||
class="text-[11px] font-black text-rose-500/80 uppercase tracking-widest px-1 flex items-center justify-center gap-2"
|
||||
>
|
||||
请输入以下代码以确认操作
|
||||
</label>
|
||||
<div
|
||||
class="bg-zinc-950 border border-rose-900/30 rounded-xl px-4 py-3 font-mono text-[10px] text-rose-400 text-center select-all"
|
||||
>
|
||||
{{ CONFIRM_CODE }}
|
||||
</div>
|
||||
<input
|
||||
v-model="resetConfirmText"
|
||||
type="text"
|
||||
placeholder="在此输入上述代码..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-rose-500/40 text-center font-mono placeholder:text-zinc-700"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||
<button
|
||||
class="py-3 bg-zinc-900 hover:bg-zinc-800 text-zinc-500 text-xs font-black rounded-xl transition-all uppercase tracking-widest"
|
||||
@click="activeModal = 'none'"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="resetConfirmText !== CONFIRM_CODE || resetLoading"
|
||||
class="py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg disabled:bg-zinc-800 disabled:text-zinc-600 disabled:cursor-not-allowed disabled:border-zinc-700"
|
||||
:class="
|
||||
resetConfirmText === CONFIRM_CODE
|
||||
? 'bg-rose-600 hover:bg-rose-500 text-white shadow-rose-900/20 active:scale-95'
|
||||
: ''
|
||||
"
|
||||
@click="resetDatabase"
|
||||
>
|
||||
{{ resetLoading ? '正在重置...' : '确认彻底重置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue'
|
||||
import { Download, Upload, RotateCw, Trash2, AlertCircle, X } from 'lucide-vue-next'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
const auth = useAuth()
|
||||
|
||||
// 状态
|
||||
const activeModal = ref('none')
|
||||
const createLoading = ref(false)
|
||||
const uploadLoading = ref(false)
|
||||
const sequenceLoading = ref(false)
|
||||
const resetLoading = ref(false)
|
||||
const selectedFile = ref(null)
|
||||
const resetConfirmText = ref('')
|
||||
const restoreProgress = ref('')
|
||||
const hasSuperAdminInBackup = ref(false)
|
||||
const CONFIRM_CODE = 'CONFIRM-DATABASE-RESET-OPERATION'
|
||||
|
||||
// 卡片配置
|
||||
const cards = [
|
||||
{
|
||||
id: 'backup',
|
||||
title: '创建备份',
|
||||
desc: '导出当前数据库的所有数据到文件,用于安全备份或迁移。',
|
||||
icon: Download,
|
||||
color: 'blue',
|
||||
btnText: '创建备份文件'
|
||||
},
|
||||
{
|
||||
id: 'restore',
|
||||
title: '恢复备份',
|
||||
desc: '从之前导出的备份文件中恢复系统数据。',
|
||||
icon: Upload,
|
||||
color: 'emerald',
|
||||
btnText: '选择备份文件'
|
||||
},
|
||||
{
|
||||
id: 'reset-seq',
|
||||
title: '重置序列',
|
||||
desc: '修复数据表的自增ID序列,确保新记录的ID从正确值开始。',
|
||||
icon: RotateCw,
|
||||
color: 'amber',
|
||||
btnText: '开始重置序列'
|
||||
},
|
||||
{
|
||||
id: 'reset-db',
|
||||
title: '重置数据库',
|
||||
desc: '清空除管理员账号外的所有系统数据。此操作不可撤销。',
|
||||
icon: Trash2,
|
||||
color: 'rose',
|
||||
btnText: '立即重置数据库',
|
||||
isDanger: true
|
||||
}
|
||||
]
|
||||
|
||||
// 备份选项
|
||||
const backupOptions = [
|
||||
{
|
||||
key: 'includeSongs',
|
||||
label: '歌曲与排期数据',
|
||||
desc: '包含所有歌曲库、用户投稿记录及历史播音排期'
|
||||
},
|
||||
{
|
||||
key: 'includeSystemData',
|
||||
label: '系统配置信息',
|
||||
desc: '包含站点设置、黑名单、播出时段等全局参数'
|
||||
},
|
||||
{
|
||||
key: 'includeUsers',
|
||||
label: '用户账户数据',
|
||||
desc: '包含所有注册用户的权限、偏好设置(不含管理员敏感信息)'
|
||||
}
|
||||
]
|
||||
|
||||
// 表单数据
|
||||
const createForm = ref({
|
||||
includeSongs: true,
|
||||
includeUsers: true,
|
||||
includeSystemData: true
|
||||
})
|
||||
|
||||
const restoreForm = ref({
|
||||
mode: 'merge',
|
||||
overwriteSuperAdmin: false
|
||||
})
|
||||
|
||||
const sequenceForm = ref({
|
||||
table: '重置所有表 (All)'
|
||||
})
|
||||
|
||||
const tableOptions = [
|
||||
'重置所有表 (All)',
|
||||
'歌曲表 (Song)',
|
||||
'用户表 (User)',
|
||||
'投票表 (Vote)',
|
||||
'排期表 (Schedule)',
|
||||
'通知表 (Notification)',
|
||||
'通知设置表 (NotificationSettings)',
|
||||
'播放时段表 (PlayTime)',
|
||||
'学期表 (Semester)',
|
||||
'系统设置表 (SystemSettings)',
|
||||
'歌曲黑名单表 (SongBlacklist)'
|
||||
]
|
||||
|
||||
const labelToValueMap = {
|
||||
'重置所有表 (All)': 'all',
|
||||
'歌曲表 (Song)': 'Song',
|
||||
'用户表 (User)': 'User',
|
||||
'投票表 (Vote)': 'Vote',
|
||||
'排期表 (Schedule)': 'Schedule',
|
||||
'通知表 (Notification)': 'Notification',
|
||||
'通知设置表 (NotificationSettings)': 'NotificationSettings',
|
||||
'播放时段表 (PlayTime)': 'PlayTime',
|
||||
'学期表 (Semester)': 'Semester',
|
||||
'系统设置表 (SystemSettings)': 'SystemSettings',
|
||||
'歌曲黑名单表 (SongBlacklist)': 'SongBlacklist'
|
||||
}
|
||||
|
||||
// 辅助函数
|
||||
const isLoading = (id) => {
|
||||
if (id === 'backup') return createLoading.value
|
||||
if (id === 'restore') return uploadLoading.value
|
||||
if (id === 'reset-seq') return sequenceLoading.value
|
||||
if (id === 'reset-db') return resetLoading.value
|
||||
return false
|
||||
}
|
||||
|
||||
const openModal = (id) => {
|
||||
activeModal.value = id
|
||||
if (id === 'reset-db') {
|
||||
resetConfirmText.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 文件处理
|
||||
const parseBackupSuperAdmin = async (file) => {
|
||||
try {
|
||||
const fileContent = await file.text()
|
||||
const backupData = JSON.parse(fileContent)
|
||||
const users = Array.isArray(backupData?.data?.users) ? backupData.data.users : []
|
||||
hasSuperAdminInBackup.value = users.some((item) => item?.role === 'SUPER_ADMIN')
|
||||
if (!hasSuperAdminInBackup.value) {
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
}
|
||||
} catch (error) {
|
||||
hasSuperAdminInBackup.value = false
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
showNotification('无法解析备份文件,请检查文件格式是否正确。', 'error')
|
||||
console.error('解析备份文件失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileSelect = async (event) => {
|
||||
const file = event.target.files[0]
|
||||
if (file && file.type === 'application/json') {
|
||||
selectedFile.value = file
|
||||
await parseBackupSuperAdmin(file)
|
||||
} else {
|
||||
showNotification('请选择有效的JSON备份文件', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const handleFileDrop = async (event) => {
|
||||
const file = event.dataTransfer.files[0]
|
||||
if (file && file.type === 'application/json') {
|
||||
selectedFile.value = file
|
||||
await parseBackupSuperAdmin(file)
|
||||
} else {
|
||||
showNotification('请选择有效的JSON备份文件', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// API 操作
|
||||
const createBackup = async () => {
|
||||
createLoading.value = true
|
||||
try {
|
||||
let tables = 'all'
|
||||
if (createForm.value.includeUsers && createForm.value.includeSongs) {
|
||||
tables = 'all'
|
||||
} else if (createForm.value.includeUsers) {
|
||||
tables = 'users'
|
||||
} else if (createForm.value.includeSongs) {
|
||||
tables = 'songs'
|
||||
} else if (createForm.value.includeSystemData) {
|
||||
tables = ['systemSettings']
|
||||
} else {
|
||||
throw new Error('请至少选择一项备份内容')
|
||||
}
|
||||
|
||||
const response = await $fetch('/api/admin/backup/export', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tables,
|
||||
includeSystemData: createForm.value.includeSystemData
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success && response.backup) {
|
||||
if (response.backup.downloadMode === 'direct' && response.backup.data) {
|
||||
const blob = new Blob([JSON.stringify(response.backup.data, null, 2)], {
|
||||
type: 'application/json'
|
||||
})
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = response.backup.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
showNotification('备份文件已下载', 'success')
|
||||
activeModal.value = 'none'
|
||||
} else if (response.backup.downloadMode === 'file' && response.backup.filename) {
|
||||
const downloadUrl = `/api/admin/backup/download/${response.backup.filename}`
|
||||
const downloadResponse = await fetch(downloadUrl, {
|
||||
method: 'GET',
|
||||
credentials: 'include'
|
||||
})
|
||||
if (!downloadResponse.ok) throw new Error(`下载失败: ${downloadResponse.status}`)
|
||||
const blob = await downloadResponse.blob()
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = response.backup.filename
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
URL.revokeObjectURL(url)
|
||||
showNotification('备份文件已下载', 'success')
|
||||
activeModal.value = 'none'
|
||||
}
|
||||
} else {
|
||||
throw new Error(response.message || '备份创建失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('创建备份失败:', error)
|
||||
showNotification('创建备份失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
createLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const restoreBackup = async () => {
|
||||
if (!selectedFile.value) return showNotification('请选择备份文件', 'error')
|
||||
|
||||
uploadLoading.value = true
|
||||
restoreProgress.value = '正在读取文件...'
|
||||
|
||||
try {
|
||||
const fileContent = await selectedFile.value.text()
|
||||
let backupData
|
||||
try {
|
||||
backupData = JSON.parse(fileContent)
|
||||
} catch {
|
||||
throw new Error('无法解析备份文件,文件格式不正确')
|
||||
}
|
||||
|
||||
if (!backupData.data) throw new Error('备份文件缺少数据部分')
|
||||
const fileHasSuperAdmin = hasSuperAdminInBackup.value
|
||||
if (!fileHasSuperAdmin) {
|
||||
restoreForm.value.overwriteSuperAdmin = false
|
||||
}
|
||||
|
||||
let preservedSuperAdminIds = []
|
||||
let temporaryPreservedUserId = null
|
||||
|
||||
if (restoreForm.value.mode === 'replace') {
|
||||
restoreProgress.value = '正在清空现有数据...'
|
||||
const clearResult = await $fetch('/api/admin/backup/clear', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
|
||||
hasSuperAdminInBackup: fileHasSuperAdmin
|
||||
}
|
||||
})
|
||||
if (!clearResult.success) throw new Error(clearResult.message || '清空数据失败')
|
||||
preservedSuperAdminIds = clearResult.preservedSuperAdminIds || []
|
||||
temporaryPreservedUserId = clearResult.temporaryPreservedUserId || null
|
||||
}
|
||||
|
||||
const tableOrder = [
|
||||
'users',
|
||||
'userIdentities',
|
||||
'systemSettings',
|
||||
'semesters',
|
||||
'playTimes',
|
||||
'songs',
|
||||
'votes',
|
||||
'schedules',
|
||||
'notificationSettings',
|
||||
'notifications',
|
||||
'songBlacklist',
|
||||
'userStatusLogs'
|
||||
]
|
||||
|
||||
const mappings = {
|
||||
users: {},
|
||||
songs: {},
|
||||
meta: {
|
||||
preservedSuperAdminIds,
|
||||
temporaryPreservedUserId
|
||||
}
|
||||
}
|
||||
const CHUNK_SIZE = 50
|
||||
let totalProcessed = 0
|
||||
let totalRecords = 0
|
||||
|
||||
for (const tableName of tableOrder) {
|
||||
if (backupData.data[tableName] && Array.isArray(backupData.data[tableName])) {
|
||||
totalRecords += backupData.data[tableName].length
|
||||
}
|
||||
}
|
||||
|
||||
for (const tableName of tableOrder) {
|
||||
const records = backupData.data[tableName]
|
||||
if (!records || !Array.isArray(records) || records.length === 0) continue
|
||||
|
||||
for (let i = 0; i < records.length; i += CHUNK_SIZE) {
|
||||
const chunk = records.slice(i, i + CHUNK_SIZE)
|
||||
const progressPercent = Math.round((totalProcessed / totalRecords) * 100)
|
||||
restoreProgress.value = `正在恢复 ${tableName} (${i + 1}-${Math.min(i + CHUNK_SIZE, records.length)}/${records.length}) ${progressPercent}%`
|
||||
|
||||
const response = await $fetch('/api/admin/backup/restore-chunk', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
tableName,
|
||||
records: chunk,
|
||||
mappings,
|
||||
mode: restoreForm.value.mode,
|
||||
overwriteSuperAdmin: restoreForm.value.overwriteSuperAdmin,
|
||||
hasSuperAdminInBackup: fileHasSuperAdmin
|
||||
}
|
||||
})
|
||||
|
||||
if (!response.success) throw new Error(response.message || `恢复表 ${tableName} 失败`)
|
||||
if (response.newMappings) {
|
||||
if (response.newMappings.users) Object.assign(mappings.users, response.newMappings.users)
|
||||
if (response.newMappings.songs) Object.assign(mappings.songs, response.newMappings.songs)
|
||||
}
|
||||
totalProcessed += chunk.length
|
||||
}
|
||||
}
|
||||
|
||||
restoreProgress.value = '正在修复数据表序列...'
|
||||
const sequenceResult = await $fetch('/api/admin/fix-sequence', {
|
||||
method: 'POST',
|
||||
body: { table: 'all' }
|
||||
})
|
||||
if (!sequenceResult.success) {
|
||||
throw new Error(sequenceResult.message || sequenceResult.error || '序列修复失败')
|
||||
}
|
||||
|
||||
restoreProgress.value = '正在重载SMTP配置...'
|
||||
const smtpReloadResult = await $fetch('/api/admin/smtp/reload', {
|
||||
method: 'POST'
|
||||
})
|
||||
if (!smtpReloadResult.success) {
|
||||
throw new Error(smtpReloadResult.message || 'SMTP配置重载失败')
|
||||
}
|
||||
|
||||
const shouldFinalizeTempUser =
|
||||
restoreForm.value.mode === 'replace' &&
|
||||
restoreForm.value.overwriteSuperAdmin &&
|
||||
fileHasSuperAdmin &&
|
||||
temporaryPreservedUserId
|
||||
|
||||
if (shouldFinalizeTempUser) {
|
||||
const restoredUserIds = Object.values(mappings.users).map((id) => Number(id))
|
||||
if (!restoredUserIds.includes(Number(temporaryPreservedUserId))) {
|
||||
restoreProgress.value = '正在完成管理员替换...'
|
||||
await $fetch('/api/admin/backup/clear', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
finalizeTempUser: true
|
||||
}
|
||||
})
|
||||
}
|
||||
showNotification('数据库恢复成功,正在重新登录', 'success')
|
||||
activeModal.value = 'none'
|
||||
setTimeout(() => {
|
||||
if (auth.logout) {
|
||||
auth.logout()
|
||||
}
|
||||
localStorage.removeItem('auth-token')
|
||||
localStorage.removeItem('user-info')
|
||||
window.location.href = '/'
|
||||
}, 1200)
|
||||
return
|
||||
}
|
||||
|
||||
showNotification('数据库恢复成功', 'success')
|
||||
activeModal.value = 'none'
|
||||
} catch (error) {
|
||||
console.error('恢复备份失败:', error)
|
||||
showNotification('恢复备份失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
uploadLoading.value = false
|
||||
restoreProgress.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const resetSequence = async () => {
|
||||
if (!sequenceForm.value.table) return
|
||||
sequenceLoading.value = true
|
||||
try {
|
||||
const tableValue = labelToValueMap[sequenceForm.value.table] || sequenceForm.value.table
|
||||
const response = await $fetch('/api/admin/fix-sequence', {
|
||||
method: 'POST',
|
||||
body: { table: tableValue }
|
||||
})
|
||||
if (response.success) {
|
||||
showNotification(response.message || '序列重置成功', 'success')
|
||||
activeModal.value = 'none'
|
||||
} else {
|
||||
throw new Error(response.error || response.message || '重置失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置序列失败:', error)
|
||||
showNotification('重置序列失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
sequenceLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const resetDatabase = async () => {
|
||||
if (resetConfirmText.value !== CONFIRM_CODE) return
|
||||
resetLoading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/admin/database/reset', { method: 'POST' })
|
||||
if (response.success) {
|
||||
showNotification('数据库已成功重置', 'success')
|
||||
activeModal.value = 'none'
|
||||
setTimeout(() => window.location.reload(), 1500)
|
||||
} else {
|
||||
throw new Error(response.message || '重置失败')
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('重置数据库失败:', error)
|
||||
showNotification('重置数据库失败: ' + error.message, 'error')
|
||||
} finally {
|
||||
resetLoading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,345 @@
|
||||
<template>
|
||||
<div class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] overflow-hidden">
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-6 border-b border-zinc-800 bg-zinc-900/20">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="w-10 h-10 rounded-2xl bg-blue-600/10 flex items-center justify-center border border-blue-500/20"
|
||||
>
|
||||
<Mail :size="20" class="text-blue-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">邮件模板管理</h3>
|
||||
<p class="text-[10px] text-zinc-500 mt-0.5">自定义系统邮件的主题与内容,支持变量替换</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
v-if="selected"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-[11px] font-bold rounded-xl transition-all"
|
||||
:disabled="saving"
|
||||
@click="doPreview"
|
||||
>
|
||||
<Eye :size="14" /> {{ previewHtml ? '刷新预览' : '实时预览' }}
|
||||
</button>
|
||||
<button
|
||||
v-if="selected"
|
||||
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-[11px] font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
:disabled="saving"
|
||||
@click="save"
|
||||
>
|
||||
<Save :size="14" /> {{ saving ? '保存中...' : '更新模板' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 min-h-[650px]">
|
||||
<!-- 左侧:模板列表 -->
|
||||
<div class="lg:col-span-3 border-r border-zinc-800 bg-zinc-900/10 flex flex-col">
|
||||
<div class="p-4 border-b border-zinc-800/50">
|
||||
<div class="relative">
|
||||
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="搜索模板..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg pl-9 pr-3 py-2 text-[10px] text-zinc-400 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1">
|
||||
<button
|
||||
v-for="t in templates"
|
||||
:key="t.key"
|
||||
class="w-full group flex flex-col gap-1 p-3 rounded-2xl transition-all text-left border"
|
||||
:class="[
|
||||
selectedKey === t.key
|
||||
? 'bg-blue-600/10 border-blue-500/20'
|
||||
: 'bg-transparent border-transparent hover:bg-zinc-800/40'
|
||||
]"
|
||||
@click="select(t)"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<span
|
||||
class="text-[11px] font-black tracking-tight transition-colors"
|
||||
:class="
|
||||
selectedKey === t.key
|
||||
? 'text-blue-400'
|
||||
: 'text-zinc-300 group-hover:text-zinc-100'
|
||||
"
|
||||
>
|
||||
{{ t.name }}
|
||||
</span>
|
||||
<div class="flex gap-1">
|
||||
<span
|
||||
v-if="t.isBuiltin && !t.isOverridden"
|
||||
class="px-1.5 py-0.5 rounded-md bg-zinc-800 text-[8px] font-black text-zinc-500 uppercase tracking-tighter"
|
||||
>
|
||||
内置
|
||||
</span>
|
||||
<span
|
||||
v-if="t.isOverridden"
|
||||
class="px-1.5 py-0.5 rounded-md bg-emerald-500/10 text-[8px] font-black text-emerald-500 uppercase tracking-tighter"
|
||||
>
|
||||
自定义
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<span class="text-[9px] font-bold text-zinc-600 font-mono">{{ t.key }}</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:编辑器区域 -->
|
||||
<div class="lg:col-span-9 flex flex-col bg-zinc-950/20 overflow-hidden">
|
||||
<div v-if="selected" class="flex-1 overflow-y-auto custom-scrollbar p-8">
|
||||
<div class="max-w-4xl mx-auto space-y-8">
|
||||
<!-- 基础信息与主题 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>模板名称</label
|
||||
>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>邮件主题</label
|
||||
>
|
||||
<span class="text-[9px] text-zinc-500 font-bold uppercase">支持变量</span>
|
||||
</div>
|
||||
<input
|
||||
v-model="form.subject"
|
||||
type="text"
|
||||
placeholder="请输入邮件主题..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 变量提示 -->
|
||||
<div class="bg-blue-600/5 border border-blue-500/10 rounded-2xl p-4 flex gap-4">
|
||||
<div
|
||||
class="w-8 h-8 rounded-xl bg-blue-500/10 flex items-center justify-center shrink-0"
|
||||
>
|
||||
<Info :size="16" class="text-blue-500" />
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<h4 class="text-[10px] font-black text-blue-400 uppercase tracking-widest">
|
||||
可用变量
|
||||
</h4>
|
||||
<p class="text-[11px] text-zinc-500 leading-relaxed font-mono">
|
||||
<template v-if="selected.key === 'verification.code'">
|
||||
<span v-pre>{{ name }}, {{ email }}, {{ code }}, {{ expiresInMinutes }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<span v-pre>{{ title }}, {{ message }}, {{ actionUrl }}, {{ fromName }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 内容编辑器 -->
|
||||
<div class="space-y-3">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>HTML 内容</label
|
||||
>
|
||||
<span
|
||||
class="px-2 py-0.5 rounded-md bg-zinc-900 text-[8px] font-black text-zinc-500 uppercase"
|
||||
>Handlebars</span
|
||||
>
|
||||
</div>
|
||||
<button
|
||||
v-if="selected.isOverridden"
|
||||
class="flex items-center gap-1.5 text-[10px] font-bold text-rose-500 hover:text-rose-400 transition-colors"
|
||||
@click="restore"
|
||||
>
|
||||
<RotateCcw :size="12" /> 恢复默认配置
|
||||
</button>
|
||||
</div>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute right-4 top-4 p-2 rounded-lg bg-zinc-900/50 border border-zinc-800 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<Code :size="14" class="text-zinc-500" />
|
||||
</div>
|
||||
<textarea
|
||||
v-model="form.html"
|
||||
rows="16"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl p-6 text-[11px] text-zinc-300 font-mono leading-relaxed focus:outline-none focus:border-blue-500/30 custom-scrollbar"
|
||||
placeholder="<html>..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 预览框 -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="transform translate-y-4 opacity-0"
|
||||
enter-to-class="transform translate-y-0 opacity-100"
|
||||
>
|
||||
<div v-if="previewHtml" class="space-y-4 pt-4 border-t border-zinc-800">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<label
|
||||
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<Eye :size="14" /> 实时预览:
|
||||
<span class="text-zinc-400 font-normal normal-case">{{ previewSubject }}</span>
|
||||
</label>
|
||||
<button
|
||||
class="text-[10px] font-bold text-zinc-500 hover:text-zinc-300"
|
||||
@click="previewHtml = ''"
|
||||
>
|
||||
隐藏预览
|
||||
</button>
|
||||
</div>
|
||||
<div class="rounded-2xl overflow-hidden border border-zinc-800 bg-white shadow-2xl">
|
||||
<iframe :srcdoc="previewHtml" class="w-full h-[500px] border-none" />
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else
|
||||
class="flex-1 flex flex-col items-center justify-center text-zinc-700 space-y-4"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-900/50 flex items-center justify-center border border-zinc-800/50"
|
||||
>
|
||||
<Mail :size="32" class="text-zinc-800" />
|
||||
</div>
|
||||
<p class="text-xs font-bold tracking-widest uppercase">请在左侧选择一个邮件模板</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { Mail, Info, Save, RotateCcw, Eye, Code, Search, ChevronRight } from 'lucide-vue-next'
|
||||
|
||||
type TemplateItem = {
|
||||
key: string
|
||||
name: string
|
||||
subject: string
|
||||
html: string
|
||||
isBuiltin: boolean
|
||||
isOverridden?: boolean
|
||||
}
|
||||
|
||||
const { success, error } = useToast()
|
||||
const templates = ref<TemplateItem[]>([])
|
||||
const selectedKey = ref<string>('')
|
||||
const form = ref<{ key: string; name: string; subject: string; html: string }>({
|
||||
key: '',
|
||||
name: '',
|
||||
subject: '',
|
||||
html: ''
|
||||
})
|
||||
const saving = ref(false)
|
||||
const previewHtml = ref<string>('')
|
||||
const previewSubject = ref<string>('')
|
||||
|
||||
const selected = computed(() => templates.value.find((t) => t.key === selectedKey.value))
|
||||
|
||||
async function loadList() {
|
||||
const res: any = await $fetch('/api/admin/email-templates')
|
||||
const list = Array.isArray(res?.templates) ? res.templates : []
|
||||
templates.value = list.map((t: any) => ({
|
||||
key: String(t.key),
|
||||
name: String(t.name),
|
||||
subject: String(t.subject),
|
||||
html: String(t.html),
|
||||
isBuiltin: !!t.isBuiltin,
|
||||
isOverridden: !!t.isOverridden
|
||||
}))
|
||||
if (!selectedKey.value && templates.value.length) select(templates.value[0])
|
||||
}
|
||||
|
||||
function select(t: TemplateItem) {
|
||||
selectedKey.value = t.key
|
||||
form.value = { key: t.key, name: t.name, subject: t.subject, html: t.html }
|
||||
previewHtml.value = ''
|
||||
previewSubject.value = ''
|
||||
}
|
||||
|
||||
async function save() {
|
||||
try {
|
||||
saving.value = true
|
||||
await $fetch('/api/admin/email-templates', { method: 'POST', body: form.value })
|
||||
success('模板已保存')
|
||||
await loadList()
|
||||
} catch (e: any) {
|
||||
error(e?.data?.message || '保存失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function restore() {
|
||||
try {
|
||||
saving.value = true
|
||||
await $fetch(`/api/admin/email-templates?key=${encodeURIComponent(form.value.key)}`, {
|
||||
method: 'DELETE'
|
||||
})
|
||||
success('已恢复默认模板')
|
||||
await loadList()
|
||||
} catch (e: any) {
|
||||
error(e?.data?.message || '恢复失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function doPreview() {
|
||||
try {
|
||||
const defaultData =
|
||||
form.value.key === 'verification.code'
|
||||
? { name: '张三', email: 'example@school.edu', code: '123456', expiresInMinutes: 5 }
|
||||
: {
|
||||
title: '系统通知',
|
||||
message: '这是一条预览内容\n支持换行与链接',
|
||||
actionUrl: 'https://example.com'
|
||||
}
|
||||
const res = await $fetch('/api/admin/email-templates/preview', {
|
||||
method: 'POST',
|
||||
body: { key: form.value.key, data: defaultData }
|
||||
})
|
||||
previewHtml.value = res.html
|
||||
previewSubject.value = res.subject
|
||||
} catch (e: any) {
|
||||
error(e?.data?.message || '预览失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,759 @@
|
||||
<template>
|
||||
<div class="max-w-[1400px] mx-auto space-y-8 pb-20">
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">向用户发送通知</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">即时推送系统公告、活动信息或重要提醒至指定群体</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
||||
<!-- 左侧:编辑区 -->
|
||||
<div class="lg:col-span-7 space-y-6">
|
||||
<div class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-8 shadow-xl">
|
||||
<div class="space-y-6">
|
||||
<!-- 标题 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
|
||||
>通知标题</label
|
||||
>
|
||||
<input
|
||||
v-model="form.title"
|
||||
type="text"
|
||||
placeholder="请输入通知标题"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200 placeholder:text-zinc-800"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
|
||||
>通知内容</label
|
||||
>
|
||||
<textarea
|
||||
v-model="form.content"
|
||||
placeholder="请输入通知详情内容..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200 placeholder:text-zinc-800 min-h-[160px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- 范围选择 -->
|
||||
<div class="space-y-3 pt-4 border-t border-zinc-800/50">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
|
||||
>通知范围</label
|
||||
>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<button
|
||||
v-for="opt in targetOptions"
|
||||
:key="opt.id"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-4 py-2.5 rounded-xl text-[11px] font-black transition-all border',
|
||||
form.scope === opt.id
|
||||
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'bg-zinc-950 border-zinc-800 text-zinc-500 hover:border-zinc-700 hover:text-zinc-300'
|
||||
]"
|
||||
@click="form.scope = opt.id"
|
||||
>
|
||||
<component :is="opt.icon" :size="14" />
|
||||
{{ opt.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 动态配置区 -->
|
||||
<div class="pt-2">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div
|
||||
v-if="form.scope === 'ALL'"
|
||||
key="all"
|
||||
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-2xl flex items-center gap-4"
|
||||
>
|
||||
<Info class="text-blue-500 shrink-0" :size="18" />
|
||||
<p class="text-[11px] font-bold text-zinc-400">
|
||||
将向系统中所有已注册的活跃用户发送此通知。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else-if="form.scope === 'GRADE'" key="grade" class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>选择年级</span
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="form.grade"
|
||||
:options="['高一', '高二', '高三', '教师']"
|
||||
placeholder="请选择年级"
|
||||
class="w-full md:w-64"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="form.scope === 'CLASS'"
|
||||
key="class"
|
||||
class="grid grid-cols-1 md:grid-cols-2 gap-4"
|
||||
>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>年级</span
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="form.classGrade"
|
||||
:options="['高一', '高二', '高三', '教师']"
|
||||
placeholder="请选择年级"
|
||||
class="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>班级</span
|
||||
>
|
||||
<input
|
||||
v-model="form.className"
|
||||
type="text"
|
||||
placeholder="如: 1班、2班"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-xs focus:outline-none focus:border-blue-500/30 text-zinc-200"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="form.scope === 'MULTI_CLASS'" key="multi" class="space-y-4">
|
||||
<div class="p-6 bg-zinc-950/50 border border-zinc-800 border-dashed rounded-2xl">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||||
<CustomSelect
|
||||
v-model="multiClassForm.grade"
|
||||
:options="['高一', '高二', '高三', '教师']"
|
||||
placeholder="请选择年级"
|
||||
class="w-full"
|
||||
/>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="multiClassForm.class"
|
||||
type="text"
|
||||
placeholder="输入班级名称"
|
||||
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-xs focus:outline-none text-zinc-200"
|
||||
>
|
||||
<button
|
||||
:disabled="!canAddClass"
|
||||
class="px-4 bg-zinc-800 hover:bg-zinc-700 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-400 font-bold rounded-xl text-xs transition-all"
|
||||
@click="addClassToSelection"
|
||||
>
|
||||
添加
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="form.selectedClasses.length > 0" class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(cls, index) in form.selectedClasses"
|
||||
:key="index"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-zinc-900 border border-zinc-800 rounded-lg group"
|
||||
>
|
||||
<span class="text-[10px] font-bold text-zinc-400"
|
||||
>{{ cls.grade }} {{ cls.class }}</span
|
||||
>
|
||||
<button
|
||||
class="text-zinc-600 hover:text-red-400 transition-colors"
|
||||
@click="removeClassFromSelection(index)"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-4">
|
||||
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
|
||||
未选择任何班级
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="form.scope === 'SPECIFIC_USERS'" key="specific" class="space-y-4">
|
||||
<div class="relative flex items-center">
|
||||
<Search
|
||||
v-if="!userSearchLoading"
|
||||
class="absolute left-4 text-zinc-700 pointer-events-none"
|
||||
:size="16"
|
||||
/>
|
||||
<Loader2
|
||||
v-else
|
||||
class="absolute left-4 text-blue-500 animate-spin pointer-events-none"
|
||||
:size="16"
|
||||
/>
|
||||
<input
|
||||
v-model="userSearchQuery"
|
||||
type="text"
|
||||
placeholder="搜索并选择用户 (姓名、用户名或ID)..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
|
||||
@input="onUserSearchInput"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div
|
||||
v-if="showUserSearchResults && userSearchResults.length > 0"
|
||||
class="bg-zinc-950 border border-zinc-800 rounded-2xl overflow-hidden max-h-[300px] overflow-y-auto custom-scrollbar"
|
||||
>
|
||||
<div class="px-4 py-2 border-b border-zinc-800/60 bg-zinc-900/40">
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>搜索结果 ({{ userSearchResults.length }})</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
v-for="user in userSearchResults"
|
||||
:key="user.id"
|
||||
class="flex items-center justify-between p-4 border-b border-zinc-800/30 last:border-0 hover:bg-zinc-800/30 transition-all"
|
||||
>
|
||||
<div>
|
||||
<h5 class="text-sm font-bold text-zinc-200">
|
||||
{{ user.name || user.username }}
|
||||
</h5>
|
||||
<div class="flex items-center gap-2 mt-0.5">
|
||||
<span class="text-[10px] text-blue-500 font-black"
|
||||
>@{{ user.username }}</span
|
||||
>
|
||||
<span
|
||||
v-if="user.grade && user.class"
|
||||
class="text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
|
||||
>{{ user.grade }} {{ user.class }}</span
|
||||
>
|
||||
<span class="text-[10px] text-zinc-700 font-black">{{
|
||||
getRoleText(user.role)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
:disabled="isUserSelected(user.id)"
|
||||
class="px-3 py-1.5 bg-zinc-800 hover:bg-blue-600 hover:text-white disabled:opacity-50 disabled:cursor-not-allowed text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase"
|
||||
@click="addUserToSelection(user)"
|
||||
>
|
||||
{{ isUserSelected(user.id) ? '已选择' : '选择' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 已选择的用户 -->
|
||||
<div v-if="form.selectedUsers.length > 0" class="space-y-3">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>已选择用户 ({{ form.selectedUsers.length }})</span
|
||||
>
|
||||
<button
|
||||
class="text-[9px] font-black text-red-500/70 hover:text-red-500 uppercase tracking-widest transition-colors"
|
||||
@click="clearAllSelectedUsers"
|
||||
>
|
||||
清空全部
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-2">
|
||||
<div
|
||||
v-for="(user, index) in form.selectedUsers"
|
||||
:key="user.id"
|
||||
class="flex items-center gap-2 px-3 py-1.5 bg-zinc-900 border border-zinc-800 rounded-lg group"
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-[10px] font-bold text-zinc-300 leading-none">{{
|
||||
user.name || user.username
|
||||
}}</span>
|
||||
<span class="text-[8px] text-zinc-600">@{{ user.username }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="text-zinc-600 hover:text-red-400 transition-colors"
|
||||
@click="removeUserFromSelection(index)"
|
||||
>
|
||||
<X :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="text-center py-4">
|
||||
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
|
||||
未选择任何用户
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 错误和成功提示 -->
|
||||
<div v-if="error || success" class="mt-6">
|
||||
<div
|
||||
v-if="error"
|
||||
class="p-4 bg-red-500/5 border border-red-500/10 rounded-2xl flex items-center gap-3"
|
||||
>
|
||||
<AlertCircle class="text-red-500 shrink-0" :size="18" />
|
||||
<p class="text-xs font-bold text-red-400">{{ error }}</p>
|
||||
</div>
|
||||
<div
|
||||
v-if="success"
|
||||
class="p-4 bg-green-500/5 border border-green-500/10 rounded-2xl flex items-center gap-3"
|
||||
>
|
||||
<Check class="text-green-500 shrink-0" :size="18" />
|
||||
<p class="text-xs font-bold text-green-400">{{ success }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-10 flex justify-end">
|
||||
<button
|
||||
:disabled="loading || !isFormValid"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-10 py-4 bg-zinc-950 border border-zinc-800 text-xs font-black rounded-2xl transition-all uppercase tracking-[0.2em] shadow-lg',
|
||||
loading || !isFormValid
|
||||
? 'text-zinc-800 cursor-not-allowed'
|
||||
: 'text-zinc-400 hover:text-zinc-100 hover:border-zinc-600 hover:bg-zinc-900'
|
||||
]"
|
||||
@click="sendNotification"
|
||||
>
|
||||
<Loader2 v-if="loading" class="animate-spin" :size="16" />
|
||||
<Send v-else :size="16" />
|
||||
{{ loading ? '发送中...' : '发送通知' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧:预览区 -->
|
||||
<div class="lg:col-span-5 space-y-6 lg:sticky lg:top-8">
|
||||
<div
|
||||
class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-8 shadow-xl flex flex-col h-full"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest mb-6 flex items-center gap-2"
|
||||
>
|
||||
<Eye :size="16" class="text-blue-500" /> 通知预览
|
||||
</h3>
|
||||
|
||||
<div class="flex-1 flex flex-col items-center justify-center p-4">
|
||||
<div
|
||||
class="w-full max-w-[320px] bg-[#0c0c0e] border border-zinc-800 rounded-3xl overflow-hidden shadow-2xl relative"
|
||||
>
|
||||
<!-- 顶部装饰 -->
|
||||
<div class="h-1.5 bg-blue-600 w-full" />
|
||||
|
||||
<div class="p-6 space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
class="w-8 h-8 rounded-xl bg-blue-600/10 text-blue-500 flex items-center justify-center border border-blue-500/20"
|
||||
>
|
||||
<Bell :size="14" />
|
||||
</div>
|
||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||
>VoiceHub 系统</span
|
||||
>
|
||||
</div>
|
||||
<span class="text-[9px] text-zinc-700 font-bold uppercase tracking-wider"
|
||||
>刚刚</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<h4
|
||||
:class="[
|
||||
'text-sm font-black transition-colors',
|
||||
form.title ? 'text-zinc-100' : 'text-zinc-800 italic'
|
||||
]"
|
||||
>
|
||||
{{ form.title || '通知标题' }}
|
||||
</h4>
|
||||
<p
|
||||
:class="[
|
||||
'text-[11px] leading-relaxed transition-colors',
|
||||
form.content ? 'text-zinc-400' : 'text-zinc-800 italic line-clamp-3'
|
||||
]"
|
||||
>
|
||||
{{ form.content || '这里将显示通知的详细内容预览...' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="pt-4 border-t border-zinc-800/50 flex items-center justify-between">
|
||||
<div class="flex items-center gap-1.5">
|
||||
<Users :size="12" class="text-zinc-700" />
|
||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-wider">
|
||||
发送范围: {{ scopeDescription }}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
class="p-1.5 text-blue-500 hover:bg-blue-600/10 rounded-lg transition-all"
|
||||
>
|
||||
<MessageSquare :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 背景光晕 -->
|
||||
<div
|
||||
class="absolute top-0 right-0 w-32 h-32 bg-blue-600/5 blur-[50px] -z-10 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 space-y-3 w-full max-w-[320px]">
|
||||
<div
|
||||
class="flex items-start gap-3 p-3 bg-amber-500/5 border border-amber-500/10 rounded-2xl"
|
||||
>
|
||||
<AlertCircle class="text-amber-500 shrink-0 mt-0.5" :size="14" />
|
||||
<p class="text-[10px] font-bold text-zinc-500 leading-normal">
|
||||
预览图仅供参考布局,实际发送效果可能会根据用户设备显示语言和屏幕尺寸有所不同。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref, onUnmounted } from 'vue'
|
||||
import {
|
||||
Send,
|
||||
Users,
|
||||
GraduationCap,
|
||||
LayoutGrid,
|
||||
User,
|
||||
Search,
|
||||
Bell,
|
||||
Info,
|
||||
X,
|
||||
Check,
|
||||
Plus,
|
||||
AlertCircle,
|
||||
Eye,
|
||||
MessageSquare,
|
||||
Loader2
|
||||
} from 'lucide-vue-next'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useAdmin } from '~/composables/useAdmin'
|
||||
|
||||
const { isAdmin } = useAuth()
|
||||
const { sendAdminNotification } = useAdmin()
|
||||
|
||||
// 表单数据
|
||||
const form = ref({
|
||||
title: '',
|
||||
content: '',
|
||||
scope: 'ALL', // 'ALL', 'GRADE', 'CLASS', 'MULTI_CLASS', 'SPECIFIC_USERS'
|
||||
grade: '',
|
||||
classGrade: '',
|
||||
className: '',
|
||||
selectedClasses: [], // 用于多班级选择
|
||||
selectedUsers: [] // 用于指定用户选择
|
||||
})
|
||||
|
||||
// 多班级选择表单
|
||||
const multiClassForm = ref({
|
||||
grade: '',
|
||||
class: ''
|
||||
})
|
||||
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
|
||||
// 用户搜索相关
|
||||
const userSearchQuery = ref('')
|
||||
const userSearchResults = ref([])
|
||||
const showUserSearchResults = ref(false)
|
||||
const userSearchLoading = ref(false)
|
||||
let userSearchTimeout = null
|
||||
|
||||
const targetOptions = [
|
||||
{ id: 'ALL', label: '全体用户', icon: Users },
|
||||
{ id: 'GRADE', label: '按年级选择', icon: GraduationCap },
|
||||
{ id: 'CLASS', label: '按班级选择', icon: LayoutGrid },
|
||||
{ id: 'MULTI_CLASS', label: '多班级选择', icon: Plus },
|
||||
{ id: 'SPECIFIC_USERS', label: '指定用户', icon: User }
|
||||
]
|
||||
|
||||
// 判断是否可以添加班级
|
||||
const canAddClass = computed(() => {
|
||||
return multiClassForm.value.grade && multiClassForm.value.class
|
||||
})
|
||||
|
||||
// 添加班级到选择列表
|
||||
const addClassToSelection = () => {
|
||||
if (!canAddClass.value) return
|
||||
|
||||
// 检查是否已经选择了这个班级
|
||||
const isDuplicate = form.value.selectedClasses.some(
|
||||
(cls) => cls.grade === multiClassForm.value.grade && cls.class === multiClassForm.value.class
|
||||
)
|
||||
|
||||
if (!isDuplicate) {
|
||||
form.value.selectedClasses.push({
|
||||
grade: multiClassForm.value.grade,
|
||||
class: multiClassForm.value.class
|
||||
})
|
||||
|
||||
// 清空输入
|
||||
multiClassForm.value.class = ''
|
||||
}
|
||||
}
|
||||
|
||||
// 从选择列表中移除班级
|
||||
const removeClassFromSelection = (index) => {
|
||||
form.value.selectedClasses.splice(index, 1)
|
||||
}
|
||||
|
||||
// 用户搜索输入处理(防抖)
|
||||
const onUserSearchInput = () => {
|
||||
clearTimeout(userSearchTimeout)
|
||||
|
||||
if (!userSearchQuery.value.trim()) {
|
||||
userSearchResults.value = []
|
||||
showUserSearchResults.value = false
|
||||
return
|
||||
}
|
||||
|
||||
userSearchTimeout = setTimeout(async () => {
|
||||
await searchUsers(userSearchQuery.value.trim())
|
||||
}, 300)
|
||||
}
|
||||
|
||||
// 搜索用户API调用
|
||||
const searchUsers = async (query) => {
|
||||
if (!query) return
|
||||
|
||||
try {
|
||||
userSearchLoading.value = true
|
||||
const response = await $fetch('/api/admin/users', {
|
||||
method: 'GET',
|
||||
query: {
|
||||
search: query,
|
||||
limit: 20
|
||||
}
|
||||
})
|
||||
|
||||
if (response.success) {
|
||||
userSearchResults.value = response.users || []
|
||||
showUserSearchResults.value = true
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('搜索用户失败:', err)
|
||||
userSearchResults.value = []
|
||||
showUserSearchResults.value = false
|
||||
} finally {
|
||||
userSearchLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 检查用户是否已被选择
|
||||
const isUserSelected = (userId) => {
|
||||
return form.value.selectedUsers.some((user) => user.id === userId)
|
||||
}
|
||||
|
||||
// 添加用户到选择列表
|
||||
const addUserToSelection = (user) => {
|
||||
if (isUserSelected(user.id)) return
|
||||
|
||||
form.value.selectedUsers.push({
|
||||
id: user.id,
|
||||
name: user.name,
|
||||
username: user.username,
|
||||
grade: user.grade,
|
||||
class: user.class,
|
||||
role: user.role
|
||||
})
|
||||
|
||||
// 清空搜索
|
||||
userSearchQuery.value = ''
|
||||
userSearchResults.value = []
|
||||
showUserSearchResults.value = false
|
||||
}
|
||||
|
||||
// 从选择列表中移除用户
|
||||
const removeUserFromSelection = (index) => {
|
||||
form.value.selectedUsers.splice(index, 1)
|
||||
}
|
||||
|
||||
// 清空所有已选用户
|
||||
const clearAllSelectedUsers = () => {
|
||||
form.value.selectedUsers = []
|
||||
}
|
||||
|
||||
// 获取角色文本
|
||||
const getRoleText = (role) => {
|
||||
const roleMap = {
|
||||
admin: '管理员',
|
||||
teacher: '教师',
|
||||
student: '学生'
|
||||
}
|
||||
return roleMap[role] || role
|
||||
}
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
if (!form.value.title || !form.value.content) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.value.scope === 'GRADE' && !form.value.grade) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.value.scope === 'CLASS' && (!form.value.classGrade || !form.value.className)) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.value.scope === 'MULTI_CLASS' && form.value.selectedClasses.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (form.value.scope === 'SPECIFIC_USERS' && form.value.selectedUsers.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
return true
|
||||
})
|
||||
|
||||
// 范围描述
|
||||
const scopeDescription = computed(() => {
|
||||
switch (form.value.scope) {
|
||||
case 'ALL':
|
||||
return '全体用户'
|
||||
case 'GRADE':
|
||||
return form.value.grade ? `${form.value.grade}年级` : '请选择年级'
|
||||
case 'CLASS':
|
||||
return form.value.classGrade && form.value.className
|
||||
? `${form.value.classGrade}年级${form.value.className}班`
|
||||
: '请选择班级'
|
||||
case 'MULTI_CLASS':
|
||||
return form.value.selectedClasses.length > 0
|
||||
? `${form.value.selectedClasses.length}个班级`
|
||||
: '请选择班级'
|
||||
case 'SPECIFIC_USERS':
|
||||
return form.value.selectedUsers.length > 0
|
||||
? `已选择${form.value.selectedUsers.length}个用户`
|
||||
: '请选择用户'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
})
|
||||
|
||||
// 发送通知
|
||||
const sendNotification = async () => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = '只有管理员可以发送系统通知'
|
||||
return
|
||||
}
|
||||
|
||||
if (!isFormValid.value) {
|
||||
error.value = '请填写完整信息'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
// 构建请求数据
|
||||
const notificationData = {
|
||||
title: form.value.title,
|
||||
content: form.value.content,
|
||||
scope: form.value.scope,
|
||||
filter: {}
|
||||
}
|
||||
|
||||
// 添加过滤条件
|
||||
if (form.value.scope === 'GRADE') {
|
||||
notificationData.filter.grade = form.value.grade
|
||||
} else if (form.value.scope === 'CLASS') {
|
||||
notificationData.filter.grade = form.value.classGrade
|
||||
notificationData.filter.class = form.value.className
|
||||
} else if (form.value.scope === 'MULTI_CLASS') {
|
||||
notificationData.filter.classes = form.value.selectedClasses
|
||||
} else if (form.value.scope === 'SPECIFIC_USERS') {
|
||||
notificationData.filter.userIds = form.value.selectedUsers.map((user) => user.id)
|
||||
}
|
||||
|
||||
// 发送通知
|
||||
const result = await sendAdminNotification(notificationData)
|
||||
|
||||
if (result && result.success) {
|
||||
success.value = `成功发送通知给 ${result.sentCount} 名用户`
|
||||
|
||||
// 3秒后自动隐藏成功提示
|
||||
setTimeout(() => {
|
||||
success.value = ''
|
||||
}, 3000)
|
||||
|
||||
// 清空表单
|
||||
form.value = {
|
||||
title: '',
|
||||
content: '',
|
||||
scope: 'ALL',
|
||||
grade: '',
|
||||
classGrade: '',
|
||||
className: '',
|
||||
selectedClasses: [],
|
||||
selectedUsers: []
|
||||
}
|
||||
multiClassForm.value = {
|
||||
grade: '',
|
||||
class: ''
|
||||
}
|
||||
// 清空用户搜索相关状态
|
||||
userSearchQuery.value = ''
|
||||
userSearchResults.value = []
|
||||
showUserSearchResults.value = false
|
||||
userSearchLoading.value = false
|
||||
clearTimeout(userSearchTimeout)
|
||||
} else {
|
||||
throw new Error(result?.message || '发送通知失败')
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.message || '发送通知时发生错误'
|
||||
console.error('发送通知错误:', err)
|
||||
|
||||
// 3秒后自动隐藏错误提示
|
||||
setTimeout(() => {
|
||||
error.value = ''
|
||||
}, 3000)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onUnmounted(() => {
|
||||
if (userSearchTimeout) {
|
||||
clearTimeout(userSearchTimeout)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
|
||||
.fade-enter-active,
|
||||
.fade-leave-active {
|
||||
transition:
|
||||
opacity 0.2s ease,
|
||||
transform 0.2s ease;
|
||||
}
|
||||
|
||||
.fade-enter-from {
|
||||
opacity: 0;
|
||||
transform: translateX(-10px);
|
||||
}
|
||||
|
||||
.fade-leave-to {
|
||||
opacity: 0;
|
||||
transform: translateX(10px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,373 @@
|
||||
<template>
|
||||
<div class="space-y-8 animate-in fade-in slide-in-from-bottom-4 duration-700">
|
||||
<!-- 统计卡片网格 -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-4 gap-6">
|
||||
<div
|
||||
v-for="(stat, i) in statCards"
|
||||
:key="i"
|
||||
class="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 relative overflow-hidden group hover:border-zinc-700 transition-all shadow-lg shadow-black/20"
|
||||
>
|
||||
<div class="flex justify-between items-start mb-4">
|
||||
<div
|
||||
:class="[
|
||||
'p-3 rounded-xl border',
|
||||
stat.color === 'blue'
|
||||
? 'bg-blue-500/10 text-blue-500 border-blue-500/20'
|
||||
: stat.color === 'emerald'
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: stat.color === 'pink'
|
||||
? 'bg-pink-500/10 text-pink-500 border-pink-500/20'
|
||||
: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20'
|
||||
]"
|
||||
>
|
||||
<component :is="stat.icon" :size="24" />
|
||||
</div>
|
||||
<div
|
||||
v-if="stat.trend"
|
||||
:class="[
|
||||
'flex items-center gap-1 text-[11px] font-bold',
|
||||
stat.trendDown ? 'text-red-400' : 'text-emerald-400'
|
||||
]"
|
||||
>
|
||||
<TrendingDown v-if="stat.trendDown" :size="12" />
|
||||
<TrendingUp v-else :size="12" />
|
||||
{{ stat.trend }}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-zinc-500 text-sm font-medium">{{ stat.label }}</p>
|
||||
<h4 class="text-3xl font-bold tracking-tight text-zinc-100">{{ stat.value }}</h4>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||
<!-- 最近活动 -->
|
||||
<div
|
||||
class="lg:col-span-5 bg-zinc-900/40 border border-zinc-800 rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
|
||||
>
|
||||
<div class="px-6 py-5 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||
<Activity :size="18" class="text-blue-500" /> 最近活动
|
||||
</h3>
|
||||
<button
|
||||
class="p-2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
:class="{ 'animate-spin': loadingActivities }"
|
||||
@click="refreshActivities"
|
||||
>
|
||||
<RefreshCw :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
<div
|
||||
class="flex-1 overflow-y-auto custom-scrollbar p-2 space-y-1 min-h-[380px] max-h-[500px]"
|
||||
>
|
||||
<div
|
||||
v-if="loadingActivities && recentActivities.length === 0"
|
||||
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
|
||||
>
|
||||
<RefreshCw :size="24" class="animate-spin" />
|
||||
<span class="text-sm">加载中...</span>
|
||||
</div>
|
||||
<div
|
||||
v-else-if="recentActivities.length === 0"
|
||||
class="flex flex-col items-center justify-center h-full text-zinc-500 gap-3 py-20"
|
||||
>
|
||||
<Inbox :size="24" />
|
||||
<span class="text-sm">暂无活动记录</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(activity, idx) in recentActivities"
|
||||
:key="idx"
|
||||
class="flex items-start gap-4 p-4 rounded-2xl hover:bg-zinc-800/40 transition-all cursor-pointer group"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'shrink-0 w-10 h-10 rounded-xl flex items-center justify-center border shadow-sm',
|
||||
getActivityStyle(activity.type).bg
|
||||
]"
|
||||
>
|
||||
<component :is="getActivityStyle(activity.type).icon" :size="18" />
|
||||
</div>
|
||||
<div class="flex-1 min-w-0">
|
||||
<h5
|
||||
class="font-bold text-sm text-zinc-200 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ activity.title }}
|
||||
</h5>
|
||||
<p class="text-xs text-zinc-500 truncate mt-1">{{ activity.description }}</p>
|
||||
<div class="flex items-center gap-1.5 mt-2">
|
||||
<Clock :size="10" class="text-zinc-600" />
|
||||
<span class="text-[10px] text-zinc-600 font-medium uppercase tracking-wider">{{
|
||||
formatTime(activity.createdAt)
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 系统状态 -->
|
||||
<div
|
||||
class="lg:col-span-4 bg-zinc-900/40 border border-zinc-800 rounded-xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
|
||||
>
|
||||
<div class="px-6 py-5 border-b border-zinc-800 flex items-center justify-between">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||
<ShieldCheck :size="18" class="text-emerald-500" /> 系统状态
|
||||
</h3>
|
||||
<span
|
||||
:class="[
|
||||
'px-3 py-1 text-[10px] font-black uppercase rounded-full border',
|
||||
systemStatus.online
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
]"
|
||||
>
|
||||
{{ systemStatus.online ? '在线' : '离线' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="p-6 space-y-6">
|
||||
<div
|
||||
v-for="(status, i) in statusItems"
|
||||
:key="i"
|
||||
class="flex items-center justify-between group"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
:class="[
|
||||
'w-1.5 h-1.5 rounded-full',
|
||||
status.active
|
||||
? 'bg-emerald-500 shadow-[0_0_10px_rgba(16,185,129,0.7)]'
|
||||
: 'bg-zinc-600'
|
||||
]"
|
||||
/>
|
||||
<span class="text-xs font-semibold text-zinc-400">{{ status.label }}</span>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-zinc-200">{{ status.value }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 快速操作 -->
|
||||
<div
|
||||
class="lg:col-span-3 bg-zinc-900/40 border border-zinc-800 rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
|
||||
>
|
||||
<div class="px-6 py-5 border-b border-zinc-800">
|
||||
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||
<Zap :size="18" class="text-yellow-500" /> 快速操作
|
||||
</h3>
|
||||
</div>
|
||||
<div class="p-6 space-y-3">
|
||||
<button
|
||||
v-for="(action, i) in quickActions"
|
||||
:key="i"
|
||||
:class="[
|
||||
'w-full flex items-center gap-3 px-5 py-4 rounded-lg border font-bold text-sm transition-all text-left group',
|
||||
action.primary
|
||||
? 'bg-blue-600 border-blue-500 text-white shadow-xl shadow-blue-900/20 hover:bg-blue-500'
|
||||
: 'bg-zinc-950/40 border-zinc-800 text-zinc-400 hover:border-zinc-600 hover:text-zinc-200'
|
||||
]"
|
||||
@click="navigateTo(action.id)"
|
||||
>
|
||||
<component :is="action.icon" :size="18" />
|
||||
{{ action.label }}
|
||||
<ExternalLink v-if="action.primary" :size="14" class="ml-auto opacity-50" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import {
|
||||
Music,
|
||||
Users,
|
||||
Calendar,
|
||||
Heart,
|
||||
TrendingDown,
|
||||
TrendingUp,
|
||||
Activity,
|
||||
ShieldCheck,
|
||||
RefreshCw,
|
||||
Clock,
|
||||
Zap,
|
||||
Bell,
|
||||
Ban,
|
||||
ExternalLink,
|
||||
Inbox
|
||||
} from 'lucide-vue-next'
|
||||
import packageJson from '~~/package.json'
|
||||
|
||||
const emit = defineEmits(['navigate'])
|
||||
|
||||
const systemVersion = ref(packageJson.version)
|
||||
const stats = ref({
|
||||
totalSongs: 0,
|
||||
totalUsers: 0,
|
||||
todaySchedules: 0,
|
||||
weeklyRequests: 0,
|
||||
songsChange: 0,
|
||||
usersChange: 0,
|
||||
requestsChange: 0,
|
||||
totalSchedules: 0,
|
||||
currentSemester: '',
|
||||
blacklistCount: 0
|
||||
})
|
||||
|
||||
const recentActivities = ref([])
|
||||
const loadingActivities = ref(false)
|
||||
|
||||
const systemStatus = ref({
|
||||
online: true,
|
||||
database: true,
|
||||
api: true
|
||||
})
|
||||
|
||||
// 统计卡片数据
|
||||
const statCards = computed(() => [
|
||||
{
|
||||
label: '总歌曲数',
|
||||
value: formatNumber(stats.value.totalSongs),
|
||||
icon: Music,
|
||||
color: 'blue',
|
||||
trend: stats.value.songsChange !== 0 ? `${Math.abs(stats.value.songsChange)}%` : null,
|
||||
trendDown: stats.value.songsChange < 0
|
||||
},
|
||||
{
|
||||
label: '注册用户',
|
||||
value: formatNumber(stats.value.totalUsers),
|
||||
icon: Users,
|
||||
color: 'emerald'
|
||||
},
|
||||
{
|
||||
label: '今日排期',
|
||||
value: formatNumber(stats.value.todaySchedules),
|
||||
icon: Calendar,
|
||||
color: 'zinc'
|
||||
},
|
||||
{
|
||||
label: '本周点歌',
|
||||
value: formatNumber(stats.value.weeklyRequests),
|
||||
icon: Heart,
|
||||
color: 'pink',
|
||||
trend: stats.value.requestsChange !== 0 ? `${Math.abs(stats.value.requestsChange)}%` : null,
|
||||
trendDown: stats.value.requestsChange < 0
|
||||
}
|
||||
])
|
||||
|
||||
// 系统状态项
|
||||
const statusItems = computed(() => [
|
||||
{
|
||||
label: '数据库连接',
|
||||
value: systemStatus.value.database ? '正常' : '异常',
|
||||
active: systemStatus.value.database
|
||||
},
|
||||
{
|
||||
label: 'API服务',
|
||||
value: systemStatus.value.api ? '正常' : '异常',
|
||||
active: systemStatus.value.api
|
||||
},
|
||||
{
|
||||
label: '当前学期',
|
||||
value: stats.value.currentSemester || '未设置',
|
||||
active: !!stats.value.currentSemester
|
||||
},
|
||||
{
|
||||
label: '黑名单项目',
|
||||
value: `${stats.value.blacklistCount} 项`,
|
||||
active: stats.value.blacklistCount >= 0
|
||||
},
|
||||
{ label: '系统版本', value: `v${systemVersion.value}`, active: true }
|
||||
])
|
||||
|
||||
// 快速操作
|
||||
const quickActions = [
|
||||
{ label: '管理排期', icon: Calendar, id: 'schedule', primary: true },
|
||||
{ label: '用户管理', icon: Users, id: 'users' },
|
||||
{ label: '发送通知', icon: Bell, id: 'notifications' },
|
||||
{ label: '黑名单管理', icon: Ban, id: 'blacklist' }
|
||||
]
|
||||
|
||||
const formatNumber = (num) => {
|
||||
if (num >= 1000) return (num / 1000).toFixed(1) + 'K'
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
const getActivityStyle = (type) => {
|
||||
const styles = {
|
||||
song: { icon: Music, bg: 'bg-purple-500/10 text-purple-500 border-purple-500/20' },
|
||||
user: { icon: Users, bg: 'bg-pink-500/10 text-pink-500 border-pink-500/20' },
|
||||
schedule: { icon: Calendar, bg: 'bg-blue-500/10 text-blue-500 border-blue-500/20' }
|
||||
}
|
||||
return styles[type] || { icon: Activity, bg: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20' }
|
||||
}
|
||||
|
||||
const loadStats = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/admin/stats', {
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
stats.value = response
|
||||
} catch (error) {
|
||||
console.error('加载统计数据失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
const loadRecentActivities = async () => {
|
||||
loadingActivities.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/admin/activities', {
|
||||
...useAuth().getAuthConfig()
|
||||
})
|
||||
recentActivities.value = response.activities || []
|
||||
} catch (error) {
|
||||
console.error('加载活动记录失败:', error)
|
||||
recentActivities.value = []
|
||||
} finally {
|
||||
loadingActivities.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const refreshActivities = () => {
|
||||
loadRecentActivities()
|
||||
}
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
return `${Math.floor(diff / 86400000)}天前`
|
||||
}
|
||||
|
||||
const navigateTo = (tab) => {
|
||||
emit('navigate', tab)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadStats()
|
||||
loadRecentActivities()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,685 @@
|
||||
<template>
|
||||
<div class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2">
|
||||
<!-- 页面标题 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">播出时段管理</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">
|
||||
定义校园广播的常规播放窗口,用户仅可在这些时间段内进行点歌或查看排期
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="bg-zinc-900/40 border border-zinc-800 rounded-xl px-4 py-2 flex items-center gap-3"
|
||||
>
|
||||
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||
>启用播出时段选择</span
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'relative w-10 h-5 rounded-full transition-colors',
|
||||
enablePlayTimeSelection ? 'bg-blue-600' : 'bg-zinc-800'
|
||||
]"
|
||||
@click="toggleGlobalEnabled"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
|
||||
enablePlayTimeSelection ? 'left-6' : 'left-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-lg shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
@click="openAddForm"
|
||||
>
|
||||
<Plus :size="14" /> 添加播出时段
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 信息卡片 -->
|
||||
<div class="bg-blue-600/5 border border-blue-500/10 rounded-xl p-5 flex items-start gap-4">
|
||||
<Info class="text-blue-500 shrink-0 mt-0.5" :size="18" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-bold text-zinc-300">关于时段限制</p>
|
||||
<p class="text-[11px] text-zinc-500 leading-relaxed">
|
||||
启用全局时段选择后,系统将仅在定义的时段内开放点歌功能。如果未设置开始或结束时间,系统将视为该边界不设限。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div
|
||||
v-if="loading"
|
||||
class="flex flex-col items-center justify-center py-20 space-y-4 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
|
||||
>
|
||||
<div
|
||||
class="w-10 h-10 border-4 border-blue-600/20 border-t-blue-600 rounded-full animate-spin"
|
||||
/>
|
||||
<p class="text-xs font-black text-zinc-500 uppercase tracking-widest">加载中...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="bg-red-500/5 border border-red-500/10 rounded-xl p-5 flex items-start gap-4"
|
||||
>
|
||||
<AlertCircle class="text-red-500 shrink-0 mt-0.5" :size="18" />
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-bold text-zinc-300">获取数据失败</p>
|
||||
<p class="text-[11px] text-zinc-500 leading-relaxed">{{ error }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 空状态 -->
|
||||
<div
|
||||
v-else-if="playTimes.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 space-y-6 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
|
||||
>
|
||||
<div class="p-6 rounded-xl bg-zinc-950 border border-zinc-800 text-zinc-700">
|
||||
<Clock :size="48" />
|
||||
</div>
|
||||
<div class="text-center space-y-2">
|
||||
<h3 class="text-lg font-bold text-zinc-100">暂无播出时段</h3>
|
||||
<p class="text-xs text-zinc-500">点击“添加播出时段”按钮创建第一个播出时段</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 时段网格 -->
|
||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-6">
|
||||
<TransitionGroup name="list">
|
||||
<div
|
||||
v-for="playTime in playTimes"
|
||||
:key="playTime.id"
|
||||
:class="[
|
||||
'group relative bg-zinc-900/30 border rounded-xl p-8 transition-all hover:shadow-2xl hover:shadow-black/40',
|
||||
playTime.enabled ? 'border-zinc-800/80' : 'border-zinc-800/40 opacity-60'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between mb-6">
|
||||
<div
|
||||
:class="[
|
||||
'p-3 rounded-xl bg-zinc-950 border border-zinc-800 transition-all',
|
||||
playTime.enabled ? 'text-blue-500 border-blue-500/20' : 'text-zinc-700'
|
||||
]"
|
||||
>
|
||||
<Clock :size="20" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
|
||||
playTime.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-zinc-800/50 text-zinc-600 border-zinc-700/50'
|
||||
]"
|
||||
>
|
||||
{{ playTime.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
<button class="p-1.5 text-zinc-700 hover:text-zinc-400">
|
||||
<MoreVertical :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4
|
||||
class="text-lg font-black text-zinc-100 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ playTime.name }}
|
||||
</h4>
|
||||
<div
|
||||
class="flex items-center gap-2 mt-1.5 text-blue-500/80 font-black tracking-tighter"
|
||||
>
|
||||
<span class="text-xl">{{ playTime.startTime || '不限' }}</span>
|
||||
<div class="w-4 h-[2px] bg-zinc-800" />
|
||||
<span class="text-xl">{{ playTime.endTime || '不限' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-zinc-500 font-medium leading-relaxed min-h-[32px] line-clamp-2">
|
||||
{{ playTime.description || '暂无详细描述...' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 border-t border-zinc-800/50 flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-500 hover:text-blue-400 hover:border-blue-500/30 transition-all"
|
||||
@click="editPlayTime(playTime)"
|
||||
>
|
||||
<Edit2 :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-500 hover:text-red-400 hover:border-red-500/30 transition-all"
|
||||
@click="confirmDelete(playTime)"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
:class="[
|
||||
'flex items-center gap-2 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
playTime.enabled
|
||||
? 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
|
||||
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
|
||||
]"
|
||||
@click="togglePlayTimeStatus(playTime)"
|
||||
>
|
||||
<Power :size="12" />
|
||||
{{ playTime.enabled ? '禁用' : '启用' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑弹窗 -->
|
||||
<Transition name="modal">
|
||||
<div
|
||||
v-if="showAddForm || editingPlayTime"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="cancelForm" />
|
||||
<div
|
||||
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div class="p-8">
|
||||
<div class="flex items-center justify-between mb-8">
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">
|
||||
{{ editingPlayTime ? '编辑播出时段' : '添加播出时段' }}
|
||||
</h3>
|
||||
<button
|
||||
class="p-2 text-zinc-500 hover:text-zinc-200 transition-colors"
|
||||
@click="cancelForm"
|
||||
>
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>时段名称</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
placeholder="例如: 午间广播"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>开始时间 (可选)</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="formData.startTime"
|
||||
type="time"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 appearance-none"
|
||||
>
|
||||
<Clock
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
|
||||
:size="14"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制开始时间</p>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>结束时间 (可选)</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="formData.endTime"
|
||||
type="time"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 appearance-none"
|
||||
>
|
||||
<Clock
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
|
||||
:size="14"
|
||||
/>
|
||||
</div>
|
||||
<p class="text-[9px] text-zinc-600 px-1">留空表示不限制结束时间</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 flex items-center gap-2"
|
||||
>
|
||||
<AlignLeft :size="10" /> 描述 (可选)
|
||||
</label>
|
||||
<textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入时段描述信息..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 min-h-[100px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer group px-1">
|
||||
<input
|
||||
v-model="formData.enabled"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-blue-600"
|
||||
>
|
||||
<span
|
||||
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
|
||||
>启用此播出时段</span
|
||||
>
|
||||
</label>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="bg-red-500/10 border border-red-500/20 rounded-lg p-3 text-[11px] text-red-400"
|
||||
>
|
||||
{{ formError }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex justify-end gap-3">
|
||||
<button
|
||||
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
|
||||
@click="cancelForm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="formSubmitting"
|
||||
class="px-8 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-lg transition-all active:scale-95"
|
||||
@click="savePlayTime"
|
||||
>
|
||||
{{ formSubmitting ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 删除确认弹窗 -->
|
||||
<Transition name="modal">
|
||||
<div v-if="showDeleteConfirm" class="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
<div
|
||||
class="absolute inset-0 bg-black/80 backdrop-blur-sm"
|
||||
@click="showDeleteConfirm = false"
|
||||
/>
|
||||
<div
|
||||
class="relative w-full max-w-sm bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div class="p-8">
|
||||
<div class="flex flex-col items-center py-4 space-y-6">
|
||||
<div
|
||||
class="w-16 h-16 rounded-xl bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/10 shadow-xl shadow-red-900/5"
|
||||
>
|
||||
<AlertCircle :size="32" />
|
||||
</div>
|
||||
<div class="text-center space-y-2 px-4">
|
||||
<h4 class="text-lg font-bold text-zinc-100">
|
||||
确定要删除播出时段 "{{ playTimeToDelete?.name }}" 吗?
|
||||
</h4>
|
||||
<p class="text-xs text-zinc-500 leading-relaxed">
|
||||
此操作不可恢复,相关的歌曲点播和排期的时段设置将受影响或被清除。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full pt-4">
|
||||
<button
|
||||
class="flex-1 px-4 py-3 bg-zinc-950 border border-zinc-800 text-zinc-500 text-xs font-black rounded-lg transition-all hover:bg-zinc-800"
|
||||
@click="showDeleteConfirm = false"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="deleteInProgress"
|
||||
class="flex-1 px-4 py-3 bg-red-600 hover:bg-red-500 disabled:opacity-50 disabled:cursor-not-allowed text-white text-xs font-black rounded-lg shadow-xl shadow-red-900/20 transition-all active:scale-95"
|
||||
@click="deletePlayTime"
|
||||
>
|
||||
{{ deleteInProgress ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import type { PlayTime } from '~/types'
|
||||
import {
|
||||
Plus,
|
||||
Clock,
|
||||
Edit2,
|
||||
Trash2,
|
||||
MoreVertical,
|
||||
Power,
|
||||
Info,
|
||||
AlertCircle,
|
||||
X,
|
||||
AlignLeft
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { getAuthConfig, isAdmin } = useAuth()
|
||||
const { showToast: showNotification } = useToast()
|
||||
|
||||
const playTimes = ref<PlayTime[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const showAddForm = ref(false)
|
||||
const editingPlayTime = ref<PlayTime | null>(null)
|
||||
const playTimeToDelete = ref<PlayTime | null>(null)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const formSubmitting = ref(false)
|
||||
const deleteInProgress = ref(false)
|
||||
const formError = ref('')
|
||||
const enablePlayTimeSelection = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const formData = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
description: '',
|
||||
enabled: true
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
await fetchPlayTimes()
|
||||
await fetchSystemSettings()
|
||||
})
|
||||
|
||||
// 获取播出时段列表
|
||||
const fetchPlayTimes = async () => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = '只有管理员才能管理播出时段'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const data = await $fetch('/api/admin/play-times', {
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 自定义排序:先按启用状态排序,然后有时间的排在前面,没有时间的排在后面
|
||||
playTimes.value = data.sort((a: PlayTime, b: PlayTime) => {
|
||||
// 首先按启用状态排序
|
||||
if (a.enabled !== b.enabled) {
|
||||
return a.enabled ? -1 : 1 // 启用的排在前面
|
||||
}
|
||||
|
||||
// 然后按是否有时间排序
|
||||
const aHasTime = !!(a.startTime || a.endTime)
|
||||
const bHasTime = !!(b.startTime || b.endTime)
|
||||
|
||||
if (aHasTime !== bHasTime) {
|
||||
return aHasTime ? -1 : 1 // 有时间的排在前面
|
||||
}
|
||||
|
||||
// 如果都有时间,按开始时间排序
|
||||
if (aHasTime && bHasTime) {
|
||||
if (a.startTime && b.startTime) {
|
||||
return a.startTime.localeCompare(b.startTime)
|
||||
} else if (a.startTime) {
|
||||
return -1
|
||||
} else if (b.startTime) {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
|
||||
// 最后按名称排序
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '获取播出时段失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 获取系统设置
|
||||
const fetchSystemSettings = async () => {
|
||||
if (!isAdmin.value) return
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const data = await $fetch('/api/admin/system-settings', {
|
||||
...authConfig
|
||||
})
|
||||
enablePlayTimeSelection.value = data.enablePlayTimeSelection
|
||||
} catch (err: any) {
|
||||
console.error('获取系统设置失败:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
// 切换全局启用状态
|
||||
const toggleGlobalEnabled = async () => {
|
||||
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
|
||||
await updateSystemSettings()
|
||||
}
|
||||
|
||||
// 更新系统设置
|
||||
const updateSystemSettings = async () => {
|
||||
if (!isAdmin.value) return
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
await $fetch('/api/admin/system-settings', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
enablePlayTimeSelection: enablePlayTimeSelection.value
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
showNotification('系统设置已更新', 'success')
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '更新系统设置失败'
|
||||
showNotification(error.value, 'error')
|
||||
// 如果失败,恢复状态
|
||||
enablePlayTimeSelection.value = !enablePlayTimeSelection.value
|
||||
}
|
||||
}
|
||||
|
||||
// 打开添加表单
|
||||
const openAddForm = () => {
|
||||
showAddForm.value = true
|
||||
Object.assign(formData, {
|
||||
id: 0,
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
description: '',
|
||||
enabled: true
|
||||
})
|
||||
}
|
||||
|
||||
// 编辑播出时段
|
||||
const editPlayTime = (playTime: PlayTime) => {
|
||||
editingPlayTime.value = playTime
|
||||
Object.assign(formData, {
|
||||
id: playTime.id,
|
||||
name: playTime.name,
|
||||
startTime: playTime.startTime || '',
|
||||
endTime: playTime.endTime || '',
|
||||
description: playTime.description || '',
|
||||
enabled: playTime.enabled
|
||||
})
|
||||
}
|
||||
|
||||
// 切换播出时段状态
|
||||
const togglePlayTimeStatus = async (playTime: PlayTime) => {
|
||||
if (!isAdmin.value) return
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
await $fetch(`/api/admin/play-times/${playTime.id}`, {
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
enabled: !playTime.enabled
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
await fetchPlayTimes()
|
||||
showNotification(playTime.enabled ? '播出时段已禁用' : '播出时段已启用', 'success')
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '更新播出时段状态失败'
|
||||
showNotification(error.value, 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = (playTime: PlayTime) => {
|
||||
playTimeToDelete.value = playTime
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
// 删除播出时段
|
||||
const deletePlayTime = async () => {
|
||||
if (!playTimeToDelete.value || !isAdmin.value) return
|
||||
|
||||
deleteInProgress.value = true
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
await $fetch(`/api/admin/play-times/${playTimeToDelete.value.id}`, {
|
||||
method: 'DELETE',
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
await fetchPlayTimes()
|
||||
showDeleteConfirm.value = false
|
||||
playTimeToDelete.value = null
|
||||
showNotification('播出时段已删除', 'success')
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '删除播出时段失败'
|
||||
showNotification(error.value, 'error')
|
||||
} finally {
|
||||
deleteInProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存播出时段
|
||||
const savePlayTime = async () => {
|
||||
formError.value = ''
|
||||
|
||||
// 时间验证(仅当两个时间都有填写时才进行比较)
|
||||
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
|
||||
formError.value = '开始时间必须早于结束时间'
|
||||
return
|
||||
}
|
||||
|
||||
// 至少要有名称
|
||||
if (!formData.name.trim()) {
|
||||
formError.value = '时段名称不能为空'
|
||||
return
|
||||
}
|
||||
|
||||
// 检查名称是否重复
|
||||
const isUpdate = !!editingPlayTime.value
|
||||
const nameExists = playTimes.value.some(
|
||||
(pt) =>
|
||||
pt.name.toLowerCase() === formData.name.trim().toLowerCase() &&
|
||||
(!isUpdate || pt.id !== formData.id)
|
||||
)
|
||||
|
||||
if (nameExists) {
|
||||
formError.value = '播出时段名称已存在,请使用其他名称'
|
||||
return
|
||||
}
|
||||
|
||||
formSubmitting.value = true
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
|
||||
await $fetch(isUpdate ? `/api/admin/play-times/${formData.id}` : '/api/admin/play-times', {
|
||||
method: isUpdate ? 'PUT' : 'POST',
|
||||
body: {
|
||||
name: formData.name.trim(),
|
||||
startTime: formData.startTime || null,
|
||||
endTime: formData.endTime || null,
|
||||
description: formData.description || null,
|
||||
enabled: formData.enabled
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
// 更新本地数据
|
||||
await fetchPlayTimes()
|
||||
cancelForm()
|
||||
showNotification(isUpdate ? '播出时段已更新' : '播出时段已创建', 'success')
|
||||
} catch (err: any) {
|
||||
formError.value = err.message || '保存播出时段失败'
|
||||
showNotification(formError.value, 'error')
|
||||
} finally {
|
||||
formSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 取消表单
|
||||
const cancelForm = () => {
|
||||
showAddForm.value = false
|
||||
editingPlayTime.value = null
|
||||
formError.value = ''
|
||||
|
||||
// 重置表单数据
|
||||
Object.assign(formData, {
|
||||
id: 0,
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
description: '',
|
||||
enabled: true
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.list-enter-active,
|
||||
.list-leave-active {
|
||||
transition: all 0.5s ease;
|
||||
}
|
||||
.list-enter-from,
|
||||
.list-leave-to {
|
||||
opacity: 0;
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .relative,
|
||||
.modal-leave-active .relative {
|
||||
transition: all 0.3s ease;
|
||||
}
|
||||
.modal-enter-from .relative,
|
||||
.modal-leave-to .relative {
|
||||
transform: scale(0.95);
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,857 @@
|
||||
<template>
|
||||
<div class="max-w-[1400px] mx-auto space-y-8 pb-20 px-2">
|
||||
<!-- 顶部标题和全局开关 -->
|
||||
<div class="flex flex-col xl:flex-row xl:items-end justify-between gap-6">
|
||||
<div class="space-y-1">
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">投稿开放管理</h2>
|
||||
<p class="text-xs text-zinc-500">管理全校师生点歌投稿的开放时间、限制数量及全局状态控制</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-4 bg-zinc-900/40 border border-zinc-800 p-2 rounded-3xl"
|
||||
>
|
||||
<div class="flex items-center gap-3 px-3 border-r border-zinc-800/50 mr-1">
|
||||
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||
>当前状态</span
|
||||
>
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[10px] font-black uppercase transition-all',
|
||||
hitRequestTime ? 'bg-emerald-500/10 text-emerald-500' : 'bg-red-500/10 text-red-500'
|
||||
]"
|
||||
>
|
||||
{{ hitRequestTime ? '已开放' : '已关闭' }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 px-2">
|
||||
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest"
|
||||
>启用投稿</span
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'relative w-10 h-5 rounded-full transition-colors',
|
||||
enableRequest ? 'bg-blue-600' : 'bg-zinc-800'
|
||||
]"
|
||||
@click="toggleGlobalRequest"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
|
||||
enableRequest ? 'left-6' : 'left-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3 px-2 border-l border-zinc-800/50">
|
||||
<span class="text-[10px] font-black text-zinc-400 uppercase tracking-widest"
|
||||
>启用时段限制</span
|
||||
>
|
||||
<button
|
||||
:class="[
|
||||
'relative w-10 h-5 rounded-full transition-colors',
|
||||
enableRequestTimeLimitation ? 'bg-purple-600' : 'bg-zinc-800'
|
||||
]"
|
||||
@click="toggleTimeLimitation"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'absolute top-1 w-3 h-3 bg-white rounded-full transition-all',
|
||||
enableRequestTimeLimitation ? 'left-6' : 'left-1'
|
||||
]"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 主要内容区域 -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
<!-- 添加时段按钮卡片 -->
|
||||
<button
|
||||
class="group relative h-full min-h-[220px] bg-zinc-900/20 border-2 border-dashed border-zinc-800 rounded-3xl flex flex-col items-center justify-center gap-4 hover:bg-blue-600/5 hover:border-blue-500/40 transition-all active:scale-95"
|
||||
@click="showAddForm = true"
|
||||
>
|
||||
<div
|
||||
class="w-14 h-14 rounded-full bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-500 group-hover:text-blue-500 group-hover:bg-blue-600/10 group-hover:border-blue-500/20 transition-all"
|
||||
>
|
||||
<Plus :size="28" />
|
||||
</div>
|
||||
<div class="text-center">
|
||||
<h4
|
||||
class="text-sm font-black text-zinc-400 group-hover:text-blue-400 transition-colors uppercase tracking-widest"
|
||||
>
|
||||
添加投稿时段
|
||||
</h4>
|
||||
<p class="text-[10px] text-zinc-600 mt-1">创建一个新的投稿开放周期</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- 时段列表 -->
|
||||
<TransitionGroup
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-for="slot in requestTimes"
|
||||
:key="slot.id"
|
||||
:class="[
|
||||
'group bg-zinc-900/40 border rounded-3xl p-8 space-y-6 transition-all hover:shadow-2xl hover:shadow-black/40',
|
||||
slot.enabled && !slot.past ? 'border-zinc-800' : 'border-zinc-800/40 opacity-60'
|
||||
]"
|
||||
>
|
||||
<div class="flex items-start justify-between">
|
||||
<div
|
||||
:class="[
|
||||
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 text-zinc-400 transition-colors',
|
||||
slot.enabled && !slot.past
|
||||
? 'text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/10'
|
||||
: ''
|
||||
]"
|
||||
>
|
||||
<Calendar :size="22" />
|
||||
</div>
|
||||
<div class="flex items-center gap-2">
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
|
||||
slot.past
|
||||
? 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||
: slot.enabled
|
||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
: 'bg-zinc-800/50 text-zinc-600 border-zinc-700/50'
|
||||
]"
|
||||
>
|
||||
{{ slot.past ? '已过期' : slot.enabled ? '已启用' : '已禁用' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<h4
|
||||
class="text-lg font-black text-zinc-100 flex items-baseline gap-2 group-hover:text-blue-400 transition-colors"
|
||||
>
|
||||
{{ slot.name }}
|
||||
<span class="text-xs font-bold text-zinc-600 tracking-tight"
|
||||
>({{ slot.accepted }}/{{ slot.expected || '∞' }})</span
|
||||
>
|
||||
</h4>
|
||||
<div class="mt-3 space-y-2">
|
||||
<div class="flex items-center gap-2 text-zinc-500 font-bold">
|
||||
<Clock :size="12" class="text-zinc-700" />
|
||||
<span class="text-[10px] uppercase tracking-tighter truncate">{{
|
||||
slot.startTime || '不限'
|
||||
}}</span>
|
||||
</div>
|
||||
<div class="flex items-center gap-2 text-zinc-500 font-bold">
|
||||
<div class="w-3 h-[2px] bg-zinc-800 ml-1.5" />
|
||||
<span class="text-[10px] uppercase tracking-tighter truncate">{{
|
||||
slot.endTime || '不限'
|
||||
}}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p
|
||||
class="text-xs text-zinc-500 font-medium leading-relaxed min-h-[32px] line-clamp-2 italic"
|
||||
>
|
||||
{{ slot.description || '暂无详细描述...' }}
|
||||
</p>
|
||||
|
||||
<!-- 投稿进度条 -->
|
||||
<div class="space-y-1.5">
|
||||
<div
|
||||
class="flex justify-between text-[9px] font-black text-zinc-600 uppercase tracking-widest px-0.5"
|
||||
>
|
||||
<span>投稿进度</span>
|
||||
<span
|
||||
>{{
|
||||
slot.expected > 0
|
||||
? Math.min(100, Math.round((slot.accepted / slot.expected) * 100))
|
||||
: 0
|
||||
}}%</span
|
||||
>
|
||||
</div>
|
||||
<div
|
||||
class="h-1.5 w-full bg-zinc-950 rounded-full overflow-hidden border border-zinc-800/50"
|
||||
>
|
||||
<div
|
||||
class="h-full transition-all duration-500"
|
||||
:style="{
|
||||
width: `${slot.expected > 0 ? Math.min(100, (slot.accepted / slot.expected) * 100) : 0}%`
|
||||
}"
|
||||
:class="
|
||||
slot.expected > 0 && slot.accepted >= slot.expected
|
||||
? 'bg-rose-500'
|
||||
: 'bg-blue-500'
|
||||
"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="pt-6 border-t border-zinc-800/50 flex items-center justify-between">
|
||||
<div class="flex gap-2">
|
||||
<button
|
||||
v-show="!slot.past"
|
||||
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-blue-400 hover:border-blue-500/30 transition-all"
|
||||
@click="editRequestTime(slot)"
|
||||
>
|
||||
<Edit2 :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-red-400 hover:border-red-500/30 transition-all"
|
||||
@click="confirmDelete(slot)"
|
||||
>
|
||||
<Trash2 :size="14" />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
v-show="!slot.past"
|
||||
:class="[
|
||||
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
slot.enabled
|
||||
? 'bg-zinc-800 text-zinc-400 hover:text-zinc-200'
|
||||
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
|
||||
]"
|
||||
@click="toggleRequestTimeStatus(slot)"
|
||||
>
|
||||
<Power :size="12" />
|
||||
{{ slot.enabled ? '禁用' : '启用' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</TransitionGroup>
|
||||
</div>
|
||||
|
||||
<!-- 统计概览 -->
|
||||
<div
|
||||
class="bg-zinc-900/20 border border-zinc-800 rounded-3xl p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
||||
>
|
||||
<div v-for="(stat, i) in stats" :key="i" class="flex items-center gap-4 group">
|
||||
<div
|
||||
:class="[
|
||||
'w-12 h-12 rounded-2xl bg-zinc-950 border border-zinc-800 flex items-center justify-center text-zinc-500 transition-colors',
|
||||
`group-hover:text-${stat.color}-500`
|
||||
]"
|
||||
>
|
||||
<component :is="stat.icon" :size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
|
||||
{{ stat.label }}
|
||||
</p>
|
||||
<h5 class="text-xl font-black text-zinc-200">{{ stat.value }}</h5>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加/编辑模态框 -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showAddForm || editingRequestTime"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
|
||||
@click.self="cancelForm"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden"
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100">
|
||||
{{ editingRequestTime ? '编辑投稿开放时段' : '添加投稿开放时段' }}
|
||||
</h3>
|
||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="cancelForm">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-8 space-y-6 max-h-[70vh] overflow-y-auto">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>时段名称</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.name"
|
||||
type="text"
|
||||
placeholder="例如: 2025冬季常规点歌"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>开始日期时间</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.startTime"
|
||||
type="datetime-local"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>结束日期时间</label
|
||||
>
|
||||
<input
|
||||
v-model="formData.endTime"
|
||||
type="datetime-local"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>计划投稿数</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<input
|
||||
v-model="formData.expected"
|
||||
type="number"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
<div
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 flex items-center gap-1.5 text-zinc-600 font-bold text-[10px]"
|
||||
>
|
||||
<Hash :size="12" />
|
||||
首歌曲
|
||||
</div>
|
||||
</div>
|
||||
<p class="text-[9px] text-zinc-600 px-1">填 0 表示不限制投稿总数</p>
|
||||
</div>
|
||||
<div class="space-y-2 flex flex-col justify-center pt-2">
|
||||
<label class="flex items-center gap-3 cursor-pointer group px-1">
|
||||
<input
|
||||
v-model="formData.enabled"
|
||||
type="checkbox"
|
||||
class="w-4.5 h-4.5 rounded-lg border-zinc-800 bg-zinc-950 accent-blue-600"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
|
||||
>启用此投稿时段</span
|
||||
>
|
||||
<p class="text-[9px] text-zinc-600 font-medium">
|
||||
只有启用的时段才会根据时间限制生效
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>时段描述 (可选)</label
|
||||
>
|
||||
<textarea
|
||||
v-model="formData.description"
|
||||
placeholder="请输入针对此开放周期的特殊规则或介绍信息..."
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 min-h-[100px] resize-none"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="formError"
|
||||
class="p-4 bg-red-500/10 border border-red-500/20 rounded-2xl flex items-center gap-3 text-red-500 text-xs font-bold"
|
||||
>
|
||||
<AlertCircle :size="16" />
|
||||
{{ formError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="px-8 py-6 bg-zinc-900/50 border-t border-zinc-800/50 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
|
||||
@click="cancelForm"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="formSubmitting"
|
||||
class="px-10 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95 disabled:opacity-50"
|
||||
@click="saveRequestTime"
|
||||
>
|
||||
{{ formSubmitting ? '保存中...' : '保存设置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 删除确认模态框 -->
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="showDeleteConfirm"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
|
||||
@click.self="showDeleteConfirm = false"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden p-8"
|
||||
>
|
||||
<div class="flex flex-col items-center space-y-6">
|
||||
<div
|
||||
class="w-16 h-16 rounded-[2rem] bg-red-600/10 text-red-500 flex items-center justify-center border border-red-500/20 shadow-xl shadow-red-900/10"
|
||||
>
|
||||
<Trash2 :size="28" />
|
||||
</div>
|
||||
<div class="text-center space-y-2 px-4">
|
||||
<h4 class="text-lg font-bold text-zinc-100">
|
||||
确定要彻底移除 "{{ RequestTimeToDelete?.name }}" 吗?
|
||||
</h4>
|
||||
<p class="text-xs text-zinc-500 leading-relaxed">
|
||||
删除时段后,原本属于此周期的投稿关联记录可能失效。建议先尝试
|
||||
<span class="text-zinc-300 font-bold">禁用</span> 该时段。
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3 w-full pt-4">
|
||||
<button
|
||||
class="flex-1 px-4 py-3 bg-zinc-900 hover:bg-zinc-800 text-zinc-500 text-xs font-black rounded-2xl transition-all"
|
||||
@click="showDeleteConfirm = false"
|
||||
>
|
||||
保留时段
|
||||
</button>
|
||||
<button
|
||||
:disabled="deleteInProgress"
|
||||
class="flex-1 px-4 py-3 bg-red-600 hover:bg-red-500 text-white text-xs font-black rounded-2xl shadow-xl shadow-red-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
@click="deleteRequestTime"
|
||||
>
|
||||
{{ deleteInProgress ? '删除中...' : '确认删除' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
<!-- 说明部分 -->
|
||||
<div class="flex flex-col md:flex-row gap-6">
|
||||
<div class="flex-1 bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-4">
|
||||
<h4
|
||||
class="text-xs font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<CheckCircle2 :size="14" class="text-emerald-500" /> 逻辑优先级说明
|
||||
</h4>
|
||||
<ul class="text-[11px] text-zinc-500 space-y-2 font-medium">
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">1.</span>
|
||||
<span
|
||||
><span class="text-zinc-300">全局开关</span>
|
||||
具有最高优先级,关闭后所有投稿均不可用。</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">2.</span>
|
||||
<span
|
||||
>开启 <span class="text-zinc-300">时段限制</span> 后,系统会检查当前时间是否处于任一
|
||||
<span class="text-zinc-300">已启用</span> 且未达到
|
||||
<span class="text-zinc-300">计划数</span> 的时段内。</span
|
||||
>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">3.</span>
|
||||
<span>若关闭时段限制,则全天候均可投稿(只要全局开关开启)。</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="flex-1 bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-4">
|
||||
<h4
|
||||
class="text-xs font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<XCircle :size="14" class="text-rose-500" /> 注意事项
|
||||
</h4>
|
||||
<ul class="text-[11px] text-zinc-500 space-y-2 font-medium">
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">•</span>
|
||||
<span>时段重叠时,系统将合并开放范围。</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">•</span>
|
||||
<span>计划投稿数设置为 0 时,系统将忽略数量上限检查。</span>
|
||||
</li>
|
||||
<li class="flex gap-2">
|
||||
<span class="text-zinc-700 font-black">•</span>
|
||||
<span>过期的时段将自动显示为“已过期”,不可编辑或重新启用。</span>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { onMounted, onUnmounted, reactive, ref, computed } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import type { RequestTime } from '~/types'
|
||||
import {
|
||||
Plus,
|
||||
Calendar,
|
||||
Clock,
|
||||
Edit2,
|
||||
Trash2,
|
||||
Power,
|
||||
AlertCircle,
|
||||
Hash,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
X,
|
||||
BarChart3,
|
||||
Filter
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
const { getAuthConfig, isAdmin } = useAuth()
|
||||
|
||||
const requestTimes = ref<RequestTime[]>([])
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const showAddForm = ref(false)
|
||||
const editingRequestTime = ref<RequestTime | null>(null)
|
||||
const RequestTimeToDelete = ref<RequestTime | null>(null)
|
||||
const showDeleteConfirm = ref(false)
|
||||
const formSubmitting = ref(false)
|
||||
const deleteInProgress = ref(false)
|
||||
const formError = ref('')
|
||||
const enableRequestTimeLimitation = ref(false)
|
||||
const hitRequestTime = ref(false)
|
||||
const enableRequest = ref(true)
|
||||
|
||||
let refreshInterval: any = null
|
||||
|
||||
const formData = reactive({
|
||||
id: 0,
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
expected: 0
|
||||
})
|
||||
|
||||
// 统计数据
|
||||
const stats = computed(() => [
|
||||
{
|
||||
label: '活跃时段',
|
||||
value: requestTimes.value.filter((s) => s.enabled && !s.past).length.toString(),
|
||||
icon: Clock,
|
||||
color: 'blue'
|
||||
},
|
||||
{
|
||||
label: '累计已接收',
|
||||
value: requestTimes.value.reduce((acc, s) => acc + s.accepted, 0).toString(),
|
||||
icon: BarChart3,
|
||||
color: 'emerald'
|
||||
},
|
||||
{
|
||||
label: '计划总容量',
|
||||
value: requestTimes.value.reduce((acc, s) => acc + (s.expected || 0), 0) || '不限',
|
||||
icon: Hash,
|
||||
color: 'purple'
|
||||
},
|
||||
{
|
||||
label: '剩余总名额',
|
||||
value: (() => {
|
||||
const totalExpected = requestTimes.value.reduce((acc, s) => acc + (s.expected || 0), 0)
|
||||
const totalAccepted = requestTimes.value.reduce((acc, s) => acc + s.accepted, 0)
|
||||
return totalExpected > 0 ? Math.max(0, totalExpected - totalAccepted).toString() : '不限'
|
||||
})(),
|
||||
icon: Filter,
|
||||
color: 'amber'
|
||||
}
|
||||
])
|
||||
|
||||
onMounted(async () => {
|
||||
await fetchRequestTimes()
|
||||
await fetchSystemSettings()
|
||||
await fetchRequestTimeHit()
|
||||
|
||||
// 每 30 秒自动刷新一次状态,以确保时间同步
|
||||
refreshInterval = setInterval(fetchRequestTimeHit, 30000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (refreshInterval) clearInterval(refreshInterval)
|
||||
})
|
||||
|
||||
const fetchRequestTimes = async () => {
|
||||
if (!isAdmin.value) {
|
||||
error.value = '只有管理员才能管理投稿开放时段'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch('/api/admin/request-times', {
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
...authConfig
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `获取投稿开放时段失败: ${response.status}`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
requestTimes.value = data.sort((a: RequestTime, b: RequestTime) => {
|
||||
if (a.past !== b.past) return a.past ? 1 : -1
|
||||
if (a.enabled !== b.enabled) return a.enabled ? -1 : 1
|
||||
const aHasTime = !!(a.startTime || a.endTime)
|
||||
const bHasTime = !!(b.startTime || b.endTime)
|
||||
if (aHasTime !== bHasTime) return aHasTime ? -1 : 1
|
||||
if (aHasTime && bHasTime) {
|
||||
if (a.startTime && b.startTime) return a.startTime.localeCompare(b.startTime)
|
||||
else if (a.startTime) return -1
|
||||
else if (b.startTime) return 1
|
||||
}
|
||||
return a.name.localeCompare(b.name)
|
||||
})
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '获取投稿开放时段失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchSystemSettings = async () => {
|
||||
if (!isAdmin.value) return
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch('/api/admin/system-settings', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...authConfig
|
||||
})
|
||||
if (!response.ok) return
|
||||
const data = await response.json()
|
||||
enableRequestTimeLimitation.value = data.enableRequestTimeLimitation
|
||||
enableRequest.value = !data.forceBlockAllRequests
|
||||
} catch (err: any) {
|
||||
console.error('获取系统设置失败:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const fetchRequestTimeHit = async () => {
|
||||
if (!isAdmin.value) return
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch('/api/request-times', {
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
...authConfig
|
||||
})
|
||||
if (!response.ok) return
|
||||
const data = await response.json()
|
||||
hitRequestTime.value = data.hit
|
||||
} catch (err: any) {
|
||||
console.error('获取投稿开放状态失败:', err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const toggleGlobalRequest = async () => {
|
||||
enableRequest.value = !enableRequest.value
|
||||
await updateSystemSettings()
|
||||
await fetchRequestTimeHit()
|
||||
}
|
||||
|
||||
const toggleTimeLimitation = async () => {
|
||||
enableRequestTimeLimitation.value = !enableRequestTimeLimitation.value
|
||||
await updateSystemSettings()
|
||||
await fetchRequestTimeHit()
|
||||
}
|
||||
|
||||
const updateSystemSettings = async () => {
|
||||
if (!isAdmin.value) return
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch('/api/admin/system-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enableRequestTimeLimitation: enableRequestTimeLimitation.value,
|
||||
forceBlockAllRequests: !enableRequest.value
|
||||
}),
|
||||
...authConfig
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `更新系统设置失败: ${response.status}`)
|
||||
}
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '更新系统设置失败'
|
||||
}
|
||||
}
|
||||
|
||||
const editRequestTime = (RequestTime: RequestTime) => {
|
||||
editingRequestTime.value = RequestTime
|
||||
Object.assign(formData, {
|
||||
id: RequestTime.id,
|
||||
name: RequestTime.name,
|
||||
startTime: RequestTime.startTime,
|
||||
endTime: RequestTime.endTime,
|
||||
description: RequestTime.description || '',
|
||||
enabled: RequestTime.enabled,
|
||||
expected: RequestTime.expected || 0
|
||||
})
|
||||
}
|
||||
|
||||
const toggleRequestTimeStatus = async (RequestTime: RequestTime) => {
|
||||
if (!isAdmin.value) return
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch(`/api/admin/request-times/${RequestTime.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ enabled: !RequestTime.enabled }),
|
||||
...authConfig
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `更新投稿开放时段状态失败: ${response.status}`)
|
||||
}
|
||||
await fetchRequestTimes()
|
||||
await fetchRequestTimeHit()
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '更新投稿开放时段状态失败'
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDelete = (RequestTime: RequestTime) => {
|
||||
RequestTimeToDelete.value = RequestTime
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
const deleteRequestTime = async () => {
|
||||
if (!RequestTimeToDelete.value || !isAdmin.value) return
|
||||
deleteInProgress.value = true
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch(`/api/admin/request-times/${RequestTimeToDelete.value.id}`, {
|
||||
method: 'DELETE',
|
||||
...authConfig
|
||||
})
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(errorData.message || `删除投稿开放时段失败: ${response.status}`)
|
||||
}
|
||||
await fetchRequestTimes()
|
||||
await fetchRequestTimeHit()
|
||||
showDeleteConfirm.value = false
|
||||
RequestTimeToDelete.value = null
|
||||
} catch (err: any) {
|
||||
error.value = err.message || '删除投稿开放时段失败'
|
||||
} finally {
|
||||
deleteInProgress.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const saveRequestTime = async () => {
|
||||
formError.value = ''
|
||||
if (formData.startTime && formData.endTime && formData.startTime >= formData.endTime) {
|
||||
formError.value = '开始时间必须早于结束时间'
|
||||
return
|
||||
}
|
||||
if (!formData.name.trim()) {
|
||||
formError.value = '时段名称不能为空'
|
||||
return
|
||||
}
|
||||
const isUpdate = !!editingRequestTime.value
|
||||
const nameExists = requestTimes.value.some(
|
||||
(pt) =>
|
||||
pt.name.toLowerCase() === formData.name.trim().toLowerCase() &&
|
||||
(!isUpdate || pt.id !== formData.id)
|
||||
)
|
||||
if (nameExists) {
|
||||
formError.value = '投稿开放时段名称已存在,请使用其他名称'
|
||||
return
|
||||
}
|
||||
formSubmitting.value = true
|
||||
try {
|
||||
const authConfig = getAuthConfig()
|
||||
const response = await fetch(
|
||||
isUpdate ? `/api/admin/request-times/${formData.id}` : '/api/admin/request-times',
|
||||
{
|
||||
method: isUpdate ? 'PUT' : 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
name: formData.name.trim(),
|
||||
startTime: formData.startTime || null,
|
||||
endTime: formData.endTime || null,
|
||||
description: formData.description || null,
|
||||
enabled: formData.enabled,
|
||||
expected: formData.expected || 0
|
||||
}),
|
||||
...authConfig
|
||||
}
|
||||
)
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}))
|
||||
throw new Error(
|
||||
errorData.message || `${isUpdate ? '更新' : '创建'}投稿开放时段失败: ${response.status}`
|
||||
)
|
||||
}
|
||||
await fetchRequestTimes()
|
||||
await fetchRequestTimeHit()
|
||||
cancelForm()
|
||||
} catch (err: any) {
|
||||
formError.value = err.message || '保存投稿开放时段失败'
|
||||
} finally {
|
||||
formSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelForm = () => {
|
||||
showAddForm.value = false
|
||||
editingRequestTime.value = null
|
||||
formError.value = ''
|
||||
Object.assign(formData, {
|
||||
id: 0,
|
||||
name: '',
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
description: '',
|
||||
enabled: true,
|
||||
expected: 0
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,172 @@
|
||||
<template>
|
||||
<div
|
||||
class="backdrop-blur-md p-6 rounded-xl border border-white/10 bg-slate-800/70 shadow-2xl max-w-[400px] mx-auto text-zinc-100 transition-transform duration-200 hover:-translate-y-0.5 hover:shadow-[0_15px_30px_rgba(0,0,0,0.2)]"
|
||||
>
|
||||
<h3 class="mb-6 pb-2 border-b border-white/10 text-zinc-100 font-bold text-lg">
|
||||
为歌曲 "{{ song?.title }}" 创建排期
|
||||
</h3>
|
||||
|
||||
<form @submit.prevent="handleSubmit">
|
||||
<div class="mb-4">
|
||||
<label class="block mb-2 font-medium text-zinc-100" for="playDate">播放日期</label>
|
||||
<input
|
||||
id="playDate"
|
||||
v-model="playDate"
|
||||
class="w-full p-3 border border-white/10 rounded-lg text-base bg-[#0f172a99] text-zinc-100 outline-none transition-colors duration-150 focus:border-indigo-500 focus:shadow-[0_0_0_2px_rgba(99,102,241,0.25)]"
|
||||
required
|
||||
type="date"
|
||||
>
|
||||
</div>
|
||||
|
||||
<!-- 播出时段选择 -->
|
||||
<div v-if="playTimeEnabled" class="mb-4">
|
||||
<CustomSelect
|
||||
v-model="playTimeId"
|
||||
:options="playTimeOptions"
|
||||
label="播出时段"
|
||||
placeholder="未指定"
|
||||
class-name="w-full"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="song?.preferredPlayTime"
|
||||
class="mt-3 p-3 bg-white/5 rounded-lg text-sm flex items-start gap-2"
|
||||
>
|
||||
<div class="text-base">💡</div>
|
||||
<div>
|
||||
用户期望的播出时段:
|
||||
<span class="font-medium text-indigo-300">
|
||||
{{ song.preferredPlayTime.name }}
|
||||
<template v-if="song.preferredPlayTime.startTime || song.preferredPlayTime.endTime">
|
||||
({{ formatPlayTimeRange(song.preferredPlayTime) }})
|
||||
</template>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-3 mt-4 bg-red-500/10 text-red-300 rounded-lg">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="flex justify-between gap-4 mt-6">
|
||||
<button
|
||||
class="flex-1 p-3 border border-white/10 rounded-lg text-base cursor-pointer transition-all duration-200 bg-white/10 text-zinc-100 hover:bg-white/15"
|
||||
type="button"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="flex-1 p-3 border-none rounded-lg text-base cursor-pointer transition-all duration-200 bg-indigo-600 text-white hover:bg-indigo-700 hover:-translate-y-px disabled:bg-indigo-500/50 disabled:cursor-not-allowed disabled:transform-none"
|
||||
type="submit"
|
||||
>
|
||||
{{ loading ? '创建中...' : '创建排期' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { useSongs } from '~/composables/useSongs'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
|
||||
const props = defineProps({
|
||||
song: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
loading: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['schedule', 'cancel'])
|
||||
|
||||
const playDate = ref('')
|
||||
const playTimeId = ref('')
|
||||
const error = ref('')
|
||||
const playTimes = ref([])
|
||||
const { playTimeEnabled } = useSongs()
|
||||
|
||||
// 转换播出时段为 CustomSelect 选项格式
|
||||
const playTimeOptions = computed(() => {
|
||||
const options = [{ label: '未指定', value: '' }]
|
||||
|
||||
if (playTimes.value && playTimes.value.length > 0) {
|
||||
playTimes.value.forEach((pt) => {
|
||||
options.push({
|
||||
label: `${pt.name} (${pt.startTime} - ${pt.endTime})`,
|
||||
value: pt.id
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
return options
|
||||
})
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
await fetchPlayTimes()
|
||||
|
||||
// 如果歌曲有期望的播出时段,默认选择该时段
|
||||
if (props.song?.preferredPlayTimeId) {
|
||||
playTimeId.value = props.song.preferredPlayTimeId
|
||||
}
|
||||
})
|
||||
|
||||
// 获取播出时段
|
||||
const fetchPlayTimes = async () => {
|
||||
try {
|
||||
// 使用useSongs中的方法获取播放时段
|
||||
await useSongs().fetchPlayTimes()
|
||||
const response = await fetch('/api/admin/play-times')
|
||||
if (response.ok) {
|
||||
const data = await response.json()
|
||||
// 只显示启用的播放时段
|
||||
playTimes.value = data.filter((pt) => pt.enabled)
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('获取播出时段失败:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// 格式化播出时段时间范围
|
||||
const formatPlayTimeRange = (playTime) => {
|
||||
if (!playTime) return ''
|
||||
|
||||
if (playTime.startTime && playTime.endTime) {
|
||||
return `${playTime.startTime} - ${playTime.endTime}`
|
||||
} else if (playTime.startTime) {
|
||||
return `${playTime.startTime} 开始`
|
||||
} else if (playTime.endTime) {
|
||||
return `${playTime.endTime} 结束`
|
||||
}
|
||||
|
||||
return '不限时间'
|
||||
}
|
||||
|
||||
const handleSubmit = () => {
|
||||
error.value = ''
|
||||
|
||||
if (!playDate.value) {
|
||||
error.value = '请选择播放日期'
|
||||
return
|
||||
}
|
||||
|
||||
const selectedDate = new Date(playDate.value)
|
||||
|
||||
// 播出时段ID需要转换为数字或null
|
||||
const schedulePlayTimeId = playTimeId.value ? parseInt(playTimeId.value) : null
|
||||
|
||||
emit('schedule', {
|
||||
songId: props.song.id,
|
||||
playDate: selectedDate,
|
||||
playTimeId: schedulePlayTimeId
|
||||
})
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,308 @@
|
||||
<template>
|
||||
<div class="schedule-item-print">
|
||||
<div class="item-content">
|
||||
<!-- 序号 -->
|
||||
<div v-if="settings.showSequence" class="sequence-number">
|
||||
{{ schedule.sequence || 1 }}
|
||||
</div>
|
||||
|
||||
<!-- 歌曲封面 -->
|
||||
<div v-if="settings.showCover" class="cover-section">
|
||||
<img
|
||||
v-if="schedule.song.cover"
|
||||
:alt="schedule.song.title"
|
||||
:src="convertToHttps(schedule.song.cover)"
|
||||
class="song-cover"
|
||||
referrerpolicy="no-referrer"
|
||||
@error="handleImageError"
|
||||
>
|
||||
<div class="cover-placeholder" :class="{ 'show': !schedule.song.cover }">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
<path d="M12 1v6m0 6v6" />
|
||||
<path d="m21 12-6-3-6 3-6-3" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="song-info">
|
||||
<div v-if="settings.showTitle" class="song-title">
|
||||
{{ schedule.song.title }}
|
||||
<!-- 重播标识 -->
|
||||
<span v-if="schedule.song.replayRequestCount > 0" class="replay-badge-print"> 重播 </span>
|
||||
</div>
|
||||
<div v-if="settings.showArtist" class="song-artist">
|
||||
{{ schedule.song.artist }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 投稿人信息(重播歌曲不显示申请人,只显示原投稿人) -->
|
||||
<div v-if="settings.showRequester" class="requester-info">
|
||||
<span class="label">投稿人:</span>
|
||||
<span class="value">
|
||||
{{ schedule.song.requester }}
|
||||
<span v-if="schedule.song.collaborators && schedule.song.collaborators.length > 0">
|
||||
& {{ schedule.song.collaborators.map((c) => c.displayName || c.name).join(' & ') }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 人数信息 -->
|
||||
<div v-if="settings.showVotes" class="votes-info">
|
||||
<span v-if="schedule.song.replayRequestCount > 0" class="label">申请重播:</span>
|
||||
<span v-else class="label">热度:</span>
|
||||
<span class="value">{{
|
||||
schedule.song.replayRequestCount > 0
|
||||
? schedule.song.replayRequestCount + '人'
|
||||
: schedule.song.voteCount || 0
|
||||
}}</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { defineProps } from 'vue'
|
||||
import { convertToHttps } from '~/utils/url'
|
||||
|
||||
// 定义props
|
||||
defineProps({
|
||||
schedule: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
settings: {
|
||||
type: Object,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
// 处理图片加载错误
|
||||
const handleImageError = (event) => {
|
||||
event.target.style.display = 'none'
|
||||
event.target.nextElementSibling?.classList.add('show')
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.schedule-item-print {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #e5e5e5;
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
width: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.sequence-number {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
background: #f0f0f0;
|
||||
border-radius: 50%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: bold;
|
||||
font-size: 14px;
|
||||
color: #333;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cover-section {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-cover {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.cover-placeholder {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: #f5f5f5;
|
||||
border-radius: 4px;
|
||||
display: none;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.cover-placeholder.show {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.cover-placeholder svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
/* 打印用重播标识 */
|
||||
.replay-badge-print {
|
||||
display: inline-block;
|
||||
padding: 1px 4px;
|
||||
background: #e3f2fd;
|
||||
border: 1px solid #2196f3;
|
||||
border-radius: 3px;
|
||||
color: #1976d2;
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
font-size: 14px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.requester-info,
|
||||
.votes-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.value {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* 打印样式 */
|
||||
@media print {
|
||||
.schedule-item-print {
|
||||
color: #000 !important;
|
||||
background: #fff !important;
|
||||
width: 100% !important;
|
||||
max-width: none !important;
|
||||
box-sizing: border-box !important;
|
||||
}
|
||||
|
||||
.item-content {
|
||||
display: flex !important;
|
||||
flex-direction: row !important;
|
||||
align-items: center !important;
|
||||
width: 100% !important;
|
||||
gap: 12px !important;
|
||||
}
|
||||
|
||||
.sequence-number {
|
||||
background: #f0f0f0 !important;
|
||||
color: #000 !important;
|
||||
width: 30px !important;
|
||||
height: 30px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.cover-section {
|
||||
width: 40px !important;
|
||||
height: 40px !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
flex: 1 !important;
|
||||
min-width: 0 !important;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
color: #000 !important;
|
||||
font-size: 16px !important;
|
||||
white-space: nowrap !important;
|
||||
overflow: hidden !important;
|
||||
text-overflow: ellipsis !important;
|
||||
}
|
||||
|
||||
.song-artist,
|
||||
.label,
|
||||
.value {
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.time-range {
|
||||
color: #666 !important;
|
||||
}
|
||||
|
||||
.requester-info,
|
||||
.votes-info,
|
||||
.playtime-info {
|
||||
display: flex !important;
|
||||
align-items: center !important;
|
||||
font-size: 12px !important;
|
||||
white-space: nowrap !important;
|
||||
flex-shrink: 0 !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* 紧凑模式 */
|
||||
.compact .schedule-item-print {
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.compact .item-content {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.compact .sequence-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.compact .cover-section {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
}
|
||||
|
||||
.compact .song-title {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.compact .song-artist {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.compact .requester-info,
|
||||
.compact .votes-info,
|
||||
.compact .playtime-info {
|
||||
font-size: 11px;
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,638 @@
|
||||
<template>
|
||||
<div class="max-w-[1200px] mx-auto space-y-10 pb-20 px-2">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">学期管理</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">
|
||||
定义系统的教学周期,点歌、排期及统计数据将基于当前活跃学期进行归档
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
@click="openAddModal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="12" y1="5" x2="12" y2="19" />
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
</svg>
|
||||
添加新学期
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-10">
|
||||
<!-- 左侧栏:当前活跃学期 -->
|
||||
<div class="lg:col-span-5 space-y-6">
|
||||
<div class="flex items-center gap-2 px-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-amber-500 fill-amber-500"
|
||||
>
|
||||
<polygon
|
||||
points="12 2 15.09 8.26 22 9.27 17 14.14 18.18 21.02 12 17.77 5.82 21.02 7 14.14 2 9.27 8.91 8.26 12 2"
|
||||
/>
|
||||
</svg>
|
||||
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
|
||||
当前活跃学期
|
||||
</h3>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="currentSemester"
|
||||
class="relative bg-gradient-to-br from-blue-600 to-blue-700 rounded-3xl p-10 shadow-2xl shadow-blue-900/20 overflow-hidden group"
|
||||
>
|
||||
<div class="relative z-10 space-y-6">
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-white/10 backdrop-blur-md flex items-center justify-center text-white border border-white/20"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="32"
|
||||
height="32"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<div class="space-y-2">
|
||||
<h4 class="text-3xl font-black text-white tracking-tight leading-tight">
|
||||
{{ currentSemester.name }}
|
||||
</h4>
|
||||
<div class="flex items-center gap-2 text-blue-100/60 font-bold text-xs">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
|
||||
<line x1="16" y1="2" x2="16" y2="6" />
|
||||
<line x1="8" y1="2" x2="8" y2="6" />
|
||||
<line x1="3" y1="10" x2="21" y2="10" />
|
||||
</svg>
|
||||
创建于 {{ formatDate(currentSemester.createdAt) }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="pt-4">
|
||||
<span
|
||||
class="px-4 py-2 bg-white text-blue-600 text-[10px] font-black uppercase tracking-widest rounded-xl shadow-lg"
|
||||
>
|
||||
正在进行中
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 装饰性背景元素 -->
|
||||
<div class="absolute -right-10 -bottom-10 w-64 h-64 bg-white/5 blur-3xl rounded-full" />
|
||||
<div class="absolute top-10 right-10 opacity-10">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="160"
|
||||
height="160"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-white"
|
||||
>
|
||||
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else
|
||||
class="bg-zinc-900/30 border border-zinc-800 border-dashed rounded-3xl p-20 flex flex-col items-center justify-center text-center gap-4"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="40"
|
||||
height="40"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-zinc-700"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-sm font-bold text-zinc-600">未设置当前活跃学期</p>
|
||||
<button class="text-xs text-blue-500 font-black hover:underline" @click="openAddModal">
|
||||
立即创建
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-zinc-900/20 border border-zinc-800 rounded-3xl flex items-start gap-4">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="18"
|
||||
height="18"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-zinc-500 shrink-0 mt-0.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="16" x2="12" y2="12" />
|
||||
<line x1="12" y1="8" x2="12.01" y2="8" />
|
||||
</svg>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs font-bold text-zinc-400">学期切换说明</p>
|
||||
<p class="text-[11px] text-zinc-600 leading-relaxed">
|
||||
切换学期后,前端点歌界面将清空旧排期。所有历史数据会自动归档至对应学期下,您仍可在数据中心进行查看。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 右侧栏:学期历史记录 -->
|
||||
<div class="lg:col-span-7 space-y-6">
|
||||
<div class="flex items-center justify-between px-1">
|
||||
<div class="flex items-center gap-2">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-zinc-600"
|
||||
>
|
||||
<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5" />
|
||||
<path d="M12 7v5l4 2" />
|
||||
</svg>
|
||||
<h3 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em]">
|
||||
学期历史记录
|
||||
</h3>
|
||||
</div>
|
||||
<span class="text-[10px] font-black text-zinc-700">共 {{ semesters.length }} 条</span>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
v-if="loading && semesters.length === 0"
|
||||
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
|
||||
>
|
||||
<p class="text-xs font-bold text-zinc-600">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="sem in sortedSemesters"
|
||||
:key="sem.id"
|
||||
class="group flex items-center justify-between p-6 rounded-[2rem] border transition-all"
|
||||
:class="
|
||||
sem.isActive
|
||||
? 'bg-blue-600/5 border-blue-500/30'
|
||||
: 'bg-zinc-900/40 border-zinc-800 hover:border-zinc-700'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-5">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl flex items-center justify-center transition-all"
|
||||
:class="
|
||||
sem.isActive
|
||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
||||
: 'bg-zinc-800 text-zinc-600 border border-zinc-700'
|
||||
"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="21 8 21 21 3 21 3 8" />
|
||||
<rect x="1" y="3" width="22" height="5" />
|
||||
<line x1="10" y1="12" x2="14" y2="12" />
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<h5
|
||||
class="font-bold transition-colors"
|
||||
:class="
|
||||
sem.isActive ? 'text-blue-400' : 'text-zinc-200 group-hover:text-blue-400'
|
||||
"
|
||||
>
|
||||
{{ sem.name }}
|
||||
</h5>
|
||||
<p class="text-[10px] text-zinc-600 font-medium uppercase tracking-widest mt-0.5">
|
||||
创建于 {{ formatDate(sem.createdAt) }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-2">
|
||||
<div
|
||||
v-if="sem.isActive"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-emerald-500/10 text-emerald-500 rounded-xl border border-emerald-500/20"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
|
||||
<polyline points="22 4 12 14.01 9 11.01" />
|
||||
</svg>
|
||||
<span class="text-[10px] font-black uppercase tracking-widest">当前学期</span>
|
||||
</div>
|
||||
<template v-else>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-400 hover:text-white text-[10px] font-black rounded-xl border border-zinc-700 transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="setActive(sem.id)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="12"
|
||||
height="12"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="5" y1="12" x2="19" y2="12" />
|
||||
<polyline points="12 5 19 12 12 19" />
|
||||
</svg>
|
||||
设为当前
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading"
|
||||
class="p-2.5 text-zinc-700 hover:text-red-500 hover:bg-red-500/10 rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="deleteSemester(sem.id)"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<polyline points="3 6 5 6 21 6" />
|
||||
<path
|
||||
d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"
|
||||
/>
|
||||
<line x1="10" y1="11" x2="10" y2="17" />
|
||||
<line x1="14" y1="11" x2="14" y2="17" />
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="semesters.length === 0 && !loading"
|
||||
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
|
||||
>
|
||||
<p class="text-xs font-bold text-zinc-600">暂无学期数据</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 添加学期弹窗 -->
|
||||
<div
|
||||
v-if="showAddModal"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-zinc-900 rounded-3xl border border-zinc-800 shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<div class="p-8 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-white tracking-tight">添加新学期</h3>
|
||||
<button
|
||||
class="p-2 text-zinc-500 hover:text-white transition-colors"
|
||||
@click="closeAddModal"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-6">
|
||||
<div class="space-y-2">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>学期名称</label
|
||||
>
|
||||
<input
|
||||
v-model="newSemester.name"
|
||||
type="text"
|
||||
placeholder="例如: 2025学年第一学期"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 font-bold transition-all"
|
||||
>
|
||||
</div>
|
||||
|
||||
<label class="flex items-center gap-3 cursor-pointer group px-1">
|
||||
<input
|
||||
v-model="newSemester.isActive"
|
||||
type="checkbox"
|
||||
class="w-4.5 h-4.5 rounded-lg border-zinc-800 bg-zinc-950 accent-blue-600 transition-all"
|
||||
>
|
||||
<div>
|
||||
<span
|
||||
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
|
||||
>设为当前活跃学期</span
|
||||
>
|
||||
<p class="text-[10px] text-zinc-600 font-medium">
|
||||
创建后将立即激活此学期,原学期将变为历史状态
|
||||
</p>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl flex items-start gap-3">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
class="text-amber-500 shrink-0 mt-0.5"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" y1="8" x2="12" y2="12" />
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" />
|
||||
</svg>
|
||||
<p class="text-[10px] text-zinc-500 leading-normal">
|
||||
提示:请按照统一的命名规范设置学期名称,这有助于数据归档后的检索与分析。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-6 bg-zinc-800/50 border-t border-zinc-800 flex gap-3 justify-end">
|
||||
<button
|
||||
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
@click="closeAddModal"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="submitting || !newSemester.name.trim()"
|
||||
class="px-8 py-2.5 bg-blue-600 hover:bg-blue-500 disabled:bg-blue-800 disabled:opacity-50 text-white text-xs font-black rounded-xl shadow-lg transition-all active:scale-95"
|
||||
@click="handleAddSemester"
|
||||
>
|
||||
{{ submitting ? '创建中...' : '创建学期' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 确认删除对话框 -->
|
||||
<ConfirmDialog
|
||||
:loading="loading"
|
||||
:show="showDeleteDialog"
|
||||
cancel-text="取消"
|
||||
confirm-text="确认删除"
|
||||
:message="deleteMessage"
|
||||
title="确认删除学期"
|
||||
type="danger"
|
||||
@close="showDeleteDialog = false"
|
||||
@confirm="confirmDelete"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
|
||||
const {
|
||||
semesters,
|
||||
currentSemester,
|
||||
loading,
|
||||
error,
|
||||
fetchSemesters,
|
||||
fetchCurrentSemester,
|
||||
createSemester,
|
||||
setActiveSemester,
|
||||
deleteSemester: deleteSemesterAPI
|
||||
} = useSemesters()
|
||||
|
||||
const showAddModal = ref(false)
|
||||
const showDeleteDialog = ref(false)
|
||||
const deleteTargetId = ref(null)
|
||||
const deleteTargetName = ref('')
|
||||
const submitting = ref(false)
|
||||
const newSemester = ref({
|
||||
name: '',
|
||||
isActive: false
|
||||
})
|
||||
|
||||
// 按创建时间排序
|
||||
const sortedSemesters = computed(() => {
|
||||
return [...semesters.value].sort((a, b) => {
|
||||
return new Date(b.createdAt) - new Date(a.createdAt)
|
||||
})
|
||||
})
|
||||
|
||||
// 删除确认信息
|
||||
const deleteMessage = computed(() => {
|
||||
return `确定要彻底删除 "${deleteTargetName.value}" 吗?此操作将同步清除该学期下的所有点歌记录、排期数据和统计信息。数据一旦删除将无法找回。`
|
||||
})
|
||||
|
||||
// 格式化日期
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 设置活跃学期
|
||||
const setActive = async (semesterId) => {
|
||||
try {
|
||||
const success = await setActiveSemester(semesterId)
|
||||
// 注意:这里需要重新获取error,因为useSemesters内部可能没有正确更新error的响应式引用
|
||||
// 或者直接依据success返回值判断
|
||||
if (success) {
|
||||
showNotification('活跃学期设置成功!', 'success')
|
||||
} else {
|
||||
// 如果setActiveSemester返回false,尝试直接显示一个通用错误,因为error可能为空
|
||||
showNotification(error.value || '设置活跃学期失败,请稍后重试', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err.message || '设置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 准备删除学期
|
||||
const deleteSemester = (semesterId) => {
|
||||
const semester = semesters.value.find((s) => s.id === semesterId)
|
||||
if (semester) {
|
||||
deleteTargetId.value = semesterId
|
||||
deleteTargetName.value = semester.name
|
||||
showDeleteDialog.value = true
|
||||
}
|
||||
}
|
||||
|
||||
// 确认删除
|
||||
const confirmDelete = async () => {
|
||||
if (!deleteTargetId.value) return
|
||||
|
||||
try {
|
||||
const success = await deleteSemesterAPI(deleteTargetId.value)
|
||||
if (success) {
|
||||
showNotification('学期删除成功!', 'success')
|
||||
} else {
|
||||
showNotification(error.value || '删除学期失败', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
showNotification(err.message || '删除失败', 'error')
|
||||
}
|
||||
|
||||
showDeleteDialog.value = false
|
||||
deleteTargetId.value = null
|
||||
deleteTargetName.value = ''
|
||||
}
|
||||
|
||||
// 处理添加学期
|
||||
const handleAddSemester = async () => {
|
||||
if (!newSemester.value.name.trim()) return
|
||||
|
||||
submitting.value = true
|
||||
try {
|
||||
const result = await createSemester({
|
||||
name: newSemester.value.name,
|
||||
isActive: newSemester.value.isActive
|
||||
})
|
||||
if (result) {
|
||||
showNotification('学期创建成功!', 'success')
|
||||
closeAddModal()
|
||||
} else {
|
||||
showNotification(error.value || '创建学期失败', 'error')
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('创建学期失败:', err)
|
||||
showNotification(err.message || '创建学期失败', 'error')
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭添加弹窗
|
||||
const closeAddModal = () => {
|
||||
showAddModal.value = false
|
||||
newSemester.value = {
|
||||
name: '',
|
||||
isActive: false
|
||||
}
|
||||
}
|
||||
|
||||
const getRecommendedName = () => {
|
||||
const now = new Date()
|
||||
const year = now.getFullYear()
|
||||
const month = now.getMonth() + 1
|
||||
|
||||
let academicYear = year
|
||||
let term = '第一学期'
|
||||
|
||||
if (month >= 3 && month <= 8) {
|
||||
academicYear = year - 1
|
||||
term = '第二学期'
|
||||
} else if (month <= 2) {
|
||||
academicYear = year - 1
|
||||
term = '第一学期'
|
||||
} else {
|
||||
academicYear = year
|
||||
term = '第一学期'
|
||||
}
|
||||
|
||||
return `${academicYear}学年${term}`
|
||||
}
|
||||
|
||||
const openAddModal = () => {
|
||||
newSemester.value.name = getRecommendedName()
|
||||
showAddModal.value = true
|
||||
}
|
||||
|
||||
// 组件挂载时获取数据
|
||||
onMounted(async () => {
|
||||
await Promise.all([fetchCurrentSemester(), fetchSemesters()])
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
@@ -0,0 +1,256 @@
|
||||
<!-- 侧边栏组件 -->
|
||||
<template>
|
||||
<aside
|
||||
:class="[
|
||||
'fixed inset-y-0 left-0 z-50 w-64 bg-[#09090b] border-r border-zinc-800 transform transition-transform duration-300 ease-in-out lg:translate-x-0',
|
||||
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||
]"
|
||||
>
|
||||
<div class="flex flex-col h-full p-4">
|
||||
<!-- 品牌标识区域 -->
|
||||
<div class="flex items-center px-2 mb-6 mt-2">
|
||||
<NuxtLink to="/" class="flex items-center gap-2.5 group">
|
||||
<!-- Logo 图标 -->
|
||||
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
|
||||
<img :src="logo" alt="VoiceHub Logo" class="w-8 h-8 object-contain" >
|
||||
</div>
|
||||
<!-- 品牌文字 -->
|
||||
<div class="flex flex-col justify-center">
|
||||
<h1 class="font-bold text-lg text-zinc-100 leading-none tracking-tight">VoiceHub</h1>
|
||||
<p
|
||||
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
|
||||
>
|
||||
管理控制台
|
||||
</p>
|
||||
</div>
|
||||
</NuxtLink>
|
||||
</div>
|
||||
|
||||
<!-- 导航菜单区域 -->
|
||||
<nav class="flex-1 space-y-6 overflow-y-auto pr-2 custom-scrollbar">
|
||||
<div v-for="(group, idx) in menuGroups" :key="idx" class="space-y-1">
|
||||
<template v-if="shouldShowGroup(group)">
|
||||
<!-- 分组标题 -->
|
||||
<h3 class="px-3 text-[10px] font-bold text-zinc-600 uppercase tracking-[0.2em] mb-2">
|
||||
{{ group.section }}
|
||||
</h3>
|
||||
<!-- 菜单项列表 -->
|
||||
<template v-for="item in group.items" :key="item.id">
|
||||
<button
|
||||
v-if="permissions.canAccessPage(item.permissionId || item.id)"
|
||||
:class="[
|
||||
'w-full flex items-center gap-3 px-3 py-2.5 rounded-lg text-[13px] font-bold transition-all group border focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500/80 focus-visible:ring-inset',
|
||||
activeTab === item.id
|
||||
? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
|
||||
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40 border-transparent'
|
||||
]"
|
||||
@click="onNavigate(item.id)"
|
||||
>
|
||||
<!-- 菜单图标 -->
|
||||
<component
|
||||
:is="item.icon"
|
||||
:size="18"
|
||||
:class="
|
||||
activeTab === item.id
|
||||
? 'text-blue-400'
|
||||
: 'text-zinc-500 group-hover:text-zinc-300'
|
||||
"
|
||||
/>
|
||||
<span class="truncate">{{ item.label }}</span>
|
||||
<!-- 选中状态指示器 -->
|
||||
<div
|
||||
v-if="activeTab === item.id"
|
||||
class="ml-auto w-1 h-1 bg-blue-400 rounded-full shadow-[0_0_8px_rgba(96,165,250,0.6)]"
|
||||
/>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- 用户信息及退出登录 -->
|
||||
<div class="mt-4 pt-4 border-t border-zinc-800">
|
||||
<div
|
||||
class="flex items-center gap-3 p-3 rounded-lg bg-zinc-900/50 border border-zinc-800/50 hover:bg-zinc-800/30 transition-colors"
|
||||
>
|
||||
<!-- 用户头像/首字母 -->
|
||||
<img
|
||||
v-if="currentUser?.avatar && !avatarError"
|
||||
:src="currentUser.avatar"
|
||||
class="w-10 h-10 rounded-lg object-cover border border-zinc-700 shrink-0"
|
||||
@error="avatarError = true"
|
||||
>
|
||||
<div
|
||||
v-else
|
||||
class="w-10 h-10 rounded-lg bg-zinc-800 flex items-center justify-center text-zinc-400 font-bold border border-zinc-700 shrink-0"
|
||||
>
|
||||
{{ (currentUser?.name || '管').charAt(0) }}
|
||||
</div>
|
||||
<!-- 用户详细信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<p class="text-xs font-black truncate text-zinc-100">
|
||||
{{ currentUser?.name || '管理员' }}
|
||||
</p>
|
||||
<p
|
||||
class="text-[10px] text-zinc-500 truncate uppercase tracking-wider font-medium mt-0.5"
|
||||
>
|
||||
{{ currentUser?.role?.replace('_', ' ') || 'ADMIN' }}
|
||||
</p>
|
||||
</div>
|
||||
<!-- 退出按钮 -->
|
||||
<button
|
||||
class="p-2 text-zinc-600 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
|
||||
title="退出登录"
|
||||
@click="$emit('logout')"
|
||||
>
|
||||
<LogOut :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
/**
|
||||
* 后台管理侧边栏组件
|
||||
*/
|
||||
import {
|
||||
LayoutDashboard,
|
||||
CalendarDays,
|
||||
Printer,
|
||||
Music2,
|
||||
BarChart3,
|
||||
Users,
|
||||
Key,
|
||||
Bell,
|
||||
Mail,
|
||||
LogOut,
|
||||
Clock,
|
||||
FileEdit,
|
||||
BookOpen,
|
||||
Ban,
|
||||
Globe,
|
||||
Database,
|
||||
Lock
|
||||
} from 'lucide-vue-next'
|
||||
import logo from '~~/public/images/logo.png'
|
||||
|
||||
const avatarError = ref(false)
|
||||
|
||||
const props = defineProps({
|
||||
// 侧边栏是否打开(移动端)
|
||||
isOpen: Boolean,
|
||||
// 当前激活的标签页 ID
|
||||
activeTab: String,
|
||||
// 当前登录用户信息
|
||||
currentUser: Object,
|
||||
// 权限控制对象
|
||||
permissions: Object,
|
||||
// 站点标题
|
||||
siteTitle: String
|
||||
})
|
||||
|
||||
watch(
|
||||
() => props.currentUser?.avatar,
|
||||
() => {
|
||||
avatarError.value = false
|
||||
}
|
||||
)
|
||||
|
||||
const emit = defineEmits(['navigate', 'close', 'logout'])
|
||||
|
||||
// 菜单分组配置
|
||||
const menuGroups = [
|
||||
{
|
||||
section: '概览',
|
||||
items: [{ icon: LayoutDashboard, label: '数据概览', id: 'overview' }]
|
||||
},
|
||||
{
|
||||
section: '内容管理',
|
||||
items: [
|
||||
{ icon: CalendarDays, label: '排期管理', id: 'schedule' },
|
||||
{ icon: Printer, label: '打印排期', id: 'print' },
|
||||
{ icon: Music2, label: '歌曲管理', id: 'songs' },
|
||||
{ icon: BarChart3, label: '数据分析', id: 'data-analysis', permissionId: 'data-analysis' }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '用户管理',
|
||||
items: [{ icon: Users, label: '用户管理', id: 'users' }]
|
||||
},
|
||||
{
|
||||
section: 'API管理',
|
||||
items: [{ icon: Key, label: 'API密钥管理', id: 'api-keys' }]
|
||||
},
|
||||
{
|
||||
section: '系统管理',
|
||||
items: [
|
||||
{ icon: Bell, label: '通知管理', id: 'notifications' },
|
||||
{ icon: Mail, label: '邮件配置', id: 'smtp-config' },
|
||||
{ icon: Clock, label: '播出时段', id: 'playtimes' },
|
||||
{ icon: FileEdit, label: '投稿管理', id: 'request-times' },
|
||||
{ icon: BookOpen, label: '学期管理', id: 'semesters' },
|
||||
{ icon: Ban, label: '黑名单管理', id: 'blacklist' },
|
||||
{ icon: Globe, label: '站点配置', id: 'site-config' },
|
||||
{ icon: Database, label: '数据库操作', id: 'database' }
|
||||
]
|
||||
},
|
||||
{
|
||||
section: '账户管理',
|
||||
items: [{ icon: Lock, label: '修改密码', id: 'password' }]
|
||||
}
|
||||
]
|
||||
|
||||
/**
|
||||
* 判断是否应该显示该菜单组
|
||||
* @param {Object} group 菜单组对象
|
||||
*/
|
||||
const shouldShowGroup = (group) => {
|
||||
if (!props.permissions) return true
|
||||
return group.items.some((item) => props.permissions.canAccessPage(item.permissionId || item.id))
|
||||
}
|
||||
|
||||
/**
|
||||
* 导航点击处理
|
||||
* @param {string} id 菜单项 ID
|
||||
*/
|
||||
const onNavigate = (id) => {
|
||||
if (id === 'password') {
|
||||
navigateTo('/change-password')
|
||||
return
|
||||
}
|
||||
emit('navigate', id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取角色显示名称
|
||||
* @param {string} role 角色标识
|
||||
*/
|
||||
const getRoleDisplayName = (role) => {
|
||||
const roleNames = {
|
||||
USER: '普通用户',
|
||||
SONG_ADMIN: '歌曲管理员',
|
||||
ADMIN: '管理员',
|
||||
SUPER_ADMIN: '超级管理员'
|
||||
}
|
||||
return roleNames[role] || role
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 自定义滚动条样式 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: #27272a;
|
||||
border-radius: 10px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: #3f3f46;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,536 @@
|
||||
<template>
|
||||
<div class="max-w-[1200px] mx-auto space-y-6 pb-24 px-2">
|
||||
<!-- 顶部标题栏 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">站点配置</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1 font-medium">
|
||||
管理站点全局属性、视觉识别、点歌逻辑及系统安全策略
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
:disabled="loading || saving"
|
||||
class="flex items-center gap-2 px-5 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="resetForm"
|
||||
>
|
||||
<RotateCcw :size="14" /> 重置
|
||||
</button>
|
||||
<button
|
||||
:disabled="loading || saving"
|
||||
class="flex items-center gap-2 px-8 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
@click="saveConfig"
|
||||
>
|
||||
<template v-if="saving">
|
||||
<div
|
||||
class="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin"
|
||||
/>
|
||||
保存中...
|
||||
</template>
|
||||
<template v-else-if="saveSuccess"> <CheckCircle2 :size="14" /> 已保存 </template>
|
||||
<template v-else> <Save :size="14" /> 保存配置 </template>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
|
||||
<div
|
||||
class="w-8 h-8 border-4 border-zinc-800 border-t-blue-500 rounded-full animate-spin mb-4"
|
||||
/>
|
||||
<p class="text-zinc-500 text-sm">加载配置中...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- 基础信息 -->
|
||||
<section :class="cardClass">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
|
||||
>
|
||||
<Globe :size="16" class="text-blue-500" /> 基础信息
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label :class="labelClass">站点标题</label>
|
||||
<input
|
||||
v-model="formData.siteTitle"
|
||||
type="text"
|
||||
placeholder="请输入站点标题"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">备案号 (ICP)</label>
|
||||
<input
|
||||
v-model="formData.icpNumber"
|
||||
type="text"
|
||||
placeholder="请输入备案号"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">公安联网备案号</label>
|
||||
<input
|
||||
v-model="formData.gonganNumber"
|
||||
type="text"
|
||||
placeholder="请输入公安备案号 (如:陕公网安备61011302001964号)"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">站点描述</label>
|
||||
<textarea
|
||||
v-model="formData.siteDescription"
|
||||
:rows="3"
|
||||
placeholder="请输入站点描述"
|
||||
:class="[inputClass, 'resize-none']"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 视觉识别 -->
|
||||
<section :class="cardClass">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
|
||||
>
|
||||
<ImageIcon :size="16" class="text-purple-500" /> 视觉识别
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label :class="labelClass">站点 Logo URL</label>
|
||||
<input
|
||||
v-model="formData.siteLogoUrl"
|
||||
type="text"
|
||||
placeholder="请输入Logo图片URL"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">首页学校 Logo URL (大尺寸)</label>
|
||||
<input
|
||||
v-model="formData.schoolLogoHomeUrl"
|
||||
type="text"
|
||||
placeholder="请输入首页学校Logo URL"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
<div>
|
||||
<label :class="labelClass">打印排期 Logo URL (小尺寸)</label>
|
||||
<input
|
||||
v-model="formData.schoolLogoPrintUrl"
|
||||
type="text"
|
||||
placeholder="请输入打印页学校Logo URL"
|
||||
:class="inputClass"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 投稿须知 -->
|
||||
<section
|
||||
class="lg:col-span-2 bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 space-y-6"
|
||||
>
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
|
||||
>
|
||||
<FileText :size="16" class="text-emerald-500" /> 投稿须知
|
||||
</h3>
|
||||
<textarea
|
||||
v-model="formData.submissionGuidelines"
|
||||
:rows="6"
|
||||
placeholder="请输入投稿须知内容"
|
||||
:class="[inputClass, 'font-mono text-xs leading-relaxed min-h-[150px]']"
|
||||
/>
|
||||
</section>
|
||||
|
||||
<!-- 投稿逻辑设置 -->
|
||||
<section :class="cardClass">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
|
||||
>
|
||||
<Settings2 :size="16" class="text-amber-500" /> 投稿逻辑设置
|
||||
</h3>
|
||||
<div class="space-y-6">
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-zinc-200">启用联合投稿</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户添加联合投稿人并发起协作投稿</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="formData.enableCollaborativeSubmission"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-zinc-200">启用投稿备注留言</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户在投稿时附加公开或仅管理员可见的备注</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="formData.enableSubmissionRemarks"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-zinc-200">启用重播申请</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户对本学期已播放过的歌曲再次申请</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="formData.enableReplayRequests"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl"
|
||||
>
|
||||
<div>
|
||||
<p class="text-xs font-bold text-zinc-200">启用投稿限额</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-0.5">限制单个用户的点歌频率</p>
|
||||
</div>
|
||||
<input
|
||||
v-model="formData.enableSubmissionLimit"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
|
||||
<div v-if="formData.enableSubmissionLimit" class="space-y-4">
|
||||
<div class="grid grid-cols-3 gap-2 p-1 bg-zinc-950 border border-zinc-800 rounded-xl">
|
||||
<button
|
||||
:class="[
|
||||
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
currentLimitType === 'daily'
|
||||
? 'bg-zinc-800 text-blue-400 shadow-sm'
|
||||
: 'text-zinc-600 hover:text-zinc-400'
|
||||
]"
|
||||
@click="handleLimitTypeChange('daily')"
|
||||
>
|
||||
每日限额
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
currentLimitType === 'weekly'
|
||||
? 'bg-zinc-800 text-blue-400 shadow-sm'
|
||||
: 'text-zinc-600 hover:text-zinc-400'
|
||||
]"
|
||||
@click="handleLimitTypeChange('weekly')"
|
||||
>
|
||||
每周限额
|
||||
</button>
|
||||
<button
|
||||
:class="[
|
||||
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||
currentLimitType === 'monthly'
|
||||
? 'bg-zinc-800 text-blue-400 shadow-sm'
|
||||
: 'text-zinc-600 hover:text-zinc-400'
|
||||
]"
|
||||
@click="handleLimitTypeChange('monthly')"
|
||||
>
|
||||
每月限额
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label :class="labelClass"
|
||||
>{{ currentLimitType === 'daily' ? '单日' : (currentLimitType === 'weekly' ? '单周' : '单月') }}投稿上限</label
|
||||
>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model.number="currentLimitValue"
|
||||
type="number"
|
||||
min="0"
|
||||
:class="inputClass"
|
||||
>
|
||||
<span
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-[10px] font-black text-zinc-700 uppercase"
|
||||
>首 / 人</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- 安全与隐私设置 -->
|
||||
<section :class="cardClass">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-800 pb-4"
|
||||
>
|
||||
<Shield :size="16" class="text-rose-500" /> 安全与隐私设置
|
||||
</h3>
|
||||
<div class="space-y-4">
|
||||
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="shrink-0 pt-0.5">
|
||||
<input
|
||||
id="show-keywords"
|
||||
v-model="formData.showBlacklistKeywords"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
<label for="show-keywords" class="cursor-pointer">
|
||||
<p class="text-xs font-bold text-zinc-200">显示黑名单具体关键词</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
|
||||
开启后,在投稿命中黑名单时将明确提示冲突关键词;关闭则仅提示“包含关键词”。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
|
||||
<div class="flex items-start gap-4">
|
||||
<div class="shrink-0 pt-0.5">
|
||||
<input
|
||||
id="hide-students"
|
||||
v-model="formData.hideStudentInfo"
|
||||
type="checkbox"
|
||||
class="w-4 h-4 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
>
|
||||
</div>
|
||||
<label for="hide-students" class="cursor-pointer">
|
||||
<p class="text-xs font-bold text-zinc-200">隐藏学生详细信息</p>
|
||||
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
|
||||
开启后,非管理员用户在前端点歌列表、排期预览中将无法查看投稿学生的完整学号与真实姓名。
|
||||
</p>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="p-4 bg-blue-500/5 border border-blue-500/10 rounded-xl flex items-start gap-3"
|
||||
>
|
||||
<AlertCircle class="text-blue-500 shrink-0 mt-0.5" :size="14" />
|
||||
<p class="text-[10px] text-zinc-500 leading-normal">
|
||||
站点基础配置在保存后将立即对所有终端生效。请在修改关键业务逻辑(如投稿限额)前确保已知晓对现有用户的影响。
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import {
|
||||
Globe,
|
||||
ImageIcon,
|
||||
FileText,
|
||||
Settings2,
|
||||
Shield,
|
||||
Save,
|
||||
RotateCcw,
|
||||
CheckCircle2,
|
||||
AlertCircle
|
||||
} from 'lucide-vue-next'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
const saveSuccess = ref(false)
|
||||
|
||||
// 样式类常量
|
||||
const inputClass =
|
||||
'w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all placeholder:text-zinc-800'
|
||||
const labelClass = 'text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2'
|
||||
const cardClass = 'bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 shadow-xl space-y-6'
|
||||
|
||||
const defaultSubmissionGuidelines = `1. 投稿时无需加入书名号
|
||||
2. 除DJ外,其他类型歌曲均接收(包括小语种)
|
||||
3. 禁止投递含有违规内容的歌曲
|
||||
4. 点播的歌曲将由管理员进行审核
|
||||
5. 审核通过后将安排在播放时段播出
|
||||
6. 提交即表明我已阅读投稿须知并已知该歌曲有概率无法播出
|
||||
7. 本系统仅提供音乐搜索和播放管理功能,不存储任何音乐文件。所有音乐内容均来自第三方音乐平台,版权归原平台及版权方所有。用户点歌时请确保遵守相关音乐平台的服务条款,尊重音乐作品版权。我们鼓励用户支持正版音乐,在官方平台购买和收听喜爱的音乐作品。
|
||||
8. 最终解释权归广播站所有`
|
||||
|
||||
const formData = ref({
|
||||
siteTitle: '',
|
||||
siteLogoUrl: '',
|
||||
schoolLogoHomeUrl: '',
|
||||
schoolLogoPrintUrl: '',
|
||||
siteDescription: '',
|
||||
submissionGuidelines: '',
|
||||
icpNumber: '',
|
||||
gonganNumber: '',
|
||||
enableCollaborativeSubmission: true,
|
||||
enableSubmissionRemarks: false,
|
||||
enableReplayRequests: false,
|
||||
enableSubmissionLimit: false,
|
||||
dailySubmissionLimit: 5,
|
||||
weeklySubmissionLimit: null,
|
||||
monthlySubmissionLimit: null,
|
||||
showBlacklistKeywords: false,
|
||||
hideStudentInfo: true
|
||||
})
|
||||
|
||||
const originalData = ref({})
|
||||
|
||||
// 当前限额类型和值的快捷访问
|
||||
const currentLimitType = computed(() => {
|
||||
if (formData.value.monthlySubmissionLimit !== null) return 'monthly'
|
||||
return formData.value.weeklySubmissionLimit !== null ? 'weekly' : 'daily'
|
||||
})
|
||||
|
||||
const currentLimitValue = computed({
|
||||
get: () => {
|
||||
if (currentLimitType.value === 'monthly') return formData.value.monthlySubmissionLimit
|
||||
return currentLimitType.value === 'daily'
|
||||
? formData.value.dailySubmissionLimit
|
||||
: formData.value.weeklySubmissionLimit
|
||||
},
|
||||
set: (val) => {
|
||||
if (currentLimitType.value === 'monthly') {
|
||||
formData.value.monthlySubmissionLimit = val
|
||||
} else if (currentLimitType.value === 'daily') {
|
||||
formData.value.dailySubmissionLimit = val
|
||||
} else {
|
||||
formData.value.weeklySubmissionLimit = val
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const response = await fetch('/api/admin/system-settings', {
|
||||
credentials: 'include'
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('获取配置失败')
|
||||
|
||||
const data = await response.json()
|
||||
|
||||
formData.value = {
|
||||
siteTitle: data.siteTitle || '',
|
||||
siteLogoUrl: data.siteLogoUrl || '',
|
||||
schoolLogoHomeUrl: data.schoolLogoHomeUrl || '',
|
||||
schoolLogoPrintUrl: data.schoolLogoPrintUrl || '',
|
||||
siteDescription: data.siteDescription || '',
|
||||
submissionGuidelines: data.submissionGuidelines || defaultSubmissionGuidelines,
|
||||
icpNumber: data.icpNumber || '',
|
||||
gonganNumber: data.gonganNumber || '',
|
||||
enableCollaborativeSubmission: data.enableCollaborativeSubmission !== false,
|
||||
enableSubmissionRemarks: !!data.enableSubmissionRemarks,
|
||||
enableReplayRequests: !!data.enableReplayRequests,
|
||||
enableSubmissionLimit: !!data.enableSubmissionLimit,
|
||||
dailySubmissionLimit: data.dailySubmissionLimit ?? 5,
|
||||
weeklySubmissionLimit: data.weeklySubmissionLimit ?? null,
|
||||
monthlySubmissionLimit: data.monthlySubmissionLimit ?? null,
|
||||
showBlacklistKeywords: !!data.showBlacklistKeywords,
|
||||
hideStudentInfo: data.hideStudentInfo ?? true
|
||||
}
|
||||
|
||||
originalData.value = JSON.parse(JSON.stringify(formData.value))
|
||||
} catch (error) {
|
||||
console.error('加载配置失败:', error)
|
||||
showNotification('加载配置失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
saving.value = true
|
||||
const configToSave = {
|
||||
...formData.value,
|
||||
siteTitle: (formData.value.siteTitle || '').trim() || '校园广播站点歌系统',
|
||||
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/favicon.ico',
|
||||
submissionGuidelines:
|
||||
(formData.value.submissionGuidelines || '').trim() || defaultSubmissionGuidelines,
|
||||
// 确保根据限额类型处理空值
|
||||
dailySubmissionLimit:
|
||||
currentLimitType.value === 'daily' ? formData.value.dailySubmissionLimit : null,
|
||||
weeklySubmissionLimit:
|
||||
currentLimitType.value === 'weekly' ? formData.value.weeklySubmissionLimit : null,
|
||||
monthlySubmissionLimit:
|
||||
currentLimitType.value === 'monthly' ? formData.value.monthlySubmissionLimit : null
|
||||
}
|
||||
|
||||
const response = await fetch('/api/admin/system-settings', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(configToSave)
|
||||
})
|
||||
|
||||
if (!response.ok) throw new Error('保存配置失败')
|
||||
|
||||
saveSuccess.value = true
|
||||
originalData.value = JSON.parse(JSON.stringify(formData.value))
|
||||
showNotification('配置保存成功!', 'success')
|
||||
|
||||
setTimeout(() => {
|
||||
saveSuccess.value = false
|
||||
}, 3000)
|
||||
} catch (error) {
|
||||
console.error('保存配置失败:', error)
|
||||
showNotification('保存配置失败,请重试', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 处理限额类型变化
|
||||
const handleLimitTypeChange = (type) => {
|
||||
const limits = {
|
||||
daily: { key: 'dailySubmissionLimit', default: 5 },
|
||||
weekly: { key: 'weeklySubmissionLimit', default: 20 },
|
||||
monthly: { key: 'monthlySubmissionLimit', default: 50 }
|
||||
}
|
||||
|
||||
// 将非当前类型的限额设为 null
|
||||
Object.keys(limits).forEach((limitType) => {
|
||||
if (limitType !== type) {
|
||||
formData.value[limits[limitType].key] = null
|
||||
}
|
||||
})
|
||||
|
||||
// 如果当前类型的限额为 null,则设置默认值
|
||||
const targetLimit = limits[type]
|
||||
if (formData.value[targetLimit.key] === null) {
|
||||
formData.value[targetLimit.key] = targetLimit.default
|
||||
}
|
||||
}
|
||||
|
||||
// 重置表单
|
||||
const resetForm = () => {
|
||||
formData.value = JSON.parse(JSON.stringify(originalData.value))
|
||||
}
|
||||
|
||||
onMounted(loadConfig)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input::-webkit-outer-spin-button,
|
||||
input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
input[type='number'] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,377 @@
|
||||
<template>
|
||||
<div class="max-w-[1400px] mx-auto space-y-10 pb-20 px-2">
|
||||
<!-- 头部区域 -->
|
||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||
<div>
|
||||
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">邮件服务配置</h2>
|
||||
<p class="text-xs text-zinc-500 mt-1">
|
||||
配置 SMTP 服务以发送系统验证码、投稿通知及其他重要提醒
|
||||
</p>
|
||||
</div>
|
||||
<div class="flex items-center gap-3">
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="reloading"
|
||||
@click="reloadSmtpConfig"
|
||||
>
|
||||
<RotateCw :size="14" :class="reloading ? 'animate-spin' : ''" />
|
||||
{{ reloading ? '重载中...' : '重载SMTP' }}
|
||||
</button>
|
||||
<button
|
||||
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95"
|
||||
:disabled="saving"
|
||||
@click="saveConfig"
|
||||
>
|
||||
<Save :size="14" /> {{ saving ? '保存中...' : '保存配置' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-8">
|
||||
<!-- 左侧栏:SMTP 设置与测试 -->
|
||||
<div class="xl:col-span-4 space-y-8">
|
||||
<!-- SMTP 核心设置 -->
|
||||
<section class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
|
||||
<div class="flex items-center justify-between">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<Server :size="16" class="text-blue-500" /> 服务配置
|
||||
</h3>
|
||||
<button
|
||||
class="relative w-10 h-5 rounded-full transition-colors"
|
||||
:class="config.smtpEnabled ? 'bg-blue-600' : 'bg-zinc-800'"
|
||||
@click="config.smtpEnabled = !config.smtpEnabled"
|
||||
>
|
||||
<div
|
||||
class="absolute top-1 w-3 h-3 bg-white rounded-full transition-all"
|
||||
:class="config.smtpEnabled ? 'left-6' : 'left-1'"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>SMTP 服务器</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpHost"
|
||||
type="text"
|
||||
placeholder="smtp.example.com"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="grid grid-cols-2 gap-3">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>端口</label
|
||||
>
|
||||
<input
|
||||
v-model.number="config.smtpPort"
|
||||
type="number"
|
||||
placeholder="587"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>加密方式</label
|
||||
>
|
||||
<CustomSelect
|
||||
:model-value="config.smtpSecure ? 'SSL/TLS' : '无'"
|
||||
:options="['SSL/TLS', '无']"
|
||||
class="w-full"
|
||||
@update:model-value="(val) => (config.smtpSecure = val === 'SSL/TLS')"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>发件人账号</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpUsername"
|
||||
type="text"
|
||||
placeholder="your-email@example.com"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>发件人邮箱 (From Email)</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpFromEmail"
|
||||
type="text"
|
||||
:placeholder="config.smtpUsername || 'your-email@example.com'"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
<p class="text-[9px] text-zinc-500 px-1 italic">
|
||||
通常与发件人账号一致,部分服务商强制要求一致。
|
||||
</p>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>授权码 / 密码</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpPassword"
|
||||
type="password"
|
||||
placeholder="••••••••••••"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>发件人姓名</label
|
||||
>
|
||||
<input
|
||||
v-model="config.smtpFromName"
|
||||
type="text"
|
||||
placeholder="校园广播站"
|
||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SMTP 测试模块 -->
|
||||
<section class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
|
||||
<h3
|
||||
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
|
||||
>
|
||||
<Send :size="16" class="text-emerald-500" /> 服务测试
|
||||
</h3>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div class="space-y-1.5">
|
||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||
>测试接收邮箱</label
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="testEmail"
|
||||
type="email"
|
||||
placeholder="输入测试邮箱地址"
|
||||
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
||||
>
|
||||
<button
|
||||
class="flex items-center gap-2 px-4 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="testing || !testEmail || !config.smtpEnabled"
|
||||
@click="sendTestEmail"
|
||||
>
|
||||
<Check v-if="testResult?.success" :size="14" class="text-emerald-500" />
|
||||
<Send v-else :size="14" />
|
||||
{{ testing ? '发送中' : testResult?.success ? '已发送' : '发送' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="testResult">
|
||||
<div
|
||||
class="flex items-center gap-2 p-3 rounded-xl text-xs"
|
||||
:class="
|
||||
testResult.success
|
||||
? 'bg-emerald-500/10 text-emerald-500 border border-emerald-500/20'
|
||||
: 'bg-red-500/10 text-red-500 border border-red-500/20'
|
||||
"
|
||||
>
|
||||
<CheckCircle v-if="testResult.success" :size="14" />
|
||||
<XCircle v-else :size="14" />
|
||||
<div class="flex flex-col gap-0.5">
|
||||
<p class="font-bold">{{ testResult.message }}</p>
|
||||
<p v-if="testResult.detail" class="text-[10px] opacity-80 break-all font-mono">
|
||||
{{ testResult.detail }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- 右侧栏:模板编辑器 -->
|
||||
<div class="xl:col-span-8">
|
||||
<EmailTemplateManager />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import EmailTemplateManager from '~/components/Admin/EmailTemplateManager.vue'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import { Server, Save, Check, Send, CheckCircle, XCircle, RotateCw } from 'lucide-vue-next'
|
||||
|
||||
const { showToast: showNotification } = useToast()
|
||||
|
||||
// 响应式数据
|
||||
const config = ref({
|
||||
smtpEnabled: false,
|
||||
smtpHost: '',
|
||||
smtpPort: 587,
|
||||
smtpSecure: false,
|
||||
smtpUsername: '',
|
||||
smtpPassword: '',
|
||||
smtpFromEmail: '',
|
||||
smtpFromName: '校园广播站'
|
||||
})
|
||||
|
||||
const testEmail = ref('')
|
||||
const testing = ref(false)
|
||||
const saving = ref(false)
|
||||
const reloading = ref(false)
|
||||
const testResult = ref(null)
|
||||
const originalConfig = ref({})
|
||||
|
||||
// 加载配置
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const response = await $fetch('/api/admin/system-settings')
|
||||
|
||||
config.value = {
|
||||
smtpEnabled: response.smtpEnabled || false,
|
||||
smtpHost: response.smtpHost || '',
|
||||
smtpPort: response.smtpPort || 587,
|
||||
smtpSecure: response.smtpSecure || false,
|
||||
smtpUsername: response.smtpUsername || '',
|
||||
smtpPassword: response.smtpPassword || '',
|
||||
smtpFromEmail: response.smtpFromEmail || '',
|
||||
smtpFromName: response.smtpFromName || '校园广播站'
|
||||
}
|
||||
|
||||
// 保存原始配置用于重置
|
||||
originalConfig.value = { ...config.value }
|
||||
} catch (error) {
|
||||
console.error('加载SMTP配置失败:', error)
|
||||
showNotification('加载配置失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
// 保存配置
|
||||
const saveConfig = async () => {
|
||||
if (config.value.smtpEnabled) {
|
||||
// 验证必填字段
|
||||
if (!config.value.smtpHost || !config.value.smtpUsername || !config.value.smtpPassword) {
|
||||
showNotification('请填写完整的SMTP配置信息', 'error')
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
saving.value = true
|
||||
try {
|
||||
await $fetch('/api/admin/system-settings', {
|
||||
method: 'POST',
|
||||
body: config.value
|
||||
})
|
||||
|
||||
originalConfig.value = { ...config.value }
|
||||
showNotification('SMTP配置保存成功', 'success')
|
||||
} catch (error) {
|
||||
console.error('保存SMTP配置失败:', error)
|
||||
showNotification(error.data?.message || '保存配置失败', 'error')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 重置配置
|
||||
const resetConfig = () => {
|
||||
config.value = { ...originalConfig.value }
|
||||
testResult.value = null
|
||||
showNotification('配置已重置', 'info')
|
||||
}
|
||||
|
||||
const reloadSmtpConfig = async () => {
|
||||
reloading.value = true
|
||||
try {
|
||||
const response = await $fetch('/api/admin/smtp/reload', {
|
||||
method: 'POST'
|
||||
})
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || 'SMTP重载失败')
|
||||
}
|
||||
showNotification(response.message || 'SMTP配置已重载', 'success')
|
||||
} catch (error) {
|
||||
console.error('重载SMTP配置失败:', error)
|
||||
showNotification(error.data?.message || error.message || '重载SMTP配置失败', 'error')
|
||||
} finally {
|
||||
reloading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 测试连接
|
||||
const testConnection = async () => {
|
||||
if (!config.value.smtpEnabled || !config.value.smtpHost) {
|
||||
showNotification('请先配置SMTP服务器信息', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/admin/smtp/test-connection', {
|
||||
method: 'POST',
|
||||
body: config.value
|
||||
})
|
||||
|
||||
testResult.value = response
|
||||
} catch (error) {
|
||||
console.error('测试连接失败:', error)
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: error.data?.message || '测试连接失败'
|
||||
}
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 发送测试邮件
|
||||
const sendTestEmail = async () => {
|
||||
if (!testEmail.value) {
|
||||
showNotification('请输入测试邮箱地址', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
testing.value = true
|
||||
testResult.value = null
|
||||
|
||||
try {
|
||||
const response = await $fetch('/api/admin/smtp/test-email', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
...config.value,
|
||||
testEmail: testEmail.value
|
||||
}
|
||||
})
|
||||
|
||||
testResult.value = response
|
||||
if (response.success) {
|
||||
showNotification('测试邮件发送成功', 'success')
|
||||
setTimeout(() => {
|
||||
testResult.value = null
|
||||
}, 5000)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('发送测试邮件失败:', error)
|
||||
testResult.value = {
|
||||
success: false,
|
||||
message: error.data?.message || '发送测试邮件失败'
|
||||
}
|
||||
} finally {
|
||||
testing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 生命周期
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped></style>
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,77 @@
|
||||
<template>
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
|
||||
@click="close"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
|
||||
<h3 class="text-xl font-black text-zinc-100">投稿备注留言</h3>
|
||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="close">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
<div class="p-8 space-y-4">
|
||||
<div class="flex items-center gap-3">
|
||||
<p class="text-xs text-zinc-500 font-medium">{{ songTitle }}</p>
|
||||
<span
|
||||
:class="[
|
||||
'px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border',
|
||||
isPublic
|
||||
? 'bg-blue-500/10 text-blue-400 border-blue-500/20'
|
||||
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
]"
|
||||
>
|
||||
{{ isPublic ? '公开备注' : '仅管理员可见' }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="bg-zinc-950/50 border border-zinc-800/50 rounded-xl p-4">
|
||||
<p class="text-sm text-zinc-200 leading-relaxed whitespace-pre-wrap">
|
||||
{{ content }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { X } from 'lucide-vue-next'
|
||||
|
||||
defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
songTitle: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
content: {
|
||||
type: String,
|
||||
default: ''
|
||||
},
|
||||
isPublic: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
const close = () => {
|
||||
emit('close')
|
||||
}
|
||||
</script>
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,483 @@
|
||||
<template>
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-200"
|
||||
enter-from-class="opacity-0"
|
||||
enter-to-class="opacity-100"
|
||||
leave-active-class="transition ease-in duration-150"
|
||||
leave-from-class="opacity-100"
|
||||
leave-to-class="opacity-0"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[100] flex items-center justify-center p-4 sm:p-6"
|
||||
@click="handleOverlayClick"
|
||||
>
|
||||
<!-- 遮罩层 -->
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<!-- 模态框面板 -->
|
||||
<transition
|
||||
enter-active-class="transition ease-out duration-300"
|
||||
enter-from-class="opacity-0 scale-95 translate-y-4"
|
||||
enter-to-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-active-class="transition ease-in duration-200"
|
||||
leave-from-class="opacity-100 scale-100 translate-y-0"
|
||||
leave-to-class="opacity-0 scale-95 translate-y-4"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="relative w-full max-w-4xl max-h-[85vh] flex flex-col bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div
|
||||
class="flex items-center justify-between px-6 py-4 border-b border-zinc-800 bg-zinc-900/50 backdrop-blur-sm"
|
||||
>
|
||||
<h3 class="text-lg font-semibold text-white">用户歌曲信息</h3>
|
||||
<button
|
||||
class="p-2 text-zinc-400 hover:text-white hover:bg-zinc-800 rounded-lg transition-colors"
|
||||
@click="$emit('close')"
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="20"
|
||||
height="20"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
>
|
||||
<path d="M18 6 6 18" />
|
||||
<path d="m6 6 18 12" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主体内容 -->
|
||||
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-zinc-900">
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center">
|
||||
<LoadingState message="加载数据中..." spinner-type="circle" />
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="flex-1 flex flex-col items-center justify-center text-zinc-400 gap-4"
|
||||
>
|
||||
<div class="text-4xl">⚠️</div>
|
||||
<p>{{ error }}</p>
|
||||
<button
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors text-sm font-medium"
|
||||
@click="retryFetch"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 内容 -->
|
||||
<div v-else-if="userSongs" class="flex-1 flex flex-col min-h-0">
|
||||
<!-- 用户信息头部 -->
|
||||
<div class="px-6 py-4 bg-zinc-900 border-b border-zinc-800">
|
||||
<div class="flex flex-col gap-1">
|
||||
<div class="flex items-center gap-3">
|
||||
<h4 class="text-base font-medium text-white">{{ userSongs.user.name }}</h4>
|
||||
<span class="text-sm text-zinc-500 font-mono">{{
|
||||
userSongs.user.username
|
||||
}}</span>
|
||||
</div>
|
||||
<p
|
||||
v-if="userSongs.user.grade || userSongs.user.class"
|
||||
class="text-sm text-zinc-400"
|
||||
>
|
||||
{{ userSongs.user.grade || '' }} {{ userSongs.user.class || '' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 工具栏 (标签页 + 过滤) -->
|
||||
<div
|
||||
class="flex flex-col sm:flex-row items-center justify-between border-b border-zinc-800 bg-zinc-900/50"
|
||||
>
|
||||
<!-- 标签页 -->
|
||||
<div class="flex w-full sm:w-auto overflow-x-auto no-scrollbar">
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.id"
|
||||
class="relative px-6 py-3 text-sm font-medium transition-colors whitespace-nowrap flex-shrink-0"
|
||||
:class="
|
||||
activeTab === tab.id
|
||||
? 'text-blue-500'
|
||||
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
|
||||
"
|
||||
@click="activeTab = tab.id"
|
||||
>
|
||||
{{ tab.label }}
|
||||
<span
|
||||
class="ml-1.5 text-xs px-1.5 py-0.5 rounded-full"
|
||||
:class="
|
||||
activeTab === tab.id
|
||||
? 'bg-blue-500/10 text-blue-500'
|
||||
: 'bg-zinc-800 text-zinc-500'
|
||||
"
|
||||
>
|
||||
{{ getTabCount(tab.id) }}
|
||||
</span>
|
||||
<!-- 激活指示器 -->
|
||||
<div
|
||||
v-if="activeTab === tab.id"
|
||||
class="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500"
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 学期过滤器 -->
|
||||
<div
|
||||
class="w-full sm:w-auto px-4 py-2 border-t sm:border-t-0 sm:border-l border-zinc-800 flex items-center gap-3 bg-zinc-900/30"
|
||||
>
|
||||
<CustomSelect
|
||||
v-model="selectedSemester"
|
||||
:options="[{ label: '全部学期', value: null }, ...semesterOptions]"
|
||||
label="学期过滤"
|
||||
placeholder="全部学期"
|
||||
class-name="w-full sm:w-48"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 歌曲列表 -->
|
||||
<div class="flex-1 overflow-y-auto p-4 sm:p-6 space-y-3 custom-scrollbar">
|
||||
<div
|
||||
v-if="filteredSongs.length === 0"
|
||||
class="flex flex-col items-center justify-center py-20 text-zinc-500"
|
||||
>
|
||||
<div class="text-5xl mb-4 opacity-50">{{ activeTabIcon }}</div>
|
||||
<p>没有找到相关记录</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="song in filteredSongs"
|
||||
:key="song.id"
|
||||
class="group flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-lg border border-zinc-800 bg-zinc-800/20 hover:bg-zinc-800/50 hover:border-zinc-700 transition-all duration-200"
|
||||
>
|
||||
<!-- 歌曲信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="flex items-center gap-2 mb-1">
|
||||
<h5 class="text-sm font-medium text-white truncate">{{ song.title }}</h5>
|
||||
<span
|
||||
v-if="song.semester"
|
||||
class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-zinc-800 text-zinc-400 border border-zinc-700"
|
||||
>
|
||||
{{ song.semester }}
|
||||
</span>
|
||||
</div>
|
||||
<div class="text-sm text-zinc-400 truncate">{{ song.artist }}</div>
|
||||
|
||||
<!-- Meta Info (Mobile) -->
|
||||
<div
|
||||
class="flex flex-wrap items-center gap-3 mt-3 sm:hidden text-xs text-zinc-500"
|
||||
>
|
||||
<span>{{ getMetaTime(song) }}</span>
|
||||
<span v-if="song.voteCount !== undefined">{{ song.voteCount }} 票</span>
|
||||
<span v-if="song.requestCount !== undefined"
|
||||
>{{ song.requestCount }} 人申请</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态与元数据 (桌面端) -->
|
||||
<div class="flex items-center gap-4 sm:gap-6">
|
||||
<!-- 状态标签 -->
|
||||
<div class="flex-shrink-0">
|
||||
<span
|
||||
class="px-2.5 py-1 rounded-full text-xs font-medium border"
|
||||
:class="getStatusClasses(song)"
|
||||
>
|
||||
{{ getStatusText(song) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Meta Info (Desktop) -->
|
||||
<div class="hidden sm:flex flex-col items-end gap-0.5 min-w-[100px]">
|
||||
<span class="text-xs text-zinc-400">{{ getMetaTime(song) }}</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span v-if="song.voteCount !== undefined" class="text-xs text-zinc-500"
|
||||
>{{ song.voteCount }} 票</span
|
||||
>
|
||||
<span v-if="song.requestCount !== undefined" class="text-xs text-zinc-500"
|
||||
>{{ song.requestCount }} 人申请</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submitter Info (if available) -->
|
||||
<div v-if="song.requester" class="hidden sm:block text-right min-w-[80px]">
|
||||
<div class="text-xs text-zinc-300">{{ song.requester.name }}</div>
|
||||
<div class="text-[10px] text-zinc-500">
|
||||
{{ song.requester.grade }}{{ song.requester.class }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 投稿人信息 (移动端) -->
|
||||
<div
|
||||
v-if="song.requester"
|
||||
class="sm:hidden pt-3 mt-1 border-t border-zinc-800/50 flex justify-between items-center text-xs"
|
||||
>
|
||||
<span class="text-zinc-500">投稿人:</span>
|
||||
<span class="text-zinc-300">
|
||||
{{ song.requester.name }}
|
||||
<span class="text-zinc-500 ml-1">
|
||||
{{ song.requester.grade }}{{ song.requester.class }}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</transition>
|
||||
</div>
|
||||
</transition>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
import { useSemesters } from '~/composables/useSemesters'
|
||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||
import LoadingState from '~/components/UI/Common/LoadingState.vue'
|
||||
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
userId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// Data
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const userSongs = ref(null)
|
||||
const activeTab = ref('submitted')
|
||||
const selectedSemester = ref(null)
|
||||
|
||||
// Composables
|
||||
const { semesters, fetchSemesterOptions } = useSemesters()
|
||||
|
||||
// Computed
|
||||
const semesterOptions = computed(() => {
|
||||
return semesters.value.map((s) => ({
|
||||
label: s.name,
|
||||
value: s.name
|
||||
}))
|
||||
})
|
||||
|
||||
const tabs = [
|
||||
{ id: 'submitted', label: '投稿歌曲' },
|
||||
{ id: 'voted', label: '投票歌曲' },
|
||||
{ id: 'replay', label: '重播申请' }
|
||||
]
|
||||
|
||||
const activeTabIcon = computed(() => {
|
||||
switch (activeTab.value) {
|
||||
case 'submitted':
|
||||
return '🎵'
|
||||
case 'voted':
|
||||
return '❤️'
|
||||
case 'replay':
|
||||
return '🔁'
|
||||
default:
|
||||
return '📄'
|
||||
}
|
||||
})
|
||||
|
||||
const currentSongs = computed(() => {
|
||||
if (!userSongs.value) return []
|
||||
switch (activeTab.value) {
|
||||
case 'submitted':
|
||||
return userSongs.value.submittedSongs || []
|
||||
case 'voted':
|
||||
return userSongs.value.votedSongs || []
|
||||
case 'replay':
|
||||
return userSongs.value.replayRequestedSongs || []
|
||||
default:
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
const filteredSongs = computed(() => {
|
||||
let songs = currentSongs.value
|
||||
|
||||
if (selectedSemester.value) {
|
||||
songs = songs.filter((song) => song.semester === selectedSemester.value)
|
||||
}
|
||||
|
||||
return songs
|
||||
})
|
||||
|
||||
// Methods
|
||||
const getTabCount = (tabId) => {
|
||||
if (!userSongs.value) return 0
|
||||
|
||||
let songs = []
|
||||
switch (tabId) {
|
||||
case 'submitted':
|
||||
songs = userSongs.value.submittedSongs || []
|
||||
break
|
||||
case 'voted':
|
||||
songs = userSongs.value.votedSongs || []
|
||||
break
|
||||
case 'replay':
|
||||
songs = userSongs.value.replayRequestedSongs || []
|
||||
break
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
|
||||
if (selectedSemester.value) {
|
||||
songs = songs.filter((song) => song.semester === selectedSemester.value)
|
||||
}
|
||||
|
||||
return songs.length
|
||||
}
|
||||
|
||||
const fetchUserSongs = async () => {
|
||||
if (!props.userId) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const auth = useAuth()
|
||||
|
||||
// 并行获取歌曲数据和学期列表
|
||||
const promises = [$fetch(`/api/admin/users/${props.userId}/songs`, { ...auth.getAuthConfig() })]
|
||||
|
||||
// 如果学期列表为空,尝试获取
|
||||
if (semesters.value.length === 0) {
|
||||
promises.push(fetchSemesterOptions())
|
||||
}
|
||||
|
||||
const [songsResponse] = await Promise.all(promises)
|
||||
userSongs.value = songsResponse
|
||||
} catch (err) {
|
||||
console.error('获取用户歌曲信息失败:', err)
|
||||
error.value = err.data?.message || '获取用户歌曲信息失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const retryFetch = () => {
|
||||
fetchUserSongs()
|
||||
}
|
||||
|
||||
const handleOverlayClick = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
// Helpers
|
||||
const formatDate = (dateString) => {
|
||||
if (!dateString) return ''
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
if (diff < 86400000 * 7) return `${Math.floor(diff / 86400000)}天前`
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric'
|
||||
})
|
||||
}
|
||||
|
||||
const getMetaTime = (song) => {
|
||||
if (activeTab.value === 'submitted') return formatDate(song.createdAt)
|
||||
if (activeTab.value === 'voted') return `${formatDate(song.votedAt)} 投票`
|
||||
if (activeTab.value === 'replay') return `${formatDate(song.requestedAt)} 申请`
|
||||
return ''
|
||||
}
|
||||
|
||||
const getStatusText = (song) => {
|
||||
if (song.played) return '已播放'
|
||||
if (song.scheduled) return '已排期'
|
||||
return '待排期'
|
||||
}
|
||||
|
||||
const getStatusClasses = (song) => {
|
||||
if (song.played) {
|
||||
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||
}
|
||||
if (song.scheduled) {
|
||||
return 'bg-blue-500/10 text-blue-500 border-blue-500/20'
|
||||
}
|
||||
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||
}
|
||||
|
||||
// 监听器
|
||||
watch(
|
||||
() => props.userId,
|
||||
(newUserId) => {
|
||||
if (newUserId && props.show) {
|
||||
fetchUserSongs()
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newShow) => {
|
||||
if (newShow && props.userId) {
|
||||
fetchUserSongs()
|
||||
} else if (!newShow) {
|
||||
// 关闭时重置状态
|
||||
userSongs.value = null
|
||||
error.value = ''
|
||||
activeTab.value = 'submitted'
|
||||
selectedSemester.value = null // 可选:关闭时重置过滤器
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* 列表的自定义滚动条 */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background-color: rgba(113, 113, 122, 0.3); /* zinc-500/30 */
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background-color: rgba(113, 113, 122, 0.5);
|
||||
}
|
||||
|
||||
/* 标签页隐藏滚动条 */
|
||||
.no-scrollbar::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
.no-scrollbar {
|
||||
-ms-overflow-style: none;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,533 @@
|
||||
<template>
|
||||
<div v-if="show" class="modal-overlay" @click="closeModal">
|
||||
<div class="modal-content" @click.stop>
|
||||
<div class="modal-header">
|
||||
<h3 class="modal-title">投票人员列表</h3>
|
||||
<button class="close-btn" @click="closeModal">
|
||||
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<line x1="18" x2="6" y1="6" y2="18" />
|
||||
<line x1="6" x2="18" y1="6" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="modal-body">
|
||||
<!-- 歌曲信息 -->
|
||||
<div v-if="songInfo" class="song-info">
|
||||
<h4 class="song-title">{{ songInfo.title }}</h4>
|
||||
<p class="song-artist">{{ songInfo.artist }}</p>
|
||||
<div class="vote-summary">
|
||||
<svg class="heart-icon" fill="currentColor" viewBox="0 0 24 24">
|
||||
<path
|
||||
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
|
||||
/>
|
||||
</svg>
|
||||
<span class="vote-count">{{ totalVotes }} 票</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 加载状态 -->
|
||||
<div v-if="loading" class="loading-container">
|
||||
<div class="spinner" />
|
||||
<p>正在加载投票人员...</p>
|
||||
</div>
|
||||
|
||||
<!-- 错误状态 -->
|
||||
<div v-else-if="error" class="error-container">
|
||||
<svg
|
||||
class="error-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="15" x2="9" y1="9" y2="15" />
|
||||
<line x1="9" x2="15" y1="9" y2="15" />
|
||||
</svg>
|
||||
<p class="error-message">{{ error }}</p>
|
||||
<button class="retry-btn" @click="fetchVoters">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- 投票人员列表 -->
|
||||
<div v-else-if="voters.length > 0" class="voters-list">
|
||||
<div class="voters-header">
|
||||
<span class="voters-title">投票人员 ({{ voters.length }})</span>
|
||||
</div>
|
||||
<div class="voters-container">
|
||||
<div v-for="(voter, index) in voters" :key="voter.id" class="voter-item">
|
||||
<div class="voter-info">
|
||||
<div class="voter-avatar">
|
||||
{{ getAvatarText(voter.name) }}
|
||||
</div>
|
||||
<div class="voter-details">
|
||||
<span class="voter-name">{{ voter.name }}</span>
|
||||
<span class="vote-time">{{ formatVoteTime(voter.votedAt) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="voter-number">#{{ index + 1 }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 无投票状态 -->
|
||||
<div v-else class="empty-state">
|
||||
<svg
|
||||
class="empty-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z"
|
||||
/>
|
||||
</svg>
|
||||
<p>暂无投票</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="modal-footer">
|
||||
<button class="close-button" @click="closeModal">关闭</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
// Props
|
||||
const props = defineProps({
|
||||
show: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
songId: {
|
||||
type: Number,
|
||||
default: null
|
||||
}
|
||||
})
|
||||
|
||||
// Emits
|
||||
const emit = defineEmits(['close'])
|
||||
|
||||
// 响应式数据
|
||||
const loading = ref(false)
|
||||
const error = ref('')
|
||||
const songInfo = ref(null)
|
||||
const voters = ref([])
|
||||
const totalVotes = ref(0)
|
||||
|
||||
// 方法
|
||||
const closeModal = () => {
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const fetchVoters = async () => {
|
||||
if (!props.songId) return
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const response = await $fetch(`/api/songs/${props.songId}/voters`)
|
||||
|
||||
songInfo.value = response.song
|
||||
voters.value = response.voters || []
|
||||
totalVotes.value = response.totalVotes || 0
|
||||
} catch (err) {
|
||||
console.error('获取投票人员失败:', err)
|
||||
error.value = err.data?.message || '获取投票人员失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const getAvatarText = (name) => {
|
||||
if (!name) return '?'
|
||||
// 提取中文姓名的最后一个字符,或英文名的首字母
|
||||
const cleanName = name.replace(/[((].*[))]/, '').trim()
|
||||
return cleanName.slice(-1).toUpperCase()
|
||||
}
|
||||
|
||||
const formatVoteTime = (dateString) => {
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diff = now - date
|
||||
|
||||
if (diff < 60000) return '刚刚'
|
||||
if (diff < 3600000) return `${Math.floor(diff / 60000)}分钟前`
|
||||
if (diff < 86400000) return `${Math.floor(diff / 3600000)}小时前`
|
||||
if (diff < 604800000) return `${Math.floor(diff / 86400000)}天前`
|
||||
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
// 监听弹窗显示状态
|
||||
watch(
|
||||
() => props.show,
|
||||
(newShow) => {
|
||||
if (newShow && props.songId) {
|
||||
fetchVoters()
|
||||
} else {
|
||||
// 重置数据
|
||||
songInfo.value = null
|
||||
voters.value = []
|
||||
totalVotes.value = 0
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: #1a1a1a;
|
||||
border-radius: 16px;
|
||||
border: 1px solid #2a2a2a;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 24px 24px 0;
|
||||
}
|
||||
|
||||
.modal-title {
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
background: #2a2a2a;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
.close-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.song-info {
|
||||
background: #2a2a2a;
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 24px;
|
||||
border: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.song-title {
|
||||
font-size: 18px;
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.song-artist {
|
||||
color: #888;
|
||||
margin: 0 0 16px 0;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vote-summary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.heart-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.vote-count {
|
||||
color: #ef4444;
|
||||
font-weight: 600;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: 3px solid #3a3a3a;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #ef4444;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: #ef4444;
|
||||
text-align: center;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.retry-btn {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 8px 16px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-btn:hover {
|
||||
background: #5a67d8;
|
||||
}
|
||||
|
||||
.voters-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.voters-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding-bottom: 12px;
|
||||
border-bottom: 1px solid #3a3a3a;
|
||||
}
|
||||
|
||||
.voters-title {
|
||||
font-weight: 600;
|
||||
color: #ffffff;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.voters-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 300px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.voter-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
background: #2a2a2a;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #3a3a3a;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.voter-item:hover {
|
||||
background: #333333;
|
||||
border-color: #4a4a4a;
|
||||
}
|
||||
|
||||
.voter-info {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.voter-avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #667eea, #764ba2);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.voter-details {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.voter-name {
|
||||
color: #ffffff;
|
||||
font-weight: 500;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.vote-time {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.voter-number {
|
||||
color: #888;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 40px 20px;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.empty-icon {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
color: #888;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.modal-footer {
|
||||
padding: 0 24px 24px;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: #3a3a3a;
|
||||
color: #ffffff;
|
||||
border: none;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: background 0.2s ease;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
background: #4a4a4a;
|
||||
}
|
||||
|
||||
/* 滚动条样式 */
|
||||
.voters-container::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.voters-container::-webkit-scrollbar-track {
|
||||
background: #2a2a2a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.voters-container::-webkit-scrollbar-thumb {
|
||||
background: #4a4a4a;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.voters-container::-webkit-scrollbar-thumb:hover {
|
||||
background: #5a5a5a;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 768px) {
|
||||
.modal-overlay {
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
max-height: 90vh;
|
||||
}
|
||||
|
||||
.modal-header,
|
||||
.modal-body,
|
||||
.modal-footer {
|
||||
padding-left: 16px;
|
||||
padding-right: 16px;
|
||||
}
|
||||
|
||||
.voter-item {
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.voter-avatar {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,363 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<form class="space-y-5" @submit.prevent="handleChangePassword">
|
||||
<div v-if="!isFirstLogin" class="space-y-2">
|
||||
<label
|
||||
for="current-password"
|
||||
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>当前密码</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<Lock :size="18" />
|
||||
</div>
|
||||
<input
|
||||
id="current-password"
|
||||
v-model="currentPassword"
|
||||
:class="[
|
||||
inputClass,
|
||||
error
|
||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
||||
: 'border-zinc-800 focus:border-blue-500/30'
|
||||
]"
|
||||
:type="showCurrentPassword ? 'text' : 'password'"
|
||||
placeholder="请输入当前密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
@click="showCurrentPassword = !showCurrentPassword"
|
||||
>
|
||||
<Eye v-if="!showCurrentPassword" :size="18" />
|
||||
<EyeOff v-else :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="new-password"
|
||||
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>新密码</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<KeyRound :size="18" />
|
||||
</div>
|
||||
<input
|
||||
id="new-password"
|
||||
v-model="newPassword"
|
||||
:class="[
|
||||
inputClass,
|
||||
error
|
||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
||||
: 'border-zinc-800 focus:border-blue-500/30'
|
||||
]"
|
||||
:type="showNewPassword ? 'text' : 'password'"
|
||||
placeholder="请输入新密码"
|
||||
required
|
||||
@input="
|
||||
error = '';
|
||||
validatePassword()
|
||||
"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
@click="showNewPassword = !showNewPassword"
|
||||
>
|
||||
<Eye v-if="!showNewPassword" :size="18" />
|
||||
<EyeOff v-else :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 密码强度指示器 -->
|
||||
<div v-if="newPassword" class="px-1 pt-1 space-y-2">
|
||||
<div class="h-1 w-full bg-zinc-800 rounded-full overflow-hidden">
|
||||
<div
|
||||
class="h-full transition-all duration-500"
|
||||
:class="passwordStrength.colorClass"
|
||||
:style="{ width: passwordStrength.width }"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex justify-between items-center">
|
||||
<span class="text-[10px] font-black uppercase tracking-widest text-zinc-500"
|
||||
>密码强度</span
|
||||
>
|
||||
<span
|
||||
class="text-[10px] font-black uppercase tracking-widest"
|
||||
:class="passwordStrength.textColorClass"
|
||||
>
|
||||
{{ passwordStrength.text }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="space-y-2">
|
||||
<label
|
||||
for="confirm-password"
|
||||
class="text-xs font-black text-zinc-500 uppercase tracking-widest ml-1"
|
||||
>确认新密码</label
|
||||
>
|
||||
<div class="relative group">
|
||||
<div
|
||||
class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-500 group-focus-within:text-blue-500 transition-colors"
|
||||
>
|
||||
<CheckCircle2 :size="18" />
|
||||
</div>
|
||||
<input
|
||||
id="confirm-password"
|
||||
v-model="confirmPassword"
|
||||
:class="[
|
||||
inputClass,
|
||||
error || (confirmPassword && newPassword !== confirmPassword)
|
||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
||||
: 'border-zinc-800 focus:border-blue-500/30'
|
||||
]"
|
||||
:type="showConfirmPassword ? 'text' : 'password'"
|
||||
placeholder="请再次输入新密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
>
|
||||
<button
|
||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-500 hover:text-zinc-300 transition-colors"
|
||||
type="button"
|
||||
@click="showConfirmPassword = !showConfirmPassword"
|
||||
>
|
||||
<Eye v-if="!showConfirmPassword" :size="18" />
|
||||
<EyeOff v-else :size="18" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 密码匹配提示 -->
|
||||
<div v-if="confirmPassword" class="px-1">
|
||||
<div
|
||||
v-if="newPassword !== confirmPassword"
|
||||
class="flex items-center gap-1.5 text-rose-500"
|
||||
>
|
||||
<XCircle :size="12" />
|
||||
<span class="text-[10px] font-bold">密码不匹配</span>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-1.5 text-emerald-500">
|
||||
<CheckCircle2 :size="12" />
|
||||
<span class="text-[10px] font-bold">密码匹配</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 状态消息 -->
|
||||
<div
|
||||
v-if="error"
|
||||
class="p-3 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-center gap-3"
|
||||
>
|
||||
<AlertCircle :size="16" class="text-rose-500 shrink-0" />
|
||||
<span class="text-xs text-rose-500 font-medium">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="success"
|
||||
class="p-3 bg-emerald-500/10 border border-emerald-500/20 rounded-xl flex items-center gap-3"
|
||||
>
|
||||
<CheckCircle2 :size="16" class="text-emerald-500 shrink-0" />
|
||||
<span class="text-xs text-emerald-500 font-medium">{{ success }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
:disabled="loading || !isFormValid"
|
||||
class="w-full flex items-center justify-center gap-2 py-3 bg-blue-600 hover:bg-blue-500 text-white text-sm font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-[0.98] disabled:opacity-50"
|
||||
type="submit"
|
||||
>
|
||||
<Loader2 v-if="loading" :size="18" class="animate-spin" />
|
||||
<span>{{ loading ? '处理中...' : isFirstLogin ? '设置初始密码' : '确认修改密码' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue'
|
||||
import {
|
||||
Lock,
|
||||
KeyRound,
|
||||
Eye,
|
||||
EyeOff,
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertCircle,
|
||||
Loader2
|
||||
} from 'lucide-vue-next'
|
||||
|
||||
// 组件属性
|
||||
const props = defineProps({
|
||||
isFirstLogin: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
const auth = useAuth()
|
||||
const router = useRouter()
|
||||
|
||||
const currentPassword = ref('')
|
||||
const newPassword = ref('')
|
||||
const confirmPassword = ref('')
|
||||
const error = ref('')
|
||||
const success = ref('')
|
||||
const loading = ref(false)
|
||||
|
||||
// 样式类
|
||||
const inputClass =
|
||||
'w-full bg-zinc-950 border rounded-xl pl-11 pr-11 py-3 text-sm text-zinc-200 placeholder:text-zinc-600 focus:outline-none transition-all'
|
||||
|
||||
// 密码显示状态
|
||||
const showCurrentPassword = ref(false)
|
||||
const showNewPassword = ref(false)
|
||||
const showConfirmPassword = ref(false)
|
||||
|
||||
// 密码强度计算
|
||||
const passwordStrength = computed(() => {
|
||||
const password = newPassword.value
|
||||
if (!password) return { width: '0%', colorClass: '', textColorClass: '', text: '' }
|
||||
|
||||
let score = 0
|
||||
|
||||
if (password.length >= 8) score += 25
|
||||
if (/[A-Z]/.test(password)) score += 25
|
||||
if (/[a-z]/.test(password)) score += 25
|
||||
if (/[0-9]/.test(password) && /[^A-Za-z0-9]/.test(password)) score += 25
|
||||
|
||||
if (score < 50) {
|
||||
return {
|
||||
width: `${score || 10}%`,
|
||||
colorClass: 'bg-rose-500',
|
||||
textColorClass: 'text-rose-500',
|
||||
text: '弱'
|
||||
}
|
||||
} else if (score < 75) {
|
||||
return {
|
||||
width: `${score}%`,
|
||||
colorClass: 'bg-amber-500',
|
||||
textColorClass: 'text-amber-500',
|
||||
text: '中等'
|
||||
}
|
||||
} else if (score < 100) {
|
||||
return {
|
||||
width: `${score}%`,
|
||||
colorClass: 'bg-blue-500',
|
||||
textColorClass: 'text-blue-500',
|
||||
text: '强'
|
||||
}
|
||||
} else {
|
||||
return {
|
||||
width: '100%',
|
||||
colorClass: 'bg-emerald-500',
|
||||
textColorClass: 'text-emerald-500',
|
||||
text: '极强'
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
// 表单验证
|
||||
const isFormValid = computed(() => {
|
||||
if (props.isFirstLogin) {
|
||||
return (
|
||||
newPassword.value &&
|
||||
confirmPassword.value &&
|
||||
newPassword.value === confirmPassword.value &&
|
||||
newPassword.value.length >= 8
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
currentPassword.value &&
|
||||
newPassword.value &&
|
||||
confirmPassword.value &&
|
||||
newPassword.value === confirmPassword.value &&
|
||||
newPassword.value.length >= 8
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
const validatePassword = () => {
|
||||
if (newPassword.value && newPassword.value.length < 8) {
|
||||
error.value = '密码长度至少为8位'
|
||||
} else {
|
||||
error.value = ''
|
||||
}
|
||||
}
|
||||
|
||||
const handleChangePassword = async () => {
|
||||
if (newPassword.value !== confirmPassword.value) {
|
||||
error.value = '新密码和确认密码不匹配'
|
||||
return
|
||||
}
|
||||
|
||||
if (newPassword.value.length < 8) {
|
||||
error.value = '新密码长度至少为8位'
|
||||
return
|
||||
}
|
||||
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
success.value = ''
|
||||
|
||||
try {
|
||||
if (props.isFirstLogin) {
|
||||
await auth.setInitialPassword(newPassword.value)
|
||||
success.value = '密码设置成功!正在跳转...'
|
||||
|
||||
// 清空表单
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
|
||||
// 密码设置完成后跳转
|
||||
setTimeout(async () => {
|
||||
// 更新用户状态
|
||||
await auth.refreshUser()
|
||||
|
||||
if (auth.isAdmin.value) {
|
||||
router.push('/dashboard')
|
||||
} else {
|
||||
router.push('/')
|
||||
}
|
||||
}, 2000)
|
||||
} else {
|
||||
await auth.changePassword(currentPassword.value, newPassword.value)
|
||||
success.value = '密码修改成功!请重新登录'
|
||||
|
||||
// 清空表单
|
||||
currentPassword.value = ''
|
||||
newPassword.value = ''
|
||||
confirmPassword.value = ''
|
||||
|
||||
// 密码修改后登出
|
||||
setTimeout(() => {
|
||||
auth.logout()
|
||||
router.push('/login')
|
||||
}, 2000)
|
||||
}
|
||||
} catch (err) {
|
||||
// 提取错误信息,支持多种错误格式
|
||||
if (err.data && err.data.statusMessage) {
|
||||
error.value = err.data.statusMessage
|
||||
} else if (err.data && err.data.message) {
|
||||
error.value = err.data.message
|
||||
} else if (err.statusMessage) {
|
||||
error.value = err.statusMessage
|
||||
} else if (err.message) {
|
||||
error.value = err.message
|
||||
} else {
|
||||
error.value = '操作失败,请重试'
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,651 @@
|
||||
<template>
|
||||
<div class="login-form">
|
||||
<div class="form-header">
|
||||
<h2>{{ isBindMode ? '绑定账号' : '欢迎回来' }}</h2>
|
||||
<p v-if="isBindMode">即将绑定 {{ providerName }} 账号: {{ providerUsername }}</p>
|
||||
<p v-else>登录您的VoiceHub账户</p>
|
||||
</div>
|
||||
|
||||
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleLogin">
|
||||
<div class="form-group">
|
||||
<label for="username">账号名</label>
|
||||
<div class="input-wrapper">
|
||||
<svg
|
||||
class="input-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
<circle cx="12" cy="7" r="4" />
|
||||
</svg>
|
||||
<input
|
||||
id="username"
|
||||
v-model="username"
|
||||
:class="{ 'input-error': error }"
|
||||
placeholder="请输入账号名"
|
||||
required
|
||||
type="text"
|
||||
@input="error = ''"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="password">密码</label>
|
||||
<div class="input-wrapper">
|
||||
<svg
|
||||
class="input-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<rect height="11" rx="2" ry="2" width="18" x="3" y="11" />
|
||||
<circle cx="12" cy="16" r="1" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
<input
|
||||
id="password"
|
||||
v-model="password"
|
||||
:class="{ 'input-error': error }"
|
||||
:type="showPassword ? 'text' : 'password'"
|
||||
placeholder="请输入密码"
|
||||
required
|
||||
@input="error = ''"
|
||||
>
|
||||
<button class="password-toggle" type="button" @click="showPassword = !showPassword">
|
||||
<svg
|
||||
v-if="showPassword"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"
|
||||
/>
|
||||
<line x1="1" x2="23" y1="1" y2="23" />
|
||||
</svg>
|
||||
<svg v-else fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-container">
|
||||
<svg
|
||||
class="error-icon"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="12" x2="12" y1="8" y2="12" />
|
||||
<line x1="12" x2="12.01" y1="16" y2="16" />
|
||||
</svg>
|
||||
<span class="error-message">{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<button :disabled="loading" class="submit-btn" type="submit">
|
||||
<svg v-if="loading" class="loading-spinner" viewBox="0 0 24 24">
|
||||
<circle
|
||||
cx="12"
|
||||
cy="12"
|
||||
fill="none"
|
||||
r="10"
|
||||
stroke="currentColor"
|
||||
stroke-dasharray="31.416"
|
||||
stroke-dashoffset="31.416"
|
||||
stroke-linecap="round"
|
||||
stroke-width="2"
|
||||
>
|
||||
<animate
|
||||
attributeName="stroke-dasharray"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
values="0 31.416;15.708 15.708;0 31.416"
|
||||
/>
|
||||
<animate
|
||||
attributeName="stroke-dashoffset"
|
||||
dur="2s"
|
||||
repeatCount="indefinite"
|
||||
values="0;-15.708;-31.416"
|
||||
/>
|
||||
</circle>
|
||||
</svg>
|
||||
<span v-if="loading">{{ isBindMode ? '绑定中...' : '登录中...' }}</span>
|
||||
<span v-else>{{ isBindMode ? '绑定并登录' : '登录' }}</span>
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<div v-if="!isBindMode && isWebAuthnSupported" class="webauthn-section">
|
||||
<div class="divider">
|
||||
<span>或</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="webauthn-btn"
|
||||
:disabled="loading"
|
||||
@click="handleWebAuthnLogin"
|
||||
>
|
||||
<Fingerprint :size="20" class="webauthn-icon" />
|
||||
<span>使用 Windows Hello / Passkey 登录</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<AuthOAuthButtons v-if="!isBindMode" />
|
||||
|
||||
<div class="form-footer">
|
||||
<p class="help-text">不同VoiceHub平台的账号不互通</p>
|
||||
</div>
|
||||
|
||||
<AuthTwoFactorVerify
|
||||
:show="show2FA"
|
||||
:user-id="userId2FA"
|
||||
:available-methods="methods2FA"
|
||||
:masked-email="maskedEmail2FA"
|
||||
:temp-token="tempToken2FA"
|
||||
@success="handle2FASuccess"
|
||||
@cancel="show2FA = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useAuth } from '~/composables/useAuth'
|
||||
import { getProviderDisplayName } from '~/utils/oauth'
|
||||
import { startAuthentication, browserSupportsWebAuthn } from '@simplewebauthn/browser'
|
||||
import { Fingerprint } from 'lucide-vue-next'
|
||||
|
||||
const route = useRoute()
|
||||
const isBindMode = computed(() => route.query.action === 'bind')
|
||||
const providerUsername = computed(() => route.query.username || '')
|
||||
const providerName = computed(() => {
|
||||
const provider = (route.query.provider as string) || '第三方'
|
||||
return getProviderDisplayName(provider)
|
||||
})
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const showPassword = ref(false)
|
||||
const isWebAuthnSupported = ref(false)
|
||||
const show2FA = ref(false)
|
||||
const userId2FA = ref(0)
|
||||
const methods2FA = ref<string[]>([])
|
||||
const tempToken2FA = ref('')
|
||||
const maskedEmail2FA = ref('')
|
||||
|
||||
const auth = useAuth()
|
||||
|
||||
const handle2FASuccess = async () => {
|
||||
if (auth.isAdmin.value) {
|
||||
await navigateTo('/dashboard')
|
||||
} else {
|
||||
await navigateTo('/')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const isApiSupported = browserSupportsWebAuthn()
|
||||
let isPlatformAuthenticatorAvailable = false
|
||||
|
||||
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
|
||||
try {
|
||||
isPlatformAuthenticatorAvailable = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
} catch (e) {
|
||||
console.warn('WebAuthn 平台认证器检查失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容外部安全密钥(如 YubiKey),即使没有内置平台认证器也允许尝试
|
||||
isWebAuthnSupported.value = isApiSupported
|
||||
})
|
||||
|
||||
const handleLogin = async () => {
|
||||
if (!username.value || !password.value) {
|
||||
error.value = '请填写完整的登录信息'
|
||||
return
|
||||
}
|
||||
|
||||
error.value = ''
|
||||
loading.value = true
|
||||
|
||||
try {
|
||||
if (isBindMode.value) {
|
||||
await $fetch('/api/auth/bind', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
username: username.value,
|
||||
password: password.value
|
||||
}
|
||||
})
|
||||
|
||||
// 绑定成功后刷新认证状态
|
||||
await auth.initAuth()
|
||||
await navigateTo('/')
|
||||
} else {
|
||||
const response = await auth.login(username.value, password.value)
|
||||
|
||||
if (response.requires2FA) {
|
||||
userId2FA.value = response.userId
|
||||
methods2FA.value = response.methods
|
||||
tempToken2FA.value = response.tempToken
|
||||
maskedEmail2FA.value = response.maskedEmail || ''
|
||||
show2FA.value = true
|
||||
return
|
||||
}
|
||||
|
||||
// 登录成功后跳转
|
||||
if (auth.isAdmin.value) {
|
||||
await navigateTo('/dashboard')
|
||||
} else {
|
||||
await navigateTo('/')
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value =
|
||||
err.message || (isBindMode.value ? '绑定失败,请检查账号密码' : '登录失败,请检查账号密码')
|
||||
// 密码错误时清空密码字段
|
||||
if (error.value.includes('密码') || error.value.includes('错误')) {
|
||||
password.value = ''
|
||||
}
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebAuthnLogin = async () => {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
// 1. 获取登录选项
|
||||
const options = await $fetch('/api/auth/webauthn/login/options', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
// 2. 调用浏览器 WebAuthn API
|
||||
const credential = await startAuthentication(options)
|
||||
|
||||
// 3. 验证登录
|
||||
const verification = await $fetch('/api/auth/webauthn/login/verify', {
|
||||
method: 'POST',
|
||||
body: credential
|
||||
})
|
||||
|
||||
if (verification.success) {
|
||||
// 登录成功
|
||||
await auth.initAuth()
|
||||
await navigateTo(verification.redirect || '/')
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('WebAuthn 登录错误:', e)
|
||||
const apiError = e as { data?: { message?: string }, message?: string }
|
||||
error.value = apiError.data?.message || apiError.message || 'Passkey 登录失败'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.login-form {
|
||||
width: 100%;
|
||||
max-width: 400px;
|
||||
animation: fadeInUp 0.4s ease both;
|
||||
}
|
||||
|
||||
@keyframes fadeInUp {
|
||||
0% {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.form-header {
|
||||
text-align: center;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form-header h2 {
|
||||
font-size: 28px;
|
||||
font-weight: var(--font-bold);
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 8px 0;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
font-size: 16px;
|
||||
color: var(--text-tertiary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.auth-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 24px;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-medium);
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.input-wrapper {
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.input-wrapper::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
left: 12px;
|
||||
right: 12px;
|
||||
bottom: 8px;
|
||||
height: 2px;
|
||||
background: var(--primary);
|
||||
border-radius: 2px;
|
||||
opacity: 0;
|
||||
transform: scaleX(0.2);
|
||||
transition:
|
||||
transform var(--transition-normal),
|
||||
opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-wrapper:focus-within::after {
|
||||
opacity: 0.35;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.input-icon {
|
||||
position: absolute;
|
||||
left: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
color: var(--text-quaternary);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
width: 100%;
|
||||
padding: 16px 16px 16px 48px;
|
||||
background: var(--input-bg);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--input-text);
|
||||
font-size: 16px;
|
||||
transition:
|
||||
border-color var(--transition-normal),
|
||||
box-shadow var(--transition-normal);
|
||||
}
|
||||
|
||||
.input-wrapper input::placeholder {
|
||||
color: var(--input-placeholder);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus {
|
||||
outline: none;
|
||||
border-color: var(--input-border-focus);
|
||||
box-shadow: var(--input-shadow-focus);
|
||||
}
|
||||
|
||||
.input-wrapper input:focus + .input-icon,
|
||||
.input-wrapper input:not(:placeholder-shown) + .input-icon {
|
||||
color: var(--primary);
|
||||
}
|
||||
|
||||
.input-wrapper input:hover {
|
||||
filter: brightness(1.03);
|
||||
}
|
||||
|
||||
.input-wrapper input.input-error {
|
||||
border-color: var(--error);
|
||||
box-shadow: 0 0 0 3px var(--error-light);
|
||||
}
|
||||
|
||||
.password-toggle {
|
||||
position: absolute;
|
||||
right: 16px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--text-quaternary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 0.2s ease,
|
||||
transform var(--transition-fast);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.password-toggle:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.password-toggle:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
.password-toggle svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.error-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 16px;
|
||||
background: var(--error-light);
|
||||
border: 1px solid var(--error-border);
|
||||
border-radius: var(--radius-lg);
|
||||
color: var(--error);
|
||||
}
|
||||
|
||||
.auth-form.has-error {
|
||||
animation: shake 0.4s ease;
|
||||
}
|
||||
|
||||
@keyframes shake {
|
||||
0% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
15% {
|
||||
transform: translateX(-6px);
|
||||
}
|
||||
30% {
|
||||
transform: translateX(6px);
|
||||
}
|
||||
45% {
|
||||
transform: translateX(-4px);
|
||||
}
|
||||
60% {
|
||||
transform: translateX(4px);
|
||||
}
|
||||
75% {
|
||||
transform: translateX(-2px);
|
||||
}
|
||||
90% {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(0);
|
||||
}
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
font-size: 14px;
|
||||
font-weight: var(--font-medium);
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
width: 100%;
|
||||
padding: 16px;
|
||||
background: var(--btn-primary-bg);
|
||||
color: var(--btn-primary-text);
|
||||
border: 1px solid var(--btn-primary-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 16px;
|
||||
font-weight: var(--font-semibold);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background var(--transition-normal),
|
||||
box-shadow var(--transition-normal),
|
||||
transform var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 8px;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.submit-btn::before {
|
||||
content: none;
|
||||
}
|
||||
|
||||
.submit-btn:hover:not(:disabled) {
|
||||
background: var(--btn-primary-hover);
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.submit-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
.submit-btn:active:not(:disabled) {
|
||||
transform: translateY(0);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.form-footer {
|
||||
margin-top: 24px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 12px;
|
||||
color: var(--text-quaternary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.help-text code {
|
||||
background: var(--input-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||
color: var(--primary);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.webauthn-section {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
margin: 20px 0;
|
||||
color: var(--text-quaternary);
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
border-bottom: 1px solid var(--input-border);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.webauthn-btn {
|
||||
width: 100%;
|
||||
padding: 14px;
|
||||
background: var(--surface-secondary);
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--input-border);
|
||||
border-radius: var(--radius-lg);
|
||||
font-size: 15px;
|
||||
font-weight: var(--font-medium);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.webauthn-btn:hover:not(:disabled) {
|
||||
background: var(--surface-tertiary);
|
||||
border-color: var(--input-border-focus);
|
||||
}
|
||||
|
||||
.webauthn-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.webauthn-icon {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
/* 响应式设计 */
|
||||
@media (max-width: 480px) {
|
||||
.form-header h2 {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.form-header p {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.input-wrapper input {
|
||||
padding: 14px 14px 14px 44px;
|
||||
font-size: 16px; /* 防止iOS缩放 */
|
||||
}
|
||||
|
||||
.submit-btn {
|
||||
padding: 14px;
|
||||
font-size: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,496 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 :size="24" class="text-blue-500 animate-spin mb-3" />
|
||||
<p class="text-zinc-500 text-xs font-medium">加载绑定状态...</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<!-- GitHub -->
|
||||
<div v-if="config.public.oauth.github" :class="itemClass">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<AuthProvidersGitHubIcon class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-zinc-200">{{
|
||||
getProviderDisplayName('github')
|
||||
}}</span>
|
||||
<span v-if="githubIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
|
||||
githubIdentity.providerUsername
|
||||
}}</span>
|
||||
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="githubIdentity"
|
||||
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="confirmUnbind('github')"
|
||||
>
|
||||
{{ actionLoading ? '处理中...' : '解绑' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind('github')"
|
||||
>
|
||||
{{ actionLoading ? '跳转中...' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Casdoor (如果启用) -->
|
||||
<div v-if="config.public.oauth.casdoor" :class="itemClass">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<Shield :size="20" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-zinc-200">{{
|
||||
getProviderDisplayName('casdoor')
|
||||
}}</span>
|
||||
<span v-if="casdoorIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
|
||||
casdoorIdentity.providerUsername
|
||||
}}</span>
|
||||
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="casdoorIdentity"
|
||||
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="confirmUnbind('casdoor')"
|
||||
>
|
||||
{{ actionLoading ? '处理中...' : '解绑' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind('casdoor')"
|
||||
>
|
||||
{{ actionLoading ? '跳转中...' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Google (如果启用) -->
|
||||
<div v-if="config.public.oauth.google" :class="itemClass">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<AuthProvidersGoogleIcon class="w-5 h-5" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<span class="text-sm font-bold text-zinc-200">{{
|
||||
getProviderDisplayName('google')
|
||||
}}</span>
|
||||
<span v-if="googleIdentity" class="text-[11px] text-blue-500 font-medium mt-0.5">{{
|
||||
googleIdentity.providerUsername
|
||||
}}</span>
|
||||
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="googleIdentity"
|
||||
class="px-4 py-1.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-xs font-black rounded-xl transition-all disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="confirmUnbind('google')"
|
||||
>
|
||||
{{ actionLoading ? '处理中...' : '解绑' }}
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click="handleBind('google')"
|
||||
>
|
||||
{{ actionLoading ? '跳转中...' : '立即绑定' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- WebAuthn / Passkey -->
|
||||
<div
|
||||
v-if="isWebAuthnSupported || webauthnIdentities.length > 0 || !isSecureContext"
|
||||
:class="[
|
||||
itemClass,
|
||||
webauthnIdentities.length > 0 ? 'cursor-pointer hover:bg-zinc-900/70' : ''
|
||||
]"
|
||||
@click="toggleWebAuthnList"
|
||||
>
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-10 h-10 rounded-xl bg-zinc-950 flex items-center justify-center border border-zinc-800 text-zinc-100"
|
||||
>
|
||||
<Fingerprint :size="20" />
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-sm font-bold text-zinc-200">Windows Hello / Passkey</span>
|
||||
<ChevronDown
|
||||
v-if="webauthnIdentities.length > 0"
|
||||
:size="14"
|
||||
class="text-zinc-500 transition-transform duration-300"
|
||||
:class="{ 'rotate-180': isWebAuthnExpanded }"
|
||||
/>
|
||||
</div>
|
||||
<span class="text-[11px] text-zinc-500 mt-0.5"
|
||||
>已绑定 {{ webauthnIdentities.length }} 个设备</span
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="isWebAuthnSupported"
|
||||
class="px-4 py-1.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
|
||||
:disabled="actionLoading"
|
||||
@click.stop="handleWebAuthnRegister"
|
||||
>
|
||||
{{ actionLoading ? '处理中...' : '添加设备' }}
|
||||
</button>
|
||||
<div v-else-if="!isSecureContext" class="flex items-center gap-1 text-amber-500 bg-amber-500/10 px-3 py-1.5 rounded-lg border border-amber-500/20">
|
||||
<AlertTriangle :size="12" />
|
||||
<span class="text-[10px] font-medium">需要 HTTPS 环境</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- WebAuthn 设备列表 -->
|
||||
<Transition name="expand">
|
||||
<div v-if="isWebAuthnExpanded && webauthnIdentities.length > 0" class="pl-16 -mt-2 overflow-hidden">
|
||||
<div class="space-y-2 pt-2">
|
||||
<div
|
||||
v-for="cred in webauthnIdentities"
|
||||
:key="cred.id"
|
||||
class="flex items-center justify-between p-3 bg-zinc-950/20 border border-zinc-900 rounded-xl group/item"
|
||||
>
|
||||
<div class="flex flex-col flex-1 mr-4">
|
||||
<div v-if="editingId === cred.id" class="flex items-center gap-2 mb-1">
|
||||
<input
|
||||
v-model="editingName"
|
||||
type="text"
|
||||
class="bg-zinc-900 border border-zinc-700 rounded px-2 py-0.5 text-xs text-zinc-200 focus:outline-none focus:border-blue-500 w-full"
|
||||
:disabled="isRenaming"
|
||||
@keyup.enter="saveEditing(cred.id)"
|
||||
@keyup.esc="cancelEditing"
|
||||
@click.stop
|
||||
ref="editInput"
|
||||
/>
|
||||
</div>
|
||||
<div v-else class="flex items-center gap-2 mb-0.5">
|
||||
<span class="text-xs font-medium text-zinc-300">{{ cred.providerUsername }}</span>
|
||||
<button
|
||||
class="text-zinc-500 hover:text-zinc-300 opacity-0 group-hover/item:opacity-100 transition-opacity p-0.5"
|
||||
@click.stop="startEditing(cred)"
|
||||
title="重命名"
|
||||
>
|
||||
<Pencil :size="12" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<span class="text-[10px] text-zinc-600"
|
||||
>添加于 {{ new Date(cred.createdAt).toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
hour12: false
|
||||
}) }}</span
|
||||
>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-if="editingId === cred.id">
|
||||
<button
|
||||
class="text-zinc-400 hover:text-green-400 transition-colors p-1"
|
||||
:disabled="isRenaming"
|
||||
@click.stop="saveEditing(cred.id)"
|
||||
title="保存"
|
||||
>
|
||||
<Check :size="14" />
|
||||
</button>
|
||||
<button
|
||||
class="text-zinc-400 hover:text-zinc-200 transition-colors p-1"
|
||||
:disabled="isRenaming"
|
||||
@click.stop="cancelEditing"
|
||||
title="取消"
|
||||
>
|
||||
<X :size="14" />
|
||||
</button>
|
||||
</template>
|
||||
<button
|
||||
v-else
|
||||
class="text-xs text-rose-500 hover:text-rose-400 font-medium px-2 py-1 opacity-0 group-hover/item:opacity-100 transition-opacity"
|
||||
@click="confirmUnbindWebAuthn(cred)"
|
||||
>
|
||||
移除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
|
||||
<!-- 确认对话框 -->
|
||||
<ConfirmDialog
|
||||
v-model:show="showConfirmDialog"
|
||||
:loading="confirmDialog.loading"
|
||||
:message="confirmDialog.message"
|
||||
:title="confirmDialog.title"
|
||||
:type="confirmDialog.type"
|
||||
@cancel="confirmDialog.onCancel"
|
||||
@confirm="confirmDialog.onConfirm"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, nextTick } from 'vue'
|
||||
import { Loader2, Shield, Fingerprint, ChevronDown, Pencil, Check, X, AlertTriangle } from 'lucide-vue-next'
|
||||
import ConfirmDialog from '~/components/UI/ConfirmDialog.vue'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
import { getProviderDisplayName } from '~/utils/oauth'
|
||||
import { startRegistration, browserSupportsWebAuthn } from '@simplewebauthn/browser'
|
||||
|
||||
const config = useRuntimeConfig()
|
||||
const { showToast } = useToast()
|
||||
const identities = ref([])
|
||||
const loading = ref(true)
|
||||
const actionLoading = ref(false)
|
||||
const isWebAuthnSupported = ref(false)
|
||||
const isSecureContext = ref(true)
|
||||
|
||||
// 编辑相关
|
||||
const editingId = ref<string | null>(null)
|
||||
const editingName = ref('')
|
||||
const isRenaming = ref(false)
|
||||
const editInput = ref<HTMLInputElement | null>(null)
|
||||
|
||||
interface WebAuthnCredential {
|
||||
id: string
|
||||
providerUsername: string
|
||||
createdAt: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
const startEditing = async (cred: WebAuthnCredential) => {
|
||||
editingId.value = cred.id
|
||||
editingName.value = cred.providerUsername
|
||||
// 聚焦输入框
|
||||
await nextTick()
|
||||
if (editInput.value) {
|
||||
(editInput.value as HTMLInputElement)?.focus()
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEditing = () => {
|
||||
editingId.value = null
|
||||
editingName.value = ''
|
||||
}
|
||||
|
||||
const saveEditing = async (id: string) => {
|
||||
if (!editingName.value.trim()) {
|
||||
showToast('设备名称不能为空', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
if (editingName.value.trim().length > 50) {
|
||||
showToast('设备名称过长 (最大50个字符)', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
isRenaming.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/webauthn/rename', {
|
||||
method: 'POST',
|
||||
body: { id, name: editingName.value }
|
||||
})
|
||||
showToast('设备名称修改成功', 'success')
|
||||
await fetchIdentities()
|
||||
cancelEditing()
|
||||
} catch (e: any) {
|
||||
showToast(e.data?.message || '修改失败', 'error')
|
||||
} finally {
|
||||
isRenaming.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const isWebAuthnExpanded = ref(false)
|
||||
const toggleWebAuthnList = () => {
|
||||
if (webauthnIdentities.value.length > 0) {
|
||||
isWebAuthnExpanded.value = !isWebAuthnExpanded.value
|
||||
}
|
||||
}
|
||||
|
||||
// 确认对话框相关
|
||||
const showConfirmDialog = ref(false)
|
||||
const confirmDialog = ref({
|
||||
title: '',
|
||||
message: '',
|
||||
type: 'warning',
|
||||
loading: false,
|
||||
onConfirm: () => {},
|
||||
onCancel: () => {}
|
||||
})
|
||||
|
||||
// 样式类
|
||||
const itemClass =
|
||||
'flex items-center justify-between p-4 bg-zinc-950/30 border border-zinc-900 rounded-2xl hover:bg-zinc-900/50 transition-all group'
|
||||
|
||||
const githubIdentity = computed(() => identities.value.find((i) => i.provider === 'github'))
|
||||
const casdoorIdentity = computed(() => identities.value.find((i) => i.provider === 'casdoor'))
|
||||
const googleIdentity = computed(() => identities.value.find((i) => i.provider === 'google'))
|
||||
const webauthnIdentities = computed(() => identities.value.filter((i) => i.provider === 'webauthn'))
|
||||
|
||||
const fetchIdentities = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
identities.value = await $fetch('/api/auth/identities')
|
||||
} catch (e) {
|
||||
console.error('获取绑定信息失败', e)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleBind = (provider) => {
|
||||
actionLoading.value = true
|
||||
// 绑定也是通过 OAuth 流程,最终回调时会自动识别已登录状态并执行绑定
|
||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||
}
|
||||
|
||||
const confirmUnbind = (provider) => {
|
||||
const providerName = getProviderDisplayName(provider)
|
||||
|
||||
confirmDialog.value = {
|
||||
title: '解除绑定',
|
||||
message: `确定要解除 ${providerName} 账号的绑定吗?解除后您将无法使用该账号登录。`,
|
||||
type: 'danger',
|
||||
loading: false,
|
||||
onConfirm: () => handleUnbind(provider),
|
||||
onCancel: () => {
|
||||
showConfirmDialog.value = false
|
||||
}
|
||||
}
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
const confirmUnbindWebAuthn = (cred) => {
|
||||
confirmDialog.value = {
|
||||
title: '移除 Passkey',
|
||||
message: `确定要移除设备 "${cred.providerUsername}" 吗?移除后将无法使用该设备登录。`,
|
||||
type: 'danger',
|
||||
loading: false,
|
||||
onConfirm: () => handleUnbind('webauthn', cred.id),
|
||||
onCancel: () => {
|
||||
showConfirmDialog.value = false
|
||||
}
|
||||
}
|
||||
showConfirmDialog.value = true
|
||||
}
|
||||
|
||||
const handleUnbind = async (provider, id = null) => {
|
||||
confirmDialog.value.loading = true
|
||||
actionLoading.value = true
|
||||
try {
|
||||
await $fetch('/api/auth/unbind', {
|
||||
method: 'POST',
|
||||
body: { provider, id }
|
||||
})
|
||||
await fetchIdentities()
|
||||
showToast('解除绑定成功', 'success')
|
||||
showConfirmDialog.value = false
|
||||
} catch (e) {
|
||||
showToast(e.data?.message || '解绑失败', 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
confirmDialog.value.loading = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleWebAuthnRegister = async () => {
|
||||
if (!isWebAuthnSupported.value) {
|
||||
showToast('您的浏览器不支持 Windows Hello / Passkey', 'error')
|
||||
return
|
||||
}
|
||||
|
||||
actionLoading.value = true
|
||||
try {
|
||||
const options = await $fetch('/api/auth/webauthn/register/options')
|
||||
|
||||
let attResp
|
||||
try {
|
||||
attResp = await startRegistration(options)
|
||||
} catch (e) {
|
||||
if (e.name === 'NotAllowedError') {
|
||||
throw new Error('用户取消了操作')
|
||||
}
|
||||
throw e
|
||||
}
|
||||
|
||||
// 提示用户输入设备名称(可选,这里先用默认的)
|
||||
// attResp.label = 'Windows Hello'
|
||||
|
||||
await $fetch('/api/auth/webauthn/register/verify', {
|
||||
method: 'POST',
|
||||
body: attResp
|
||||
})
|
||||
|
||||
showToast('设备添加成功', 'success')
|
||||
await fetchIdentities()
|
||||
} catch (e) {
|
||||
console.error('WebAuthn 注册错误:', e)
|
||||
const apiError = e as { data?: { message?: string }; message?: string }
|
||||
const err = e as Error
|
||||
const message = apiError.data?.message || err.message || '添加设备失败'
|
||||
showToast(message, 'error')
|
||||
} finally {
|
||||
actionLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
fetchIdentities()
|
||||
|
||||
isSecureContext.value = window.isSecureContext
|
||||
|
||||
const isApiSupported = browserSupportsWebAuthn()
|
||||
let isPlatformAuthenticatorAvailable = false
|
||||
|
||||
if (isApiSupported && window.PublicKeyCredential?.isUserVerifyingPlatformAuthenticatorAvailable) {
|
||||
try {
|
||||
isPlatformAuthenticatorAvailable = await window.PublicKeyCredential.isUserVerifyingPlatformAuthenticatorAvailable()
|
||||
} catch (e) {
|
||||
console.warn('WebAuthn 平台认证器检查失败:', e)
|
||||
}
|
||||
}
|
||||
|
||||
// 兼容外部安全密钥(如 YubiKey),即使没有内置平台认证器也允许尝试
|
||||
isWebAuthnSupported.value = isApiSupported
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.expand-enter-active,
|
||||
.expand-leave-active {
|
||||
transition: all 0.3s ease-in-out;
|
||||
max-height: 500px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.expand-enter-from,
|
||||
.expand-leave-to {
|
||||
max-height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,45 @@
|
||||
<template>
|
||||
<div v-if="hasEnabledProviders" class="mt-6 w-full">
|
||||
<div class="flex items-center text-center text-[var(--text-tertiary)] text-xs mb-4">
|
||||
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
|
||||
<span class="px-2.5">或使用第三方账号登录</span>
|
||||
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
|
||||
</div>
|
||||
<div class="flex justify-center gap-4">
|
||||
<button
|
||||
v-if="config.public.oauth.github"
|
||||
type="button"
|
||||
class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-[#24292e] hover:text-white hover:border-[#24292e]"
|
||||
title="使用 GitHub 登录"
|
||||
@click="loginWith('github')"
|
||||
>
|
||||
<AuthProvidersGitHubIcon />
|
||||
</button>
|
||||
|
||||
<button
|
||||
v-if="config.public.oauth.google"
|
||||
type="button"
|
||||
class="w-12 h-12 rounded-full border border-[var(--border-color)] bg-[var(--bg-secondary)] text-[var(--text-primary)] flex items-center justify-center cursor-pointer transition-all duration-200 hover:-translate-y-0.5 hover:shadow-sm hover:bg-[var(--bg-tertiary)] hover:bg-white hover:text-black hover:border-[#dadce0]"
|
||||
title="使用 Google 登录"
|
||||
@click="loginWith('google')"
|
||||
>
|
||||
<AuthProvidersGoogleIcon />
|
||||
</button>
|
||||
|
||||
<!-- 其他提供商可在此添加 -->
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
const hasEnabledProviders = computed(() => {
|
||||
return config.public.oauth && Object.values(config.public.oauth).some((enabled) => enabled)
|
||||
})
|
||||
|
||||
const loginWith = (provider: string) => {
|
||||
// 外部导航到 API 端点
|
||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<img src="https://casdoor.org/img/casdoor.png" alt="Casdoor" class="h-5 w-auto object-contain" >
|
||||
</template>
|
||||
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
width="24"
|
||||
height="24"
|
||||
viewBox="0 0 24 24"
|
||||
fill="currentColor"
|
||||
>
|
||||
<path
|
||||
d="M12 0c-6.626 0-12 5.373-12 12 0 5.302 3.438 9.8 8.207 11.387.599.111.793-.261.793-.577v-2.234c-3.338.726-4.033-1.416-4.033-1.416-.546-1.387-1.333-1.756-1.333-1.756-1.089-.745.083-.729.083-.729 1.205.084 1.839 1.237 1.839 1.237 1.07 1.834 2.807 1.304 3.492.997.107-.775.418-1.305.762-1.604-2.665-.305-5.467-1.334-5.467-5.931 0-1.311.469-2.381 1.236-3.221-.124-.303-.535-1.524.117-3.176 0 0 1.008-.322 3.301 1.23.957-.266 1.983-.399 3.003-.404 1.02.005 2.047.138 3.006.404 2.291-1.552 3.297-1.23 3.297-1.23.653 1.653.242 2.874.118 3.176.77.84 1.235 1.911 1.235 3.221 0 4.609-2.807 5.624-5.479 5.921.43.372.823 1.102.823 2.222v3.293c0 .319.192.694.801.576 4.765-1.589 8.199-6.086 8.199-11.386 0-6.627-5.373-12-12-12z"
|
||||
/>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,22 @@
|
||||
<template>
|
||||
<svg viewBox="0 0 24 24" width="1.2em" height="1.2em" xmlns="http://www.w3.org/2000/svg">
|
||||
<g transform="matrix(1, 0, 0, 1, 27.009001, -39.238998)">
|
||||
<path
|
||||
fill="#4285F4"
|
||||
d="M -3.264 51.509 C -3.264 50.719 -3.334 49.969 -3.454 49.239 L -14.754 49.239 L -14.754 53.749 L -8.284 53.749 C -8.574 55.229 -9.424 56.479 -10.684 57.329 L -10.684 60.329 L -6.824 60.329 C -4.564 58.239 -3.264 55.159 -3.264 51.509 Z"
|
||||
/>
|
||||
<path
|
||||
fill="#34A853"
|
||||
d="M -14.754 63.239 C -11.514 63.239 -8.804 62.159 -6.824 60.329 L -10.684 57.329 C -11.764 58.049 -13.134 58.489 -14.754 58.489 C -17.884 58.489 -20.534 56.379 -21.484 53.529 L -25.464 53.529 L -25.464 56.619 C -23.494 60.539 -19.444 63.239 -14.754 63.239 Z"
|
||||
/>
|
||||
<path
|
||||
fill="#FBBC05"
|
||||
d="M -21.484 53.529 C -21.734 52.809 -21.864 52.039 -21.864 51.239 C -21.864 50.439 -21.724 49.669 -21.484 48.949 L -21.484 45.859 L -25.464 45.859 C -26.284 47.479 -26.754 49.299 -26.754 51.239 C -26.754 53.179 -26.284 54.999 -25.464 56.619 L -21.484 53.529 Z"
|
||||
/>
|
||||
<path
|
||||
fill="#EA4335"
|
||||
d="M -14.754 43.989 C -12.984 43.989 -11.404 44.599 -10.154 45.789 L -6.734 42.369 C -8.804 40.429 -11.514 39.239 -14.754 39.239 C -19.444 39.239 -23.494 41.939 -25.464 45.859 L -21.484 48.949 C -20.534 46.099 -17.884 43.989 -14.754 43.989 Z"
|
||||
/>
|
||||
</g>
|
||||
</svg>
|
||||
</template>
|
||||
@@ -0,0 +1,234 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<!-- 状态显示 -->
|
||||
<div class="flex items-center justify-between p-4 bg-zinc-950/50 rounded-xl border border-zinc-800">
|
||||
<div class="flex items-center gap-3">
|
||||
<div
|
||||
class="p-2 rounded-lg"
|
||||
:class="isEnabled ? 'bg-green-500/10 text-green-500' : 'bg-zinc-800 text-zinc-500'"
|
||||
>
|
||||
<ShieldCheck :size="20" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 class="font-bold text-zinc-100">双重认证 (2FA)</h3>
|
||||
<p class="text-xs text-zinc-500">
|
||||
{{ isEnabled ? '您的账户已开启双重认证保护' : '未开启,建议开启以提升账户安全性' }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-if="!isEnabled"
|
||||
@click="startSetup"
|
||||
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-sm font-bold rounded-lg transition-colors"
|
||||
>
|
||||
开启
|
||||
</button>
|
||||
<button
|
||||
v-else
|
||||
@click="confirmDisable"
|
||||
class="px-4 py-2 bg-red-500/10 hover:bg-red-500/20 text-red-500 border border-red-500/20 text-sm font-bold rounded-lg transition-colors"
|
||||
>
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 开启流程 -->
|
||||
<div v-if="showSetup" class="bg-zinc-950/30 rounded-xl border border-zinc-800 p-6 space-y-6 animate-in fade-in slide-in-from-top-4">
|
||||
<div class="flex items-start justify-between">
|
||||
<div>
|
||||
<h4 class="font-bold text-zinc-100 mb-1">设置双重认证</h4>
|
||||
<p class="text-sm text-zinc-400">请使用 Google Authenticator 或其他验证器应用扫描下方二维码</p>
|
||||
</div>
|
||||
<button @click="cancelSetup" class="text-zinc-500 hover:text-zinc-300">
|
||||
<X :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col md:flex-row gap-8 items-center md:items-start">
|
||||
<!-- 二维码区域 -->
|
||||
<div class="flex-shrink-0 bg-white p-2 rounded-lg">
|
||||
<img v-if="qrCodeUrl" :src="qrCodeUrl" alt="2FA QR Code" class="w-48 h-48" />
|
||||
<div v-else class="w-48 h-48 flex items-center justify-center bg-zinc-100 text-zinc-400">
|
||||
<Loader2 class="animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证输入区域 -->
|
||||
<div class="flex-1 space-y-4 w-full">
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">
|
||||
手动输入密钥
|
||||
</label>
|
||||
<div class="flex items-center gap-2">
|
||||
<code class="px-3 py-2 bg-zinc-900 rounded-lg text-zinc-300 font-mono text-sm border border-zinc-800 select-all">
|
||||
{{ secret }}
|
||||
</code>
|
||||
<button
|
||||
@click="copySecret"
|
||||
class="p-2 hover:bg-zinc-800 rounded-lg text-zinc-400 transition-colors"
|
||||
title="复制密钥"
|
||||
>
|
||||
<Copy :size="16" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-xs font-bold text-zinc-500 uppercase tracking-wider mb-2">
|
||||
验证码
|
||||
</label>
|
||||
<div class="flex gap-2">
|
||||
<input
|
||||
v-model="verificationCode"
|
||||
type="text"
|
||||
placeholder="请输入6位验证码"
|
||||
maxlength="6"
|
||||
class="flex-1 bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-2 text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all font-mono text-center tracking-widest"
|
||||
@keyup.enter="enable2FA"
|
||||
/>
|
||||
<button
|
||||
@click="enable2FA"
|
||||
:disabled="loading || verificationCode.length !== 6"
|
||||
class="px-6 py-2 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold rounded-lg transition-colors flex items-center gap-2"
|
||||
>
|
||||
<Loader2 v-if="loading" class="animate-spin" :size="16" />
|
||||
<span>验证并开启</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<UIConfirmDialog
|
||||
:show="showDisableConfirm"
|
||||
title="关闭双重认证"
|
||||
content="关闭后,您的账户将不再受到双重认证保护。为确保安全,请输入您的登录密码以确认。"
|
||||
confirm-text="验证并关闭"
|
||||
confirm-type="danger"
|
||||
:show-input="true"
|
||||
input-placeholder="请输入当前登录密码"
|
||||
input-type="password"
|
||||
@confirm="disable2FA"
|
||||
@cancel="showDisableConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { ShieldCheck, X, Copy, Loader2 } from 'lucide-vue-next'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
initialEnabled?: boolean
|
||||
}>()
|
||||
|
||||
const isEnabled = ref(props.initialEnabled || false)
|
||||
const showSetup = ref(false)
|
||||
const showDisableConfirm = ref(false)
|
||||
const loading = ref(false)
|
||||
const qrCodeUrl = ref('')
|
||||
const secret = ref('')
|
||||
const verificationCode = ref('')
|
||||
const { showToast } = useToast()
|
||||
|
||||
// 获取当前状态(可选,如果父组件没传可以通过API获取,这里暂时假设父组件会传或者初始化为false,
|
||||
// 实际生产中最好有一个 API check status,但为了简化,这里先依赖父组件或默认)
|
||||
// 修正:我们应该在组件挂载时检查状态,但目前没有专门的 check status API,
|
||||
// 通常这包含在 user profile 信息中。如果 user 对象里有 has2FA 字段最好。
|
||||
// 假设父组件通过 prop 传入,或者我们添加一个 check API。
|
||||
// 为了稳健,我们可以复用 generate 接口之前的逻辑,或者在 login 时返回的信息里带。
|
||||
// 这里的实现先依赖 props,如果需要更精确,可以加一个 api。
|
||||
|
||||
const startSetup = async () => {
|
||||
try {
|
||||
loading.value = true
|
||||
const data = await $fetch('/api/user/2fa/generate', {
|
||||
method: 'POST'
|
||||
})
|
||||
|
||||
if (data) {
|
||||
qrCodeUrl.value = data.qrCode
|
||||
secret.value = data.secret
|
||||
showSetup.value = true
|
||||
verificationCode.value = ''
|
||||
}
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '获取验证码失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelSetup = () => {
|
||||
showSetup.value = false
|
||||
qrCodeUrl.value = ''
|
||||
secret.value = ''
|
||||
verificationCode.value = ''
|
||||
}
|
||||
|
||||
const copySecret = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(secret.value)
|
||||
showToast('密钥已复制', 'success')
|
||||
} catch (err) {
|
||||
showToast('复制失败', 'error')
|
||||
}
|
||||
}
|
||||
|
||||
const enable2FA = async () => {
|
||||
if (verificationCode.value.length !== 6) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const { error } = await useFetch('/api/user/2fa/enable', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
token: verificationCode.value,
|
||||
secret: secret.value
|
||||
}
|
||||
})
|
||||
|
||||
if (error.value) throw error.value
|
||||
|
||||
showToast('双重认证已开启', 'success')
|
||||
isEnabled.value = true
|
||||
cancelSetup()
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '验证失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDisable = () => {
|
||||
showDisableConfirm.value = true
|
||||
}
|
||||
|
||||
const disable2FA = async (password: string) => {
|
||||
if (!password) {
|
||||
showToast('请输入密码', 'warning')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
const { error } = await useFetch('/api/user/2fa/disable', {
|
||||
method: 'POST',
|
||||
body: { password }
|
||||
})
|
||||
|
||||
if (error.value) throw error.value
|
||||
|
||||
showToast('双重认证已关闭', 'success')
|
||||
isEnabled.value = false
|
||||
showDisableConfirm.value = false
|
||||
} catch (err: any) {
|
||||
showToast(err.data?.message || err.message || '关闭失败', 'error')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -0,0 +1,233 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[2000] bg-black/80 backdrop-blur-sm flex items-center justify-center p-4"
|
||||
@click="$emit('cancel')"
|
||||
>
|
||||
<div
|
||||
class="w-full max-w-md bg-zinc-950 border border-zinc-800 rounded-2xl p-6 shadow-2xl space-y-6"
|
||||
@click.stop
|
||||
>
|
||||
<div class="text-center space-y-2">
|
||||
<h3 class="text-xl font-bold text-zinc-100">双重认证</h3>
|
||||
<p class="text-sm text-zinc-400">
|
||||
{{ method === 'totp' ? '请输入您的认证器应用生成的6位验证码' : '请输入发送到您邮箱的验证码' }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="space-y-4">
|
||||
<!-- 邮箱输入 (仅 Email 模式) -->
|
||||
<div v-if="method === 'email'" class="space-y-2">
|
||||
<label class="block text-sm text-zinc-400">
|
||||
请补全您的邮箱以接收验证码
|
||||
<span v-if="maskedEmail" class="block text-xs text-zinc-500 mt-1">
|
||||
提示: {{ maskedEmail }}
|
||||
</span>
|
||||
</label>
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="emailInput"
|
||||
type="email"
|
||||
placeholder="请输入完整邮箱地址"
|
||||
class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
:disabled="emailSent && cooldown > 0"
|
||||
@keyup.enter="!emailSent && sendEmailCode()"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 验证码输入 -->
|
||||
<div v-if="method === 'totp' || emailSent">
|
||||
<div class="relative">
|
||||
<input
|
||||
v-model="code"
|
||||
type="text"
|
||||
maxlength="6"
|
||||
placeholder="000000"
|
||||
class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-center text-2xl font-mono tracking-widest text-zinc-100 placeholder-zinc-700 focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500 transition-all"
|
||||
@keyup.enter="handleVerify"
|
||||
ref="inputRef"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 发送邮箱验证码按钮 -->
|
||||
<div v-if="method === 'email'" class="text-center">
|
||||
<button
|
||||
type="button"
|
||||
@click="sendEmailCode"
|
||||
:disabled="cooldown > 0 || sending || !emailInput"
|
||||
class="text-sm text-blue-500 hover:text-blue-400 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{{ cooldown > 0 ? `${cooldown}秒后重新发送` : (emailSent ? '重新发送验证码' : '发送验证码') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="p-3 bg-red-500/10 border border-red-500/20 rounded-lg flex items-center gap-2 text-red-500 text-sm">
|
||||
<AlertCircle :size="16" />
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
@click="handleVerify"
|
||||
:disabled="loading || code.length !== 6"
|
||||
class="w-full py-3 bg-blue-600 hover:bg-blue-500 disabled:opacity-50 disabled:cursor-not-allowed text-white font-bold rounded-lg transition-colors flex items-center justify-center gap-2"
|
||||
>
|
||||
<Loader2 v-if="loading" class="animate-spin" :size="18" />
|
||||
<span>验证登录</span>
|
||||
</button>
|
||||
|
||||
<!-- 切换验证方式按钮 -->
|
||||
<button
|
||||
v-if="hasMultipleMethods"
|
||||
@click="toggleMethod"
|
||||
class="w-full py-2 text-blue-500 hover:text-blue-400 text-sm transition-colors"
|
||||
>
|
||||
{{ method === 'totp' ? '无法使用验证器?切换到邮箱验证' : '使用验证器应用验证' }}
|
||||
</button>
|
||||
|
||||
<button
|
||||
@click="$emit('cancel')"
|
||||
class="w-full py-2 text-zinc-500 hover:text-zinc-300 text-sm transition-colors"
|
||||
>
|
||||
返回登录
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch, nextTick, computed, onUnmounted } from 'vue'
|
||||
import { Loader2, AlertCircle } from 'lucide-vue-next'
|
||||
import { useToast } from '~/composables/useToast'
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
userId: number
|
||||
availableMethods?: string[]
|
||||
maskedEmail?: string
|
||||
tempToken?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'success', data: any): void
|
||||
(e: 'cancel'): void
|
||||
}>()
|
||||
|
||||
const { showToast } = useToast()
|
||||
// 默认优先使用 TOTP,如果没有则使用 Email
|
||||
const defaultMethod = computed(() => props.availableMethods?.includes('totp') ? 'totp' : 'email')
|
||||
const method = ref(defaultMethod.value)
|
||||
const methods = computed(() => props.availableMethods || ['totp', 'email'])
|
||||
const hasMultipleMethods = computed(() => methods.value.length > 1)
|
||||
|
||||
const code = ref('')
|
||||
const emailInput = ref('')
|
||||
const emailSent = ref(false)
|
||||
const loading = ref(false)
|
||||
const sending = ref(false)
|
||||
const cooldown = ref(0)
|
||||
const error = ref('')
|
||||
const inputRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
let timer: NodeJS.Timeout | undefined
|
||||
|
||||
onUnmounted(() => {
|
||||
if (timer) {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
})
|
||||
|
||||
const toggleMethod = () => {
|
||||
method.value = method.value === 'totp' ? 'email' : 'totp'
|
||||
// 切换回 totp 时清空邮箱输入
|
||||
if (method.value === 'totp') {
|
||||
emailInput.value = ''
|
||||
emailSent.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const sendEmailCode = async () => {
|
||||
try {
|
||||
if (!emailInput.value) {
|
||||
error.value = '请输入您的邮箱地址'
|
||||
return
|
||||
}
|
||||
|
||||
sending.value = true
|
||||
error.value = ''
|
||||
|
||||
await $fetch('/api/auth/2fa/send-email', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
userId: props.userId,
|
||||
token: props.tempToken,
|
||||
email: emailInput.value
|
||||
}
|
||||
})
|
||||
|
||||
showToast('验证码已发送', 'success')
|
||||
emailSent.value = true
|
||||
cooldown.value = 60
|
||||
timer = setInterval(() => {
|
||||
cooldown.value--
|
||||
if (cooldown.value <= 0) {
|
||||
clearInterval(timer)
|
||||
timer = undefined
|
||||
}
|
||||
}, 1000)
|
||||
|
||||
// 发送成功后自动聚焦验证码输入框
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.message || err.message || '发送失败'
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleVerify = async () => {
|
||||
if (code.value.length !== 6) return
|
||||
|
||||
try {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
const { verify2FA } = useAuth()
|
||||
const response = await verify2FA(props.userId, code.value, method.value as 'totp' | 'email', props.tempToken)
|
||||
|
||||
emit('success', response)
|
||||
} catch (err: any) {
|
||||
error.value = err.data?.message || err.message || '验证失败'
|
||||
code.value = '' // 出错清空
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 切换方式时清空输入
|
||||
watch(method, () => {
|
||||
code.value = ''
|
||||
error.value = ''
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
})
|
||||
|
||||
// 显示时聚焦输入框
|
||||
watch(() => props.show, (newVal) => {
|
||||
if (newVal) {
|
||||
// 每次重新打开时重置为默认方式(优先 TOTP)
|
||||
method.value = defaultMethod.value
|
||||
nextTick(() => inputRef.value?.focus())
|
||||
}
|
||||
})
|
||||
</script>
|
||||
@@ -0,0 +1,293 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition
|
||||
enter-active-class="transition duration-300 ease-out"
|
||||
enter-from-class="opacity-0 scale-95"
|
||||
enter-to-class="opacity-100 scale-100"
|
||||
leave-active-class="transition duration-200 ease-in"
|
||||
leave-from-class="opacity-100 scale-100"
|
||||
leave-to-class="opacity-0 scale-95"
|
||||
>
|
||||
<div
|
||||
v-if="show"
|
||||
class="fixed inset-0 z-[2000] flex items-center justify-center p-4 sm:p-6"
|
||||
@click.self="close"
|
||||
>
|
||||
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm" />
|
||||
|
||||
<div
|
||||
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl flex flex-col max-h-[90vh] overflow-hidden"
|
||||
@click.stop
|
||||
>
|
||||
<!-- 头部 -->
|
||||
<div class="flex items-center justify-between p-8 pb-4">
|
||||
<div class="flex items-center gap-4">
|
||||
<div
|
||||
class="w-12 h-12 rounded-2xl bg-blue-600/10 flex items-center justify-center text-blue-500"
|
||||
>
|
||||
<Icon name="user" :size="24" />
|
||||
</div>
|
||||
<h3 class="text-xl font-black text-zinc-100 tracking-tight">{{ title }}</h3>
|
||||
</div>
|
||||
<button
|
||||
class="w-10 h-10 flex items-center justify-center rounded-xl bg-zinc-800/50 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100 transition-all"
|
||||
@click="close"
|
||||
>
|
||||
<Icon name="x" :size="20" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- 主体 -->
|
||||
<div class="flex-1 overflow-y-auto p-8 pt-4 custom-scrollbar">
|
||||
<!-- 搜索输入框 -->
|
||||
<div class="relative mb-6 group">
|
||||
<div class="absolute inset-y-0 left-4 flex items-center pointer-events-none">
|
||||
<Icon
|
||||
name="search"
|
||||
:size="20"
|
||||
class="text-zinc-500 group-focus-within:text-zinc-300 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
<input
|
||||
ref="searchInput"
|
||||
v-model="searchQuery"
|
||||
class="w-full pl-12 pr-12 py-4 bg-zinc-950 border border-zinc-800 rounded-2xl text-zinc-100 placeholder-zinc-600 focus:outline-none focus:border-blue-500/30 transition-all"
|
||||
placeholder="搜索用户名或姓名..."
|
||||
type="text"
|
||||
@input="handleSearch"
|
||||
@keyup.enter="performSearch"
|
||||
>
|
||||
<div v-if="loading" class="absolute inset-y-0 right-4 flex items-center">
|
||||
<Icon name="loader" :size="20" class="text-zinc-400 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 搜索结果 -->
|
||||
<div class="user-list space-y-3 min-h-[300px]">
|
||||
<div
|
||||
v-if="users.length === 0 && !loading && hasSearched"
|
||||
class="flex flex-col items-center justify-center py-12 text-zinc-500"
|
||||
>
|
||||
<div
|
||||
class="w-16 h-16 rounded-3xl bg-zinc-800/50 flex items-center justify-center mb-4"
|
||||
>
|
||||
<Icon name="search" :size="32" class="opacity-20" />
|
||||
</div>
|
||||
<p class="text-sm font-bold uppercase tracking-widest">未找到匹配的用户</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-for="user in users"
|
||||
:key="user.id"
|
||||
:class="[
|
||||
'group flex items-center p-4 rounded-2xl border transition-all cursor-pointer',
|
||||
isSelected(user)
|
||||
? 'bg-blue-600/10 border-blue-500/30 shadow-lg'
|
||||
: 'bg-zinc-950 border-transparent hover:border-zinc-800'
|
||||
]"
|
||||
@click="toggleSelection(user)"
|
||||
>
|
||||
<!-- 头像 -->
|
||||
<div
|
||||
:class="[
|
||||
'w-12 h-12 rounded-xl flex items-center justify-center text-lg font-black mr-4 transition-colors',
|
||||
isSelected(user) ? 'bg-blue-600 text-white' : 'bg-zinc-800 text-zinc-500'
|
||||
]"
|
||||
>
|
||||
{{ user.name.charAt(0) }}
|
||||
</div>
|
||||
|
||||
<!-- 用户信息 -->
|
||||
<div class="flex-1 min-w-0">
|
||||
<div class="font-bold text-zinc-100 truncate">
|
||||
{{ user.name }}
|
||||
</div>
|
||||
<div
|
||||
class="text-[10px] font-black uppercase tracking-widest flex flex-wrap gap-2 mt-0.5 text-zinc-500"
|
||||
>
|
||||
<span v-if="user.grade" class="flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
{{ user.grade }}
|
||||
</span>
|
||||
<span v-if="user.class" class="flex items-center">
|
||||
<span class="w-1 h-1 rounded-full bg-current mr-1.5 opacity-40" />
|
||||
{{ user.class }}
|
||||
</span>
|
||||
<span class="truncate">@{{ user.username }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 选中指示器 -->
|
||||
<div class="ml-4">
|
||||
<div
|
||||
:class="[
|
||||
'w-6 h-6 rounded-lg border-2 flex items-center justify-center transition-all',
|
||||
isSelected(user)
|
||||
? 'bg-blue-600 border-blue-600 text-white'
|
||||
: 'border-zinc-800 group-hover:border-zinc-700'
|
||||
]"
|
||||
>
|
||||
<Icon v-if="isSelected(user)" name="check" :size="14" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 底部操作栏 -->
|
||||
<div class="p-8 pt-0">
|
||||
<div class="flex items-center justify-between gap-4">
|
||||
<div class="text-[10px] font-black text-zinc-500 uppercase tracking-widest">
|
||||
<span v-if="selectedUsers.length > 0" class="text-blue-500">
|
||||
已选择 {{ selectedUsers.length }} 人
|
||||
</span>
|
||||
</div>
|
||||
<div class="flex gap-3">
|
||||
<button
|
||||
class="px-6 py-3 rounded-xl bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-black transition-all active:scale-95 uppercase tracking-widest"
|
||||
@click="close"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
:disabled="selectedUsers.length === 0"
|
||||
class="px-8 py-3 rounded-xl bg-blue-600 hover:bg-blue-500 text-white text-xs font-black disabled:opacity-50 transition-all active:scale-95 uppercase tracking-widest shadow-lg shadow-blue-900/20"
|
||||
@click="confirm"
|
||||
>
|
||||
确定
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, nextTick } from 'vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
|
||||
// 简单的防抖函数实现
|
||||
function useDebounceFn(fn, delay) {
|
||||
let timeoutId
|
||||
return (...args) => {
|
||||
if (timeoutId) clearTimeout(timeoutId)
|
||||
timeoutId = setTimeout(() => {
|
||||
fn(...args)
|
||||
}, delay)
|
||||
}
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
show: Boolean,
|
||||
title: {
|
||||
type: String,
|
||||
default: '搜索用户'
|
||||
},
|
||||
multiple: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
excludeIds: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:show', 'select'])
|
||||
|
||||
const searchQuery = ref('')
|
||||
const users = ref([])
|
||||
const loading = ref(false)
|
||||
const hasSearched = ref(false)
|
||||
const selectedUsers = ref([])
|
||||
const searchInput = ref(null)
|
||||
|
||||
const close = () => {
|
||||
emit('update:show', false)
|
||||
}
|
||||
|
||||
const isSelected = (user) => {
|
||||
return selectedUsers.value.some((u) => u.id === user.id)
|
||||
}
|
||||
|
||||
const toggleSelection = (user) => {
|
||||
if (props.multiple) {
|
||||
const index = selectedUsers.value.findIndex((u) => u.id === user.id)
|
||||
if (index === -1) {
|
||||
selectedUsers.value.push(user)
|
||||
} else {
|
||||
selectedUsers.value.splice(index, 1)
|
||||
}
|
||||
} else {
|
||||
selectedUsers.value = [user]
|
||||
}
|
||||
}
|
||||
|
||||
const confirm = () => {
|
||||
emit('select', props.multiple ? selectedUsers.value : selectedUsers.value[0])
|
||||
close()
|
||||
}
|
||||
|
||||
const performSearch = async () => {
|
||||
if (!searchQuery.value.trim()) return
|
||||
|
||||
loading.value = true
|
||||
hasSearched.value = true
|
||||
|
||||
try {
|
||||
const data = await $fetch('/api/users/search', {
|
||||
params: { keyword: searchQuery.value }
|
||||
})
|
||||
|
||||
if (data.success) {
|
||||
// 过滤掉排除的用户
|
||||
users.value = data.users.filter((u) => !props.excludeIds.includes(u.id))
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索用户失败', error)
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleSearch = useDebounceFn(() => {
|
||||
performSearch()
|
||||
}, 500)
|
||||
|
||||
watch(
|
||||
() => props.show,
|
||||
(newVal) => {
|
||||
if (newVal) {
|
||||
searchQuery.value = ''
|
||||
users.value = []
|
||||
selectedUsers.value = []
|
||||
hasSearched.value = false
|
||||
nextTick(() => {
|
||||
searchInput.value?.focus()
|
||||
})
|
||||
}
|
||||
}
|
||||
)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,231 @@
|
||||
<template>
|
||||
<div class="space-y-6">
|
||||
<div v-if="loading" class="flex flex-col items-center justify-center py-12">
|
||||
<Loader2 :size="24" class="text-blue-500 animate-spin mb-3" />
|
||||
<p class="text-zinc-500 text-xs font-medium">加载中...</p>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-else-if="error"
|
||||
class="p-4 bg-rose-500/10 border border-rose-500/20 rounded-xl flex items-center justify-between"
|
||||
>
|
||||
<div class="flex items-center gap-3">
|
||||
<AlertCircle :size="16" class="text-rose-500" />
|
||||
<span class="text-xs text-rose-500 font-medium">{{ error }}</span>
|
||||
</div>
|
||||
<button
|
||||
class="px-3 py-1 bg-rose-500 text-white text-[10px] font-black uppercase rounded-lg hover:bg-rose-400 transition-all"
|
||||
@click="fetchSettings"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else class="space-y-4">
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-zinc-200">歌曲被选中通知</h3>
|
||||
<p class="text-[11px] text-zinc-500 mt-0.5">当您投稿的歌曲被选中安排播放时通知您</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.songSelectedNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-zinc-200">歌曲已播放通知</h3>
|
||||
<p class="text-[11px] text-zinc-500 mt-0.5">当您投稿的歌曲被播放时通知您</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.songPlayedNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-zinc-200">歌曲获得投票通知</h3>
|
||||
<p class="text-[11px] text-zinc-500 mt-0.5">当您投稿的歌曲获得新投票时通知您</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.songVotedNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="localSettings.songVotedNotify"
|
||||
class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-3"
|
||||
>
|
||||
<div class="flex items-center justify-between">
|
||||
<h3 class="text-xs font-black text-zinc-500 uppercase tracking-widest">投票通知阈值</h3>
|
||||
<span class="text-xs font-bold text-blue-500"
|
||||
>每获得 {{ localSettings.songVotedThreshold }} 票通知一次</span
|
||||
>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="localSettings.songVotedThreshold"
|
||||
type="range"
|
||||
max="10"
|
||||
min="1"
|
||||
step="1"
|
||||
class="w-full h-1.5 bg-zinc-800 rounded-full appearance-none accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
<div class="flex justify-between text-[10px] font-black text-zinc-700">
|
||||
<span>1</span>
|
||||
<span>10</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div :class="itemClass">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-zinc-200">系统通知</h3>
|
||||
<p class="text-[11px] text-zinc-500 mt-0.5">接收系统公告和其他重要通知</p>
|
||||
</div>
|
||||
<div class="shrink-0">
|
||||
<input
|
||||
v-model="localSettings.systemNotify"
|
||||
type="checkbox"
|
||||
class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-3">
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex-1">
|
||||
<h3 class="text-sm font-bold text-zinc-200">通知刷新间隔</h3>
|
||||
<p class="text-[11px] text-zinc-500 mt-0.5">设置通知自动刷新的时间间隔</p>
|
||||
</div>
|
||||
<span class="text-xs font-bold text-blue-500">{{
|
||||
formatRefreshInterval(localSettings.refreshInterval)
|
||||
}}</span>
|
||||
</div>
|
||||
<input
|
||||
v-model.number="localSettings.refreshInterval"
|
||||
type="range"
|
||||
max="300"
|
||||
min="10"
|
||||
step="10"
|
||||
class="w-full h-1.5 bg-zinc-800 rounded-full appearance-none accent-blue-600 cursor-pointer"
|
||||
@change="saveSettings"
|
||||
>
|
||||
<div class="flex justify-between text-[10px] font-black text-zinc-700">
|
||||
<span>10s</span>
|
||||
<span>5m</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import { Loader2, AlertCircle } from 'lucide-vue-next'
|
||||
import { useNotifications } from '~/composables/useNotifications'
|
||||
|
||||
const notificationsService = useNotifications()
|
||||
const loading = computed(() => notificationsService.loading.value)
|
||||
const error = computed(() => notificationsService.error.value)
|
||||
const settings = computed(() => notificationsService.settings.value)
|
||||
|
||||
// 样式类
|
||||
const itemClass =
|
||||
'flex items-center justify-between p-4 bg-zinc-950/30 border border-zinc-900 rounded-2xl hover:bg-zinc-900/50 transition-all'
|
||||
|
||||
// 本地设置,用于双向绑定
|
||||
const localSettings = ref({
|
||||
songSelectedNotify: true,
|
||||
songPlayedNotify: true,
|
||||
songVotedNotify: true,
|
||||
songVotedThreshold: 1,
|
||||
systemNotify: true,
|
||||
refreshInterval: 60
|
||||
})
|
||||
|
||||
// 监听设置变化
|
||||
watch(
|
||||
settings,
|
||||
(newSettings) => {
|
||||
if (newSettings) {
|
||||
localSettings.value = {
|
||||
songSelectedNotify: newSettings.songSelectedNotify,
|
||||
songPlayedNotify: newSettings.songPlayedNotify,
|
||||
songVotedNotify: newSettings.songVotedNotify,
|
||||
songVotedThreshold: newSettings.songVotedThreshold || 1,
|
||||
systemNotify: newSettings.systemNotify,
|
||||
refreshInterval: newSettings.refreshInterval || 60
|
||||
}
|
||||
}
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
|
||||
// 初始化
|
||||
onMounted(async () => {
|
||||
await fetchSettings()
|
||||
})
|
||||
|
||||
// 获取设置
|
||||
const fetchSettings = async () => {
|
||||
await notificationsService.fetchNotificationSettings()
|
||||
}
|
||||
|
||||
// 保存设置
|
||||
const saveSettings = async () => {
|
||||
await notificationsService.updateNotificationSettings(localSettings.value)
|
||||
}
|
||||
|
||||
// 格式化刷新间隔
|
||||
const formatRefreshInterval = (seconds) => {
|
||||
if (seconds < 60) {
|
||||
return `${seconds}秒`
|
||||
} else {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = seconds % 60
|
||||
return remainingSeconds > 0 ? `${minutes}分${remainingSeconds}秒` : `${minutes}分钟`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
input[type='range']::-webkit-slider-thumb {
|
||||
-webkit-appearance: none;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #2563eb; /* blue-600 */
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input[type='range']::-moz-range-thumb {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: #2563eb;
|
||||
border-radius: 50%;
|
||||
cursor: pointer;
|
||||
border: none;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
input[type='range']::-webkit-slider-thumb:hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,205 @@
|
||||
<template>
|
||||
<div
|
||||
class="lyric-am"
|
||||
:class="{
|
||||
'align-right': settings.lyricAlignRight.value
|
||||
}"
|
||||
:style="{
|
||||
'--amll-lp-color': 'rgb(var(--main-cover-color, 239 239 239))',
|
||||
'--amll-lp-hover-bg-color': 'rgba(255,255,255,0.08)',
|
||||
'--amll-lyric-left-padding': settings.lyricAlignRight.value
|
||||
? ''
|
||||
: `${settings.lyricHorizontalOffset.value}px`,
|
||||
'--amll-lyric-right-padding': settings.lyricAlignRight.value
|
||||
? `${settings.lyricHorizontalOffset.value}px`
|
||||
: ''
|
||||
}"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="lyricManager.loading.value" class="lyric-message-container">
|
||||
<Icon name="loader" :size="32" class="spin-animation" />
|
||||
<div class="message-text">歌词加载中...</div>
|
||||
</div>
|
||||
<div v-else-if="lyricManager.error.value" class="lyric-message-container">
|
||||
<Icon name="alert-circle" :size="32" />
|
||||
<div class="message-text">{{ lyricManager.error.value }}</div>
|
||||
</div>
|
||||
<div v-else-if="!lyricLines || lyricLines.length === 0" class="lyric-message-container">
|
||||
<Icon name="music" :size="48" style="opacity: 0.5" />
|
||||
<div class="message-text">暂无歌词</div>
|
||||
</div>
|
||||
<LyricPlayer
|
||||
v-else
|
||||
ref="lyricPlayerRef"
|
||||
:lyric-lines="lyricLines"
|
||||
:current-time="currentTime * 1000"
|
||||
:playing="audioPlayer.getPlayingStatus().value"
|
||||
:enable-spring="settings.useAMSpring.value"
|
||||
:enable-scale="settings.useAMSpring.value"
|
||||
:align-position="settings.lyricsScrollOffset.value"
|
||||
:align-anchor="settings.lyricsScrollOffset.value > 0.4 ? 'center' : 'top'"
|
||||
:enable-blur="settings.lyricsBlur.value"
|
||||
:hide-passed-lines="settings.hidePassedLines.value"
|
||||
:word-fade-width="settings.wordFadeWidth.value"
|
||||
:style="{
|
||||
'--display-count-down-show': settings.countDownShow.value ? 'flex' : 'none',
|
||||
'--amll-lp-font-size': `${settings.lyricFontSize.value}px`,
|
||||
'font-weight': settings.lyricFontWeight.value,
|
||||
'font-family': settings.LyricFont.value !== 'follow' ? settings.LyricFont.value : ''
|
||||
}"
|
||||
class="am-lyric"
|
||||
@line-click="jumpSeek"
|
||||
/>
|
||||
</Transition>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from 'vue'
|
||||
import LyricPlayer, { type LyricPlayerRef } from '~/components/AMLL/LyricPlayer.vue'
|
||||
import Icon from '~/components/UI/Icon.vue'
|
||||
import { useLyricManager } from '~/composables/useLyricManager'
|
||||
import { useLyricSettings } from '~/composables/useLyricSettings'
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
|
||||
import type { LyricLineMouseEvent } from '@applemusic-like-lyrics/core'
|
||||
import { cloneDeep } from 'lodash-es'
|
||||
|
||||
const props = defineProps({
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const lyricManager = useLyricManager()
|
||||
const settings = useLyricSettings()
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const audioPlayerControl = useAudioPlayerControl()
|
||||
|
||||
const lyricPlayerRef = ref<LyricPlayerRef | null>(null)
|
||||
|
||||
const lyricLines = computed(() => {
|
||||
// 使用 cloneDeep 剥离 Vue 响应式代理,防止 structuredClone 错误
|
||||
return cloneDeep(lyricManager.lyrics.value)
|
||||
})
|
||||
|
||||
// 进度跳转
|
||||
const jumpSeek = (line: LyricLineMouseEvent) => {
|
||||
// 移除焦点,防止组件内部焦点导致键盘事件异常
|
||||
if (document.activeElement instanceof HTMLElement) {
|
||||
document.activeElement.blur()
|
||||
}
|
||||
|
||||
const lineContent = line.line.getLine()
|
||||
if (!lineContent?.startTime) return
|
||||
const time = lineContent.startTime
|
||||
const offsetMs = settings.lyricOffset.value
|
||||
const targetTime = (time - offsetMs) / 1000
|
||||
|
||||
audioPlayerControl.seekAndPlay(targetTime)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.lyric-am {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.am-lyric {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
padding-left: var(--amll-lyric-left-padding, 10px);
|
||||
padding-right: 80px;
|
||||
}
|
||||
|
||||
/* 移动端适配 */
|
||||
@media (max-width: 990px) {
|
||||
.am-lyric {
|
||||
padding: 0;
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.lyric-am.align-right .am-lyric {
|
||||
padding-left: 80px;
|
||||
padding-right: var(--amll-lyric-right-padding, 10px);
|
||||
}
|
||||
|
||||
@media (max-width: 990px) {
|
||||
.lyric-am.align-right .am-lyric {
|
||||
padding: 0;
|
||||
margin-right: -20px;
|
||||
}
|
||||
}
|
||||
|
||||
/* 高亮效果 */
|
||||
:deep(.am-lyric .current),
|
||||
:deep(.am-lyric .is-current),
|
||||
:deep(.am-lyric .active),
|
||||
:deep(.am-lyric .is-active),
|
||||
:deep(.am-lyric .lyric-line.current),
|
||||
:deep(.am-lyric .lyric-line.is-current) {
|
||||
mix-blend-mode: plus-lighter;
|
||||
color: rgba(255, 255, 255, 0.95);
|
||||
text-shadow: 0 2px 12px rgba(255, 255, 255, 0.06);
|
||||
will-change: transform, opacity, color;
|
||||
}
|
||||
|
||||
@supports not (mix-blend-mode: plus-lighter) {
|
||||
:deep(.am-lyric .current),
|
||||
:deep(.am-lyric .is-current),
|
||||
:deep(.am-lyric .active),
|
||||
:deep(.am-lyric .is-active),
|
||||
:deep(.am-lyric .lyric-line.current),
|
||||
:deep(.am-lyric .lyric-line.is-current) {
|
||||
color: #ffffff;
|
||||
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||
}
|
||||
}
|
||||
|
||||
:deep(.am-lyric div[class*='lyricMainLine'] span) {
|
||||
text-align: start;
|
||||
}
|
||||
|
||||
.lyric-message-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--amll-lp-color, #efefef);
|
||||
gap: 16px;
|
||||
opacity: 0.8;
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.message-text {
|
||||
font-size: 18px;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.5px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.spin-animation {
|
||||
animation: spin 1s linear infinite;
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,829 @@
|
||||
<template>
|
||||
<div
|
||||
:style="{
|
||||
'--lrc-size': getFontSize(settings.lyricFontSize.value, settings.lyricFontSizeMode.value),
|
||||
'--lrc-tran-size': getFontSize(
|
||||
settings.lyricTranFontSize.value,
|
||||
settings.lyricFontSizeMode.value
|
||||
),
|
||||
'--lrc-roma-size': getFontSize(
|
||||
settings.lyricRomaFontSize.value,
|
||||
settings.lyricFontSizeMode.value
|
||||
),
|
||||
'--lrc-bold': settings.lyricFontWeight.value,
|
||||
'--lrc-left-padding': `${settings.lyricHorizontalOffset.value}px`,
|
||||
'font-family': settings.LyricFont.value !== 'follow' ? settings.LyricFont.value : '',
|
||||
cursor: 'auto'
|
||||
}"
|
||||
:class="[
|
||||
'lyric',
|
||||
'normal',
|
||||
{
|
||||
'align-right': settings.lyricAlignRight.value
|
||||
}
|
||||
]"
|
||||
@mouseleave="lrcAllLeave"
|
||||
>
|
||||
<div class="lyric-content">
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="lyricManager.loading.value" class="lyric-loading">歌词正在加载中...</div>
|
||||
<div v-else-if="!currentLyricData || currentLyricData.length === 0" class="lyric-loading">
|
||||
暂无歌词
|
||||
</div>
|
||||
<div
|
||||
v-else
|
||||
ref="lyricScrollContainer"
|
||||
class="lyric-scroll-container"
|
||||
tabindex="-1"
|
||||
@wheel="handleUserScroll"
|
||||
@touchstart="handleUserScroll"
|
||||
>
|
||||
<!-- 顶部占位 -->
|
||||
<div id="lrc-placeholder" class="placeholder" />
|
||||
<!-- 预处理后的歌词列表 -->
|
||||
<template v-for="(item, index) in processedLyrics" :key="`item-${index}`">
|
||||
<!-- 倒计时行 -->
|
||||
<div
|
||||
v-if="item.type === 'countdown'"
|
||||
class="countdown-line"
|
||||
:style="{
|
||||
animationPlayState: isPlaying ? 'running' : 'paused'
|
||||
}"
|
||||
>
|
||||
<Transition name="fade" mode="out-in">
|
||||
<div v-if="isCountdownVisible(item)" class="count-down">
|
||||
<div
|
||||
v-for="i in 3"
|
||||
:key="i"
|
||||
:style="{ opacity: getPointOpacity(item, i - 1) }"
|
||||
class="point"
|
||||
/>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
<!-- 歌词行 -->
|
||||
<div
|
||||
v-else
|
||||
:id="`lrc-${index}`"
|
||||
:class="getLyricLineClass(item, index)"
|
||||
:style="getLyricLineStyle(item, index)"
|
||||
@click="jumpSeek(item.data.startTime)"
|
||||
>
|
||||
<!-- 逐字歌词 -->
|
||||
<template v-if="isYrcMode">
|
||||
<div class="content">
|
||||
<div
|
||||
v-for="(text, textIndex) in item.data.words"
|
||||
:key="textIndex"
|
||||
:class="{
|
||||
'content-text': true,
|
||||
'end-with-space': text.word.endsWith(' ') || text.startTime === 0
|
||||
}"
|
||||
:style="getYrcVars(text, index)"
|
||||
>
|
||||
<span class="yrc-word" :lang="getLyricLanguage(text.word)">
|
||||
{{ text.word }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 普通歌词 -->
|
||||
<template v-else>
|
||||
<span class="content" :lang="getLyricLanguage(item.data.words?.[0]?.word)">
|
||||
{{ item.data.words.map((w) => w.word).join('') }}
|
||||
</span>
|
||||
</template>
|
||||
<!-- 翻译和音译 -->
|
||||
<template v-if="settings.swapTranRoma.value">
|
||||
<!-- 音译在前 -->
|
||||
<span v-if="item.data.romanLyric && settings.showRoma.value" class="roma" lang="en">
|
||||
{{ item.data.romanLyric }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.data.translatedLyric && settings.showTranslation.value"
|
||||
class="tran"
|
||||
lang="en"
|
||||
>
|
||||
{{ item.data.translatedLyric }}
|
||||
</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<!-- 翻译在前(默认) -->
|
||||
<span
|
||||
v-if="item.data.translatedLyric && settings.showTranslation.value"
|
||||
class="tran"
|
||||
lang="en"
|
||||
>
|
||||
{{ item.data.translatedLyric }}
|
||||
</span>
|
||||
<span v-if="item.data.romanLyric && settings.showRoma.value" class="roma" lang="en">
|
||||
{{ item.data.romanLyric }}
|
||||
</span>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
<!-- 底部占位 -->
|
||||
<div class="placeholder" />
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, watch, onMounted, onBeforeUnmount, nextTick } from 'vue'
|
||||
import type { LyricWord, LyricLine } from '@applemusic-like-lyrics/lyric'
|
||||
import { useLyricManager } from '~/composables/useLyricManager'
|
||||
import { useLyricSettings } from '~/composables/useLyricSettings'
|
||||
import { useAudioPlayer } from '~/composables/useAudioPlayer'
|
||||
import { useAudioPlayerControl } from '~/composables/useAudioPlayerControl'
|
||||
|
||||
const props = defineProps({
|
||||
currentTime: {
|
||||
type: Number,
|
||||
default: 0
|
||||
}
|
||||
})
|
||||
|
||||
const lyricManager = useLyricManager()
|
||||
const settings = useLyricSettings()
|
||||
const audioPlayer = useAudioPlayer()
|
||||
const audioPlayerControl = useAudioPlayerControl()
|
||||
|
||||
const lyricScrollContainer = ref<HTMLElement | null>(null)
|
||||
|
||||
// 是否为逐字歌词模式
|
||||
const isYrcMode = computed(() => {
|
||||
const format = lyricManager.lyricFormat.value
|
||||
return (
|
||||
settings.showYrc.value &&
|
||||
(format === 'qrc' || format === 'word-by-word' || format === 'enhanced')
|
||||
)
|
||||
})
|
||||
|
||||
// 获取当前使用的歌词数据
|
||||
const currentLyricData = computed(() => lyricManager.lyrics.value)
|
||||
|
||||
const isPlaying = computed(() => audioPlayer.getPlayingStatus().value)
|
||||
|
||||
/** 处理后的歌词项类型 */
|
||||
type ProcessedLyricItem =
|
||||
| { type: 'lyric'; originalIndex: number; data: LyricLine }
|
||||
| { type: 'countdown'; startTime: number; duration: number }
|
||||
|
||||
/** 间奏倒计时最小时长(毫秒) */
|
||||
const COUNTDOWN_THRESHOLD = 10000
|
||||
/** 开头倒计时最小时长(毫秒) */
|
||||
const INTRO_THRESHOLD = 3000
|
||||
|
||||
/** 预处理歌词数据 */
|
||||
const processedLyrics = computed<ProcessedLyricItem[]>(() => {
|
||||
const lyrics = currentLyricData.value
|
||||
if (!lyrics || lyrics.length === 0) return []
|
||||
const result: ProcessedLyricItem[] = []
|
||||
// 检查开头是否需要倒计时
|
||||
const firstLyricStart = lyrics[0]?.startTime || 0
|
||||
if (settings.countDownShow.value && firstLyricStart >= INTRO_THRESHOLD) {
|
||||
result.push({
|
||||
type: 'countdown',
|
||||
startTime: 0,
|
||||
duration: firstLyricStart
|
||||
})
|
||||
}
|
||||
// 遍历歌词
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
const item = lyrics[i]
|
||||
result.push({
|
||||
type: 'lyric',
|
||||
originalIndex: i,
|
||||
data: item
|
||||
})
|
||||
// 检查是否需要插入倒计时
|
||||
if (settings.countDownShow.value && i < lyrics.length - 1) {
|
||||
const currentEnd = item.endTime
|
||||
const nextStart = lyrics[i + 1]?.startTime || 0
|
||||
const gap = nextStart - currentEnd
|
||||
if (gap >= COUNTDOWN_THRESHOLD) {
|
||||
result.push({
|
||||
type: 'countdown',
|
||||
startTime: currentEnd,
|
||||
duration: gap
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
})
|
||||
|
||||
/** 倒计时是否可见 */
|
||||
const isCountdownVisible = (item: ProcessedLyricItem): boolean => {
|
||||
if (item.type !== 'countdown') return false
|
||||
// 计算实时时间 - 0.5s 是否小于开始 + 持续时间
|
||||
// currentTime 是秒,转换为毫秒
|
||||
return props.currentTime * 1000 + 500 < item.startTime + item.duration
|
||||
}
|
||||
|
||||
/** 计算当前活跃的歌词行索引列表 */
|
||||
const activeLineIndices = computed<number[]>(() => {
|
||||
const lyrics = processedLyrics.value
|
||||
if (!lyrics || lyrics.length === 0) return []
|
||||
const currentSeek = props.currentTime * 1000 // 秒转毫秒
|
||||
const activeCandidates: number[] = []
|
||||
|
||||
for (let i = 0; i < lyrics.length; i++) {
|
||||
const item = lyrics[i]
|
||||
let start = 0
|
||||
let end = Infinity
|
||||
if (item.type === 'lyric') {
|
||||
start = item.data.startTime || 0
|
||||
end = item.data.endTime ?? Infinity
|
||||
} else {
|
||||
start = item.startTime
|
||||
end = item.startTime + item.duration
|
||||
}
|
||||
|
||||
if (currentSeek >= start && currentSeek < end) {
|
||||
activeCandidates.push(i)
|
||||
}
|
||||
}
|
||||
// 如果没有活跃行,找最近的上一行
|
||||
if (activeCandidates.length === 0 && currentSeek > 0) {
|
||||
// 找到第一个开始时间大于当前时间的行
|
||||
const nextIndex = lyrics.findIndex((item) => {
|
||||
const start = item.type === 'lyric' ? item.data.startTime : item.startTime
|
||||
return (start || 0) > currentSeek
|
||||
})
|
||||
if (nextIndex === -1) return [lyrics.length - 1] // 都在后面,取最后一行
|
||||
if (nextIndex > 0) return [nextIndex - 1] // 取前一行
|
||||
}
|
||||
return activeCandidates
|
||||
})
|
||||
|
||||
/** 首个高亮行索引 */
|
||||
const firstActiveIndex = computed(() => {
|
||||
return activeLineIndices.value[0] ?? -1
|
||||
})
|
||||
|
||||
/** 判断某行是否高亮 */
|
||||
const isLineActive = (index: number): boolean => {
|
||||
return activeLineIndices.value.includes(index)
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算倒计时圆点透明度
|
||||
*/
|
||||
const getPointOpacity = (item: ProcessedLyricItem, index: number): number => {
|
||||
if (item.type !== 'countdown') return 0
|
||||
const perPointTime = item.duration / 3
|
||||
const currentTime = props.currentTime * 1000 - item.startTime
|
||||
if (currentTime <= 0) return 0
|
||||
if (currentTime < perPointTime * (index + 1)) {
|
||||
const percentage = (currentTime - perPointTime * index) / perPointTime
|
||||
return 0.1 + 0.7 * (1 - percentage)
|
||||
}
|
||||
return 0.1
|
||||
}
|
||||
|
||||
/** 当前滚动动画 ID */
|
||||
let scrollAnimationId: number | null = null
|
||||
/** 用户是否正在滚动 */
|
||||
const userScrolling = ref(false)
|
||||
/** 用户滚动恢复超时 ID */
|
||||
let userScrollTimeoutId: ReturnType<typeof setTimeout> | null = null
|
||||
/** 用户滚动恢复超时时间(毫秒) */
|
||||
const USER_SCROLL_TIMEOUT = 3000
|
||||
|
||||
/**
|
||||
* 自主滚动
|
||||
*/
|
||||
const handleUserScroll = () => {
|
||||
userScrolling.value = true
|
||||
if (userScrollTimeoutId !== null) {
|
||||
clearTimeout(userScrollTimeoutId)
|
||||
}
|
||||
// 超时后恢复
|
||||
userScrollTimeoutId = setTimeout(() => {
|
||||
userScrolling.value = false
|
||||
userScrollTimeoutId = null
|
||||
lyricsScroll(firstActiveIndex.value)
|
||||
}, USER_SCROLL_TIMEOUT)
|
||||
}
|
||||
|
||||
/**
|
||||
* 平滑滚动
|
||||
*/
|
||||
const smoothScrollTo = (container: HTMLElement, targetY: number, duration = 300) => {
|
||||
// 取消之前的动画
|
||||
if (scrollAnimationId !== null) {
|
||||
cancelAnimationFrame(scrollAnimationId)
|
||||
scrollAnimationId = null
|
||||
}
|
||||
// 计算起始位置和目标位置
|
||||
const startY = container.scrollTop
|
||||
const diff = targetY - startY
|
||||
// 如果差值很小,直接设置
|
||||
if (Math.abs(diff) < 0.5) {
|
||||
container.scrollTop = targetY
|
||||
return
|
||||
}
|
||||
const startTime = performance.now()
|
||||
const step = (currentTime: number) => {
|
||||
const elapsed = currentTime - startTime
|
||||
const progress = Math.min(elapsed / duration, 1)
|
||||
// easeInOutQuad 缓动
|
||||
const easedProgress =
|
||||
progress < 0.5 ? 2 * progress * progress : 1 - Math.pow(-2 * progress + 2, 2) / 2
|
||||
container.scrollTop = startY + diff * easedProgress
|
||||
if (progress < 1) {
|
||||
scrollAnimationId = requestAnimationFrame(step)
|
||||
} else {
|
||||
scrollAnimationId = null
|
||||
}
|
||||
}
|
||||
scrollAnimationId = requestAnimationFrame(step)
|
||||
}
|
||||
|
||||
/**
|
||||
* 歌词滚动
|
||||
*/
|
||||
const lyricsScroll = (index: number) => {
|
||||
const container = lyricScrollContainer.value
|
||||
if (!container) return
|
||||
// 用户滚动时不自动滚动
|
||||
if (userScrolling.value) return
|
||||
const lrcItemDom = document.getElementById(index >= 0 ? `lrc-${index}` : 'lrc-placeholder')
|
||||
if (!lrcItemDom) return
|
||||
// 计算目标滚动位置
|
||||
const containerHeight = container.clientHeight
|
||||
const elementTop = lrcItemDom.offsetTop
|
||||
const elementHeight = lrcItemDom.offsetHeight
|
||||
// 居中偏移滚动
|
||||
let targetY = elementTop - (containerHeight - elementHeight) * settings.lyricsScrollOffset.value
|
||||
// 确保不超出边界
|
||||
targetY = Math.max(0, Math.min(targetY, container.scrollHeight - container.clientHeight))
|
||||
// 执行平滑滚动
|
||||
smoothScrollTo(container, targetY, 500)
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标移出歌词区域恢复自动滚动
|
||||
*/
|
||||
const lrcAllLeave = () => {
|
||||
userScrolling.value = false
|
||||
if (userScrollTimeoutId !== null) {
|
||||
clearTimeout(userScrollTimeoutId)
|
||||
userScrollTimeoutId = null
|
||||
}
|
||||
lyricsScroll(firstActiveIndex.value)
|
||||
}
|
||||
|
||||
type CssVars = Record<`--${string}`, string>
|
||||
|
||||
/** 逐字歌词淡入淡出因子 */
|
||||
const YRC_DIM_ALPHA = 0.3
|
||||
/** 逐字歌词淡入淡出时间 */
|
||||
const YRC_LINE_FADE_MS = 250
|
||||
/** 淡入淡出行索引 */
|
||||
const yrcFadingLineIndex = ref<number | null>(null)
|
||||
/** 淡入淡出结束时间 */
|
||||
const yrcFadingUntilAt = ref<number>(0)
|
||||
|
||||
const getYrcFadeFactor = (index: number): number => {
|
||||
if (yrcFadingLineIndex.value !== index) return 1
|
||||
const now = Date.now()
|
||||
if (now >= yrcFadingUntilAt.value) return 1
|
||||
const remain = yrcFadingUntilAt.value - now
|
||||
return Math.min(Math.max(remain / YRC_LINE_FADE_MS, 0), 1)
|
||||
}
|
||||
|
||||
const getYrcVars = (wordData: LyricWord, lyricIndex: number): CssVars => {
|
||||
const currentSeek = props.currentTime * 1000
|
||||
const fadeFactor = getYrcFadeFactor(lyricIndex)
|
||||
// 判断是否显示
|
||||
const currentLineItem = processedLyrics.value[lyricIndex]
|
||||
if (!currentLineItem || currentLineItem.type !== 'lyric') return {}
|
||||
|
||||
if (!isYrcLineOn(lyricIndex)) return {}
|
||||
// 计算进度
|
||||
const duration = wordData.endTime - wordData.startTime
|
||||
const safeDuration = Math.max(duration, 1)
|
||||
const rawProgress = (currentSeek - wordData.startTime) / safeDuration
|
||||
const progress = Math.min(Math.max(rawProgress, 0), 1)
|
||||
const maskX = `${(1 - Math.min(progress, 1)) * 100}%`
|
||||
// 计算透明度
|
||||
const hasStarted = currentSeek >= wordData.startTime
|
||||
const brightAlpha = hasStarted ? YRC_DIM_ALPHA + (1 - YRC_DIM_ALPHA) * fadeFactor : YRC_DIM_ALPHA
|
||||
const darkAlpha = YRC_DIM_ALPHA
|
||||
|
||||
let transform = 'scale(1)'
|
||||
if (progress > 0) {
|
||||
const scale = 1 + 0.08 * progress
|
||||
const y = -2 * progress
|
||||
transform = `scale(${scale}) translateY(${y}px)`
|
||||
}
|
||||
|
||||
return {
|
||||
'--yrc-mask-x': maskX,
|
||||
'--yrc-opacity': '1',
|
||||
'--yrc-bright-alpha': `${brightAlpha}`,
|
||||
'--yrc-dark-alpha': `${darkAlpha}`,
|
||||
'--yrc-transform': transform
|
||||
}
|
||||
}
|
||||
|
||||
const isYrcLineOn = (index: number): boolean => {
|
||||
const isActive = isLineActive(index)
|
||||
const isFading = yrcFadingLineIndex.value === index && Date.now() < yrcFadingUntilAt.value
|
||||
return isActive || isFading
|
||||
}
|
||||
|
||||
const getLyricLineClass = (item: ProcessedLyricItem, index: number) => {
|
||||
if (item.type !== 'lyric') return []
|
||||
const lyricData = item.data
|
||||
const isOn = isYrcMode.value ? isYrcLineOn(index) : isLineActive(index)
|
||||
return [
|
||||
'lrc-line',
|
||||
isYrcMode.value ? 'is-yrc' : 'is-lrc',
|
||||
{
|
||||
on: isOn,
|
||||
'is-bg': lyricData.isBG,
|
||||
'is-duet': lyricData.isDuet
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
const getLyricLineStyle = (item: ProcessedLyricItem, index: number) => {
|
||||
if (item.type !== 'lyric') return {}
|
||||
|
||||
// 如果用户正在滚动,或者模糊设置关闭,则不模糊
|
||||
if (!settings.lyricsBlur.value || userScrolling.value) return { filter: 'blur(0)' }
|
||||
|
||||
const activeIdx = firstActiveIndex.value
|
||||
const isOn = isLineActive(index)
|
||||
return {
|
||||
filter: isOn ? 'blur(0)' : `blur(${Math.min(Math.abs(activeIdx - index) * 1.8, 10)}px)`
|
||||
}
|
||||
}
|
||||
|
||||
const jumpSeek = (time: number) => {
|
||||
if (!time) return
|
||||
userScrolling.value = false
|
||||
if (userScrollTimeoutId !== null) {
|
||||
clearTimeout(userScrollTimeoutId)
|
||||
userScrollTimeoutId = null
|
||||
}
|
||||
|
||||
const offsetMs = settings.lyricOffset.value
|
||||
const targetTime = (time - offsetMs) / 1000
|
||||
|
||||
audioPlayerControl.seekAndPlay(targetTime)
|
||||
}
|
||||
|
||||
// Utils
|
||||
const getFontSize = (size: number, mode: string) => {
|
||||
if (mode === 'adaptive') {
|
||||
return `calc(${size} / 1080 * 100vh)`
|
||||
}
|
||||
return `${size}px`
|
||||
}
|
||||
|
||||
const getLyricLanguage = (lyric: string): 'ja' | 'ko' | 'zh-CN' | 'en' => {
|
||||
if (!lyric || typeof lyric !== 'string') return 'en'
|
||||
if (/[\u3040-\u309F\u30A0-\u30FF]/.test(lyric)) return 'ja'
|
||||
if (/[\uAC00-\uD7AF]/.test(lyric)) return 'ko'
|
||||
if (/[\u4E00-\u9FFF]/.test(lyric)) return 'zh-CN'
|
||||
return 'en'
|
||||
}
|
||||
|
||||
// Watchers
|
||||
watch(firstActiveIndex, (val, oldVal) => {
|
||||
lyricsScroll(val)
|
||||
if (typeof oldVal === 'number' && oldVal >= 0 && oldVal !== val) {
|
||||
yrcFadingLineIndex.value = oldVal
|
||||
yrcFadingUntilAt.value = Date.now() + YRC_LINE_FADE_MS
|
||||
}
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
nextTick().then(() => {
|
||||
lyricsScroll(firstActiveIndex.value)
|
||||
})
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (scrollAnimationId !== null) {
|
||||
cancelAnimationFrame(scrollAnimationId)
|
||||
scrollAnimationId = null
|
||||
}
|
||||
if (userScrollTimeoutId !== null) {
|
||||
clearTimeout(userScrollTimeoutId)
|
||||
userScrollTimeoutId = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.lyric {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
align-items: flex-start;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
.lyric-scroll-container {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
padding-left: var(--lrc-left-padding, 10px);
|
||||
padding-right: 80px;
|
||||
box-sizing: border-box;
|
||||
/* 隐藏滚动条 */
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
@media (max-width: 990px) {
|
||||
padding-right: 20px;
|
||||
}
|
||||
}
|
||||
.placeholder {
|
||||
width: 100%;
|
||||
&:first-child {
|
||||
height: 300px;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
}
|
||||
&:last-child {
|
||||
height: 0;
|
||||
padding-top: 100%;
|
||||
}
|
||||
}
|
||||
.lyric-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* 倒计时行 */
|
||||
.countdown-line {
|
||||
margin: 6px 0;
|
||||
padding: 10px 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-height: 48px;
|
||||
.count-down {
|
||||
display: flex;
|
||||
flex-direction: row-reverse;
|
||||
align-items: center;
|
||||
animation: breathe 4s ease-in-out infinite;
|
||||
.point {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
margin-right: 12px;
|
||||
border-radius: 50%;
|
||||
background-color: rgb(var(--main-cover-color));
|
||||
transition: opacity 0.5s;
|
||||
@media (max-width: 900px) {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
}
|
||||
@media (max-width: 700px) {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lrc-line {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 6px 0;
|
||||
padding: 10px 16px;
|
||||
transform: scale(0.95);
|
||||
transform-origin: left center;
|
||||
will-change: filter, opacity, transform;
|
||||
transition:
|
||||
filter 0.35s,
|
||||
opacity 0.35s,
|
||||
transform 0.5s cubic-bezier(0.25, 0.1, 0.25, 1);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
.content {
|
||||
display: block;
|
||||
font-size: var(--lrc-size);
|
||||
font-weight: var(--lrc-bold);
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
hyphens: auto;
|
||||
.content-text {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
overflow: visible;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
.yrc-word {
|
||||
display: inline-block;
|
||||
box-sizing: border-box;
|
||||
padding-block: 0.2em;
|
||||
margin-block: -0.2em;
|
||||
opacity: var(--yrc-opacity, 0.3);
|
||||
}
|
||||
&.end-with-space {
|
||||
margin-right: 12px;
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.tran {
|
||||
margin-top: 8px;
|
||||
opacity: 0.6;
|
||||
font-size: var(--lrc-tran-size);
|
||||
transition: opacity 0.35s;
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
hyphens: auto;
|
||||
}
|
||||
.roma {
|
||||
margin-top: 4px;
|
||||
opacity: 0.5;
|
||||
font-size: var(--lrc-roma-size);
|
||||
transition: opacity 0.35s;
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
hyphens: auto;
|
||||
}
|
||||
&.is-lrc {
|
||||
opacity: 0.3;
|
||||
}
|
||||
&.is-yrc {
|
||||
.content {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
width: 100%;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
white-space: normal;
|
||||
}
|
||||
.tran,
|
||||
.roma {
|
||||
opacity: 0.3;
|
||||
}
|
||||
&.is-bg {
|
||||
opacity: 0.4;
|
||||
transform: scale(0.7);
|
||||
padding: 0px 20px;
|
||||
}
|
||||
&.is-duet {
|
||||
transform-origin: right;
|
||||
.content,
|
||||
.tran,
|
||||
.roma {
|
||||
text-align: right;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.on {
|
||||
opacity: 1 !important;
|
||||
transform: scale(1);
|
||||
.tran,
|
||||
.roma {
|
||||
opacity: 0.6;
|
||||
}
|
||||
&.is-bg {
|
||||
opacity: 0.85 !important;
|
||||
}
|
||||
}
|
||||
&::before {
|
||||
content: '';
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 0px;
|
||||
top: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
background-color: rgba(var(--main-cover-color), 0.14);
|
||||
opacity: 0;
|
||||
z-index: 0;
|
||||
transform: scale(1.05);
|
||||
transition:
|
||||
transform 0.35s ease,
|
||||
opacity 0.35s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@media (hover: hover) and (pointer: fine) {
|
||||
.lrc-line:hover {
|
||||
opacity: 1;
|
||||
&::before {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.lrc-line:active {
|
||||
&::before {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
}
|
||||
}
|
||||
&.flex-end,
|
||||
&.align-right {
|
||||
span {
|
||||
text-align: right;
|
||||
}
|
||||
.placeholder {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.lrc-line {
|
||||
transform-origin: right;
|
||||
.content {
|
||||
text-align: right;
|
||||
}
|
||||
&.is-duet {
|
||||
transform-origin: left;
|
||||
.content,
|
||||
.tran,
|
||||
.roma {
|
||||
text-align: left;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
}
|
||||
.countdown-line {
|
||||
justify-content: flex-end;
|
||||
.count-down {
|
||||
flex-direction: row;
|
||||
}
|
||||
}
|
||||
}
|
||||
&.center,
|
||||
&.pure {
|
||||
span {
|
||||
text-align: center !important;
|
||||
}
|
||||
.placeholder {
|
||||
justify-content: center !important;
|
||||
}
|
||||
.lrc-line {
|
||||
transform-origin: center !important;
|
||||
.content {
|
||||
text-align: center !important;
|
||||
justify-content: center !important;
|
||||
}
|
||||
}
|
||||
.countdown-line {
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
/* 逐字歌词:激活行启用 mask 动画 */
|
||||
.lrc-line.is-yrc.on {
|
||||
.content-text {
|
||||
.yrc-word {
|
||||
will-change: -webkit-mask-position-x;
|
||||
mask-image: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, var(--yrc-bright-alpha, 1)) 45.4545454545%,
|
||||
rgba(0, 0, 0, var(--yrc-dark-alpha, 0.3)) 54.5454545455%
|
||||
);
|
||||
mask-size: 220% 100%;
|
||||
mask-repeat: no-repeat;
|
||||
-webkit-mask-image: linear-gradient(
|
||||
to right,
|
||||
rgba(0, 0, 0, var(--yrc-bright-alpha, 1)) 45.4545454545%,
|
||||
rgba(0, 0, 0, var(--yrc-dark-alpha, 0.3)) 54.5454545455%
|
||||
);
|
||||
-webkit-mask-size: 220% 100%;
|
||||
-webkit-mask-repeat: no-repeat;
|
||||
-webkit-mask-position-x: var(--yrc-mask-x, 0%);
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.lyric-loading {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 22px;
|
||||
color: #fff;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="site-footer">
|
||||
<div class="footer-info">
|
||||
<span v-if="icpNumber" class="footer-item">
|
||||
<a
|
||||
:href="`https://beian.miit.gov.cn/`"
|
||||
class="icp-link"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ icpNumber }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="gonganNumber" class="footer-item">
|
||||
<a
|
||||
:href="gonganLink || 'https://beian.mps.gov.cn/'"
|
||||
class="icp-link"
|
||||
rel="noopener noreferrer"
|
||||
target="_blank"
|
||||
>
|
||||
{{ gonganNumber }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="siteTitle" class="footer-item">© {{ currentYear }} {{ siteTitle }}</span>
|
||||
<span v-else class="footer-item">© {{ currentYear }} {{ copyrightOwner }}</span>
|
||||
<span class="footer-item">Worker in {{ responseTime }}ms</span>
|
||||
<span class="footer-item">
|
||||
<a class="voicehub-link" :href="repoUrl" rel="noopener noreferrer" target="_blank">
|
||||
{{ systemName }} v{{ systemVersion }}
|
||||
</a>
|
||||
</span>
|
||||
<span v-if="isNetlify" class="footer-item">
|
||||
<a
|
||||
href="https://www.netlify.com"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="netlify-badge"
|
||||
>
|
||||
<img
|
||||
src="https://www.netlify.com/img/global/badges/netlify-color-accent.svg"
|
||||
alt="Deploys by Netlify"
|
||||
>
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { getCopyrightOwner, getSystemName, getRepoUrl } from '@/utils/core/security'
|
||||
import packageJson from '~~/package.json'
|
||||
|
||||
// 使用 useSiteConfig composable 获取配置
|
||||
const { siteTitle, icp: icpNumber, gonganNumber } = useSiteConfig()
|
||||
const config = useRuntimeConfig()
|
||||
|
||||
// 自动生成公安联网备案链接
|
||||
const gonganLink = computed(() => {
|
||||
if (!gonganNumber.value) return '#'
|
||||
// 提取数字部分
|
||||
const codeMatch = gonganNumber.value.match(/\d+/)
|
||||
const code = codeMatch ? codeMatch[0] : ''
|
||||
if (!code) return '#'
|
||||
return `https://beian.mps.gov.cn/#/query/webSearch?code=${code}`
|
||||
})
|
||||
|
||||
const isNetlify = config.public.isNetlify
|
||||
|
||||
const copyrightOwner = getCopyrightOwner()
|
||||
const systemName = getSystemName()
|
||||
const repoUrl = getRepoUrl()
|
||||
const systemVersion = packageJson.version
|
||||
const currentYear = new Date().getFullYear()
|
||||
|
||||
const responseTime = ref(0)
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window !== 'undefined' && window.performance) {
|
||||
// 使用 requestAnimationFrame 确保在渲染后计算
|
||||
requestAnimationFrame(() => {
|
||||
const loadTime = Math.round(window.performance.now())
|
||||
responseTime.value = loadTime
|
||||
})
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.site-footer {
|
||||
text-align: center;
|
||||
padding: 20px 0;
|
||||
margin-top: 30px;
|
||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.footer-item {
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
font-size: 12px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.footer-item:not(:last-child)::after {
|
||||
content: ' | ';
|
||||
margin: 0 10px;
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
.footer-item a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.footer-item a:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.icp-link,
|
||||
.voicehub-link {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
transition: color 0.2s ease;
|
||||
}
|
||||
|
||||
.icp-link:hover,
|
||||
.voicehub-link:hover {
|
||||
color: rgba(255, 255, 255, 0.8);
|
||||
}
|
||||
|
||||
.netlify-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.netlify-badge img {
|
||||
height: 16px;
|
||||
opacity: 0.8;
|
||||
transition: opacity 0.2s ease;
|
||||
}
|
||||
|
||||
.netlify-badge:hover img {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.site-footer {
|
||||
padding: 15px 0;
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.footer-info {
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.footer-item {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.footer-item:not(:last-child)::after {
|
||||
margin: 0 2px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -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×tamp=${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}×tamp=${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
Reference in New Issue
Block a user