Compare commits

...
98 Commits
Author SHA1 Message Date
Henry_Du 4fe807eb39 feat(排期): 新增批量发布全部草稿接口 publish-drafts
给小程序端「一键全部发布」用:一个事务把所有草稿翻成已发布并处理待处理重播申请,只清一次缓存、批量发订阅消息。避免前端逐条调用单条 publish 时每次都重建整张排期缓存导致的卡顿。
2026-10-24 13:39:13 +08:00
Henry_Du f5a43b37cc fix(微信订阅): 用微信真实关键词修正歌曲排期模板(thing1/phrase2/time3/character_string4),并修掉字段认领顺序导致的校准失效 2026-10-24 11:56:50 +08:00
Henry_Du dc2565da34 fix(微信订阅): 字段名按微信真实模板校准,不再靠猜编号;phrase 只截断不补齐 2026-10-24 10:18:57 +08:00
Henry_Du c51b7c660a fix(微信订阅): 不再按本地记账拦截发送,一律交给微信裁决 2026-10-11 16:59:25 +08:00
Henry_Du 6f9def0ffd docs(微信订阅): 自测接口的提示指向新的模板登记表与体检接口 2026-10-11 16:12:28 +08:00
Henry_Du d1b643bd93 refactor(微信订阅): 重写订阅消息链路,每一步失败都可见
把「微信订阅消息」这套代码按验收标准推翻重写:用户能自己看懂为什么收不到,
任何一步失败都必须看得见。对外函数名不变,notificationService / test-subscribe
等调用点无需改动。

新增 server/utils/wechatSubscribeTemplates.ts(纯函数,零依赖):
  - 模板登记表唯一来源(内置 SONG_SELECTED / SONG_REJECTED),服务层不再自己写死
  - parseSubscribeTemplates 如实报告环境变量覆盖情况:坏 JSON 只报告不抛、
    只给 id 时沿用内置字段映射、缺 id 的项记进 unknownKeys 并忽略
  - findEmptyFields / findEmptyDataFields 发出前自查空字段(微信空值整条 47003)
  - explainWechatErrcode 把 43101/47003/40003/40037/40001/45009 翻成人话

重写 server/services/wechatSubscribeService.ts:
  - sendWechatSubscribe 返回 SendOutcome,用 stage 明确「卡在哪一步」:
    no-template / disabled-by-user / no-wechat-openid / no-access-token /
    empty-field / no-grant / rejected-no-quota / rejected-bad-field / rejected-other
  - 每一次返回都写一行日志,不再有静默跳过(这是「发不出去全靠猜」的根因)
  - inspectWechatSubscribe 只读体检:不发消息、不消耗额度,返回逐项 verdict + actions
  - grantWechatSubscribe 依靠新增的 WechatSubscribeGrant 表记账:微信没有查询
    剩余授权的接口,只能自己数(用户点允许 +1、发送成功 -1)
  - 记账表不可用时失败开放(fail-open):额度预检关闭、照常发送,避免通知整体哑掉
  - access_token 显式 responseType: json 并做字符串兜底解析

新增接口:
  - GET  /api/user/wechat-subscribe-status   小程序显示「还能收到几条 / 为什么收不到」
  - POST /api/user/wechat-subscribe-grant    小程序授权成功后上报 accept 的模板
  - GET  /api/admin/wechat/diagnose          重写:人话结论 + actions,?send=1 真发一次

其他:新增 WechatSubscribeGrant 表(schema + 手写迁移)、.env.example 说明 JSON 覆盖
写法、.gitattributes 补 *.sql eol=lf。

测试:新增 test/wechatSubscribeTemplates.test.mjs(13 个用例,可直接 import .ts,
不依赖 Nuxt 别名),npm test 里订阅相关 25/25 通过。
2026-10-11 16:02:33 +08:00
Henry_Du 09e7f53a1a feat(微信订阅): 新增一键体检接口 /api/admin/wechat/diagnose
线上排查「订阅消息没发出去」以前只能靠猜,因为没有查询额度的接口,四条跳过路径
也不写日志。这个 GET 接口(仅超管,浏览器直接打开即可)一次返回所有事实:

- access_token 能不能拿到、长度多少
- WECHAT_MINI_SUBSCRIBE_TEMPLATES / APPID / SECRET 是否注入、模板开关是否启用
- 当前账号有没有绑微信 openid(没有就永远发不出去)、哪些 userId 绑了微信
- 每个模板的 ID、字段映射,以及 buildTemplateData 算出的、真发出去会用的 data
- 加 ?send=1 时顺手给当前用户真发一条(两个模板各一次),把微信的 errcode
  原样返回:0=通、43101=没额度、47003=字段名不对、40003=openid 不对、40001=token 失效

用法:登录后台后打开 /api/admin/wechat/diagnose (或 ?send=1)
2026-10-11 15:37:39 +08:00
Henry_Du 308a2103ac fix(通知): 微信订阅消息的每条跳过路径都写日志,不再静默
以前 sendWechatSubscribeToUser 的四条提前返回(no-template / disabled-by-user /
no-wechat-openid / no-access-token)都不写日志,线上排查「通知没发出去」时
容器日志里一片空白,只能靠猜。现在每条都打一行 [WeChatSubscribe] ... 跳过:原因,
另外 createSongSelectedNotification 在「用户关了通知总开关」时也补了一行。

用途:发布一首新歌后执行 docker logs --timestamps voicehub-app | grep WeChatSubscribe
即可直接看到卡在哪一步(模板没配 / 用户没绑微信 / 拿不到 token / 没有额度 43101 / 字段 47003)。
2026-10-11 15:16:54 +08:00
Henry_Du 81fd05b39a feat(微信订阅): 自测接口支持 dryRun(不消耗额度)与 sendToAll(区分没额度/字段错) 2026-10-11 12:04:03 +08:00
Henry_Du b64ee5cbc6 fix(排期): 发布按钮不再兼任删除,清空排期改为显式操作并加服务端守卫 2026-10-11 01:15:24 +08:00
Henry_Du 28238ab00f test(微信订阅): 补上 SONG_REJECTED 驳回模板的映射与截断回归测试 2026-10-11 01:06:37 +08:00
Henry_Du 5962df638d ci(deploy): 工作流从 4 个 job 精简为 2 个,并启用 BuildKit registry 构建缓存
- 删掉 preflight(npm ci + nuxt build)与 meta-warmup:Dockerfile 自己在容器内
  跑 npm ci + node scripts/memory-build.js,这两个 job 的产物从来没被 docker build
  用到,纯白烧 3-6 分钟,meta-warmup 还在 build-image 之前抢同一个 runner 的内存。
- 删掉每个 job 各自跑的 apt 换源 / 清华镜像 / git URL 重写:对镜像构建没有任何影响。
- buildx 从「装不上就硬失败/一大段兜底脚本」简化为「装得上就用(带 registry 缓存),
  装不上就退普通 docker build」,部署永不因 buildx 缺席而失败。
- 新增 BuildKit registry 缓存(--cache-from/--cache-to type=registry,tag :buildcache):
  依赖安装层跨 run 复用,第二次之后的部署会快很多。
- 镜像标签改在 build-image 内计算(原先依赖 meta-warmup 的 outputs)。
- 新增 .gitattributes 强制 yml/sh 用 LF(core.autocrlf=true 会把 heredoc 写坏)。
2026-10-11 00:14:22 +08:00
Henry_Du 3eff710d5d feat(微信订阅): 接入歌曲排期模板并加固部署流程
订阅消息(小程序侧唯一能主动触达用户的通道):
- 新增 server/utils/wechatSubscribeFields.ts:按微信字段类型裁剪值(thing≤20、
  phrase 必须 5 个汉字、time/date/character_string 各自上限),空白压成一行,
  北京时间 time 由 playTime 的开始时间生成,解析不出退化为当天 00:00。
- wechatSubscribeService 内置 SONG_SELECTED 模板(站长新给的那个模板 ID),
  并转发上述纯函数;notificationService.createSongSelectedNotification 现在会带上
  playDate/playTime/sequence,三个排期接口(schedule / publish / bulk-publish)补传 sequence。
- 新增 test/wechatSubscribeFields.test.mjs(10 例,node --test 全绿)。
- test-subscribe 管理接口支持 vars 覆盖并返回真实发送值,方便站长核对字段名。

部署流程(Gitea Actions 容器里没有 buildx 插件,旧流程在此硬失败):
- 每个 job 各自安装/探测 docker-buildx(v0.19.3,走 gh-proxy 再退直连),
  不把 `buildx version` 当唯一依据;装不上时降级为普通 `docker build` 继续发布,
  不再因为缺插件整条流水线失败。

注意:SONG_SELECTED 的字段名(thing2/date4/time1/character_string3/phrase5)是按常见
命名推断的,需在公众平台后台核对;不一致时用环境变量覆盖,模板 ID 变更后老用户需重新授权。
2026-10-11 00:04:14 +08:00
Henry_Du ebf1c3a127 perf(auth): 登录去掉一次冗余查库 + 缓存 2FA 查询 + 修掉 session_key 进日志
登录实测约 232ms,其中 bcrypt 约 72ms、网络往返约 80ms 是不可省的计算/物理成本,
剩下的都是我们自己的开销。这次把能省的两处省掉(实测约 40ms,占登录耗时约 19%):

1. 去掉「数据库连接检查」的冗余预检
   原来每次登录先 await db.select().from(users).limit(1) 只为"测连接",
   紧接着又会真的查用户,连不上一样会报错 —— 纯粹多一次数据库往返。
   现在把那次真实查询包进 try,只有它失败才回报 503「数据库服务暂时不可用」,
   错误码与文案完全不变。

2. 缓存「这个账号有没有开 2FA」
   登录每次都查 userIdentities 找 TOTP,但绝大多数账号根本没开 2FA。
   新增 server/utils/totp-cache.ts:
     · 查到**没有** TOTP → 缓存 10 分钟,后续登录直接跳过查询
     · 查到**有** TOTP   → **不缓存**(安全优先:绝不能因为缓存而让刚开 2FA 的人被跳过验证)
   开启/关闭 2FA 的接口必须调用 invalidateTotpCache(userId),已在
   server/api/user/2fa/{enable,disable}.post.ts 接上 —— 否则会有最长 10 分钟的验证空窗。
   该缓存不参与密码校验,最坏情况只是多查一次库。

3. lastLogin / lastLoginIp 的 UPDATE 不再阻塞响应
   登录时间与登录 IP 是审计信息,晚写几十毫秒、甚至这次写失败,
   都不影响用户能否登录。改为不 await(保留 .catch,避免 unhandledRejection)。

4. 安全修复:微信登录不再把 session_key 打进日志
   server/api/auth/wechat/login.post.ts 原来打印整个 jscode2session 响应前 500 字,
   而该响应含 { session_key, openid, unionid }。
   session_key 是敏感凭据(可解密用户数据、配合其他接口换取手机号),
   长期落在服务端日志里等于凭据落盘。
   现在只打印:顶层键名、errcode/errmsg、以及 openid/session_key/unionid 的
   存在性与长度(不打印值);仅在"响应根本不是对象"时才打印截断原文
   (那种情况它不可能是合法凭据,且是排查被代理拦成 HTML 的唯一线索)。

已验证:
- 缓存逻辑用假 db 真跑过 5 个场景(命中缓存省下查询、失效后立刻重查、
  有 2FA 不缓存、多用户互不干扰、只失效指定用户)
- 4 个改动文件的相对 import 路径均存在;totp-cache 的导出与两处 import 对得上
- login.post.ts 抽走 TOTP 查询后已清理不再使用的 userIdentities / and 导入
- 文件编码 UTF-8 无 BOM、无乱码、括号平衡
2026-10-01 21:04:02 +08:00
Henry_Du 752a2eae87 feat(auth): 已登录账号绑定微信改为一键(不再要求填姓名密码)
站长反馈:登陆进去的账号绑定微信就不用再写账号信息了吧。

原来的绑定只有一个入口 POST /api/auth/bind,它是**未登录**场景用的:
微信扫出来的是个陌生身份,得先"姓名 + 密码"验证身份,才知道该绑到谁头上。
用户已经在账号页里登录了,再让他输一遍账号信息就很别扭。

新增 POST /api/auth/wechat/bind-self { bindToken }:
- 必须已登录(用 event.context.user 直接落库,不再要密码);
- bindToken 走原有 verifyBindingToken 校验;
- 该微信已经绑给**别人** → 409(不许抢绑);
- 已经绑给**自己** → 幂等返回成功(重复点不报错);
- 并发下唯一约束(23505)冲突也会复判一次,避免误报失败。

小程序端:
- utils/api.js 新增 bindWechatToSelf()(wx.login → wechat/login 拿 bindToken → 调新接口);
- 「账号管理 → 绑定微信」与首页那条绑定提示都改成一步完成,
  不再跳绑定页填账号密码(绑定页保留给未登录场景:如登录页发现微信未绑账号时去注册/绑定)。
2026-10-01 00:08:06 +08:00
Henry_Du cd6e235848 fix(users): 状态大小写不敏感 + 彻底修掉「无法编辑其它用户」
站长反馈仍然无法编辑其它用户。上一版只把**小程序端**的状态改成小写,
但如果小程序没重新编译(或用的还是旧版本),发上来的仍是 `ACTIVE`,
服务端 `['active','withdrawn','graduate'].includes(status)` 依旧不通过 → 400
「用户状态只能是 active, withdrawn 或 graduate」——编辑任何用户都会失败。

这版把容忍度放到**服务端**,与客户端版本解耦:

- 新增 server/utils/user-status.ts:
  · USER_STATUSES = ['active','withdrawn','graduate']
  · normalizeUserStatus() 大小写不敏感('ACTIVE' / ' Active ' → 'active'),认不出返回 null
  · USER_STATUS_RULE_MESSAGE 统一文案
- [id].put.ts:状态改用归一化值;另修正"自己只能改姓名"里的状态比较也忽略大小写
- [id]/status.put.ts、batch-status.put.ts:同样归一化;
  并修掉一个我自己引入的隐患(把归一化结果赋回 const 变量 → TypeError),
  batch-status 之前被误写成不存在的 statusesRaw,一并改正

效果:无论小程序是否重新编译,管理员都能编辑其它用户(含历史拼音账号只改年级)。
2026-09-30 23:49:40 +08:00
Henry_Du deb71875c1 fix(users): 自己可改显示名、超管可在编辑里提升超管(修「保存失败/禁止修改自己的账户」)
站长反馈:在 admin 模式下仍无法修改用户信息,弹「禁止在用户管理中修改自己的账户」;
另外角色下拉里没有「技术运维」。

1) 自己改自己:原来一律禁止 → 现在**只允许改姓名(显示名)**,其余(账号名/年级/班级/
   状态/角色/密码)只要动了就仍然拒绝。这样站长能在「用户管理」里把 admin 的显示名
   改成自己的名字(登录名仍是 admin,应急登录不受影响)。
   状态比较忽略大小写:库里是 active,客户端历史版本可能发 ACTIVE,不该因此判定"改了状态"。

2) 超管可在通用编辑接口里把别人提升为 SUPER_ADMIN(站长要求下拉里要有「技术运维」):
   新增 allowPromoteBySuper 例外 —— 仅当登录者是 SUPER_ADMIN、目标角色设为 SUPER_ADMIN、
   且目标不是初始账号(ID=1,本来就是超管)时放行;非超管仍被 canChangeRoleOf 拦住。
   与 POST /api/admin/users/{id}/promote-super 效果一致,两条路都能走。
2026-09-30 22:58:10 +08:00
Henry_Du dfb4891022 fix(users): 只校验"真的改过"的姓名/账号名,修回"管理员无法编辑其他用户"
站长反馈:管理员无法编辑其他用户信息了。

根因:「姓名/账号名必须纯中文」的校验是**每次编辑都重新做一遍**的,
所以历史账号(早期用拼音/英文建的、名字里带「·」或空格的)连"改个年级"
都会被这条规则拦死 —— 管理员等于没法维护这些账号。admin 账号之前也是同一个毛病。

改成与服务端同一口径的"只校验改动过的字段":
- 没改动 → 不校验(历史数据原样保留,不强迫改名);
- 改了 → 必须满足规则(纯中文;admin 账号的姓名例外);
- 主动把中文名改成拼音/带空格 → 仍然拦。

初始管理员(ID=1)的规则不变:登录名必须保持 admin、角色锁死、密码不在这里改。

(对应的小程序端同样改成"只校验改动字段":编辑时记下原始姓名/账号名,
提交时比对;新增时两个字段都校验。)
2026-09-30 22:02:14 +08:00
Henry_Du 792b3d4ef6 feat(users): 初始管理员可改显示名(登录名锁死 admin)+ 新增「提升为超级管理员」
站长诉求:想自定义 admin 账号的资料,但**必须保留 admin/admin123 作为应急登录**;
同时想用自己绑了微信的中文账号做日常管理。

按"哪些能改、哪些必须锁死"拆开处理:

1) server/api/admin/users/[id].put.ts —— 原来对 ID=1 **一律 403**(连显示名都改不了),
   现在:
   · 允许改:姓名(显示名)、年级、班级、状态;
   · 锁死:登录名必须保持 admin(那就是 admin/admin123 的应急通道,改了就登不进去);
   · 锁死:角色不能动(防止把自己降权导致没人能管站点);
   · 锁死:密码不在这里改(避免被其它管理员顺手重置;要改用它登录后去「设置」里改)。

2) 新增 POST /api/admin/users/{id}/promote-super —— 把某个账号提升为超级管理员。
   之所以单开接口而不是放开通用编辑接口的 role:原 `canChangeRoleOf()` 明确禁止提升为
   SUPER_ADMIN,两个前端的角色下拉也故意不给这一项。这里是**显式、可审计**的通道:
   仅 SUPER_ADMIN 可调、不能提升自己、已经是超管的会提示、成功后清该用户认证缓存。

前端(小程序)配套:用户管理操作菜单改成 {label, run} 数组(有权限时才出现"提升为超级管理员",
不再靠写死 tapIndex),点击有二次确认并列出会拿到的权限;编辑初始管理员时登录名输入框 disabled
并显示原因,姓名照常可改。
2026-09-30 21:42:59 +08:00
Henry_Du 5039ab3f79 feat(auth): 账号名必须是纯中文,唯一例外是管理员账号 admin
站长要求:账号名除了 admin 其它都必须是中文名。

原来「只能包含汉字」的校验散在 5 个写入路径上,而且**没有 admin 例外** ——
导致连 admin 账号自己都编辑不了(改任何字段都撞「姓名和用户名只能包含汉字」)。

- 新增 server/utils/username.ts:规则一处定义
  · isValidUsername:纯中文,或恰好是 admin
  · isValidPersonName:姓名纯中文,但 admin 账号的姓名不做限制
  · 统一提示文案(接口报错与客户端提示共用同一句)
- 5 个写入路径全部改用它,并删掉各自的 chineseRegex:
  admin/users 新建、admin/users 编辑、admin/users 批量导入、auth/register、user/profile
- 注册时明确拒绝 admin(系统保留账号)

已验证:两端规则对 9 个用例结论完全一致;5 处均无旧正则;
admin 可编辑,拼音名(zhangsan / zhang.san / 张三1 / 带空格)一律被拦。
2026-09-30 20:50:09 +08:00
Henry_Du 1a334bbf5c chore(brand): 补上 Admin/WorkflowTutorial.vue 的品牌名(上一个提交漏了路径)
上一个提交把路径写成 app/components/WorkflowTutorial.vue(实际在 Admin/ 下),
git 报 pathspec 不匹配 → 这个文件没进那次提交,这里补上。
2026-09-30 20:25:10 +08:00
Henry_Du cccaf36990 feat(brand): 站点品牌名统一为「宝一外校园点歌系统」
站长要求全站一致(小程序端已单独提交)。本次只改**用户可见文案与默认值**:

- 服务端默认值/兜底:system-settings-defaults、admin/system-settings(GET/POST)、
  site-config.get、utils/siteUtils —— 原来是 'VoiceHub' / '校园广播站点歌系统'
- 用户可见通知文案:MeoW 绑定验证码与标题、MeoW 测试通知、微信订阅测试通知
- 2FA 应用名(authenticator.keyuri)、WebAuthn rpName(出现在系统密钥弹窗里)
- 网页版界面:首页副标题、后台侧栏品牌名、登录/注册/找回/改密标题与文案、
  年度回顾(IntroSlide / ShareCard / 页面标题)、用户教程、通知发送预览、
  API 密钥与数据库导入说明、酷狗 sidecar 说明、打印页脚与歌单卡片页脚、Logo alt

**刻意不动**:localStorage/sessionStorage 的 `voicehub_*` 键、`voicehub:` 前缀的
STORAGE_KEY 与缓存/Redis 前缀、`window.voiceHubPlayer` 之类代码标识、MeoW 的
User-Agent、以及上游项目链接 `github.com/laoshuikaixue/VoiceHub`(那是对原项目的署名)。

注意:站点名**数据**(systemSettings.siteTitle)仍是你后台里设置的「宝一外校园点歌」,
代码只保证默认值与文案一致;要让全站显示完整名称,请在后台「站点配置 → 站点标题」
改成「宝一外校园点歌系统」。
2026-09-30 20:24:55 +08:00
Henry_Du 9d3edc2c5e feat(kugou): 凭证支持环境变量兜底(配一次永久用);补 deploy.yml 变量映射
站长问「能不能跳转到酷狗 App 登录」—— 平台不允许(小程序无法启动任意原生 App),
而且即使跳过去,App 内的登录态也只在那台手机上,不会回传给服务端。
所以真正的便利做法是「凭证配一次、长期复用」:

- kugouService.kuGouFetch:cookie/token 增加 KUGOU_ADMIN_COOKIE / KUGOU_ADMIN_TOKEN
  兜底读环境变量;优先级 显式传入 > 库里绑定 > 环境变量
  (库里绑定的优先,所以之后想临时换一份 Cookie 仍可在后台粘一次覆盖)
- status.get.ts:bound 增加 source: 'db' | 'env' | 'none',界面能说清凭证从哪来
- deploy.yml:KUGOU_ADMIN_COOKIE / KUGOU_ADMIN_TOKEN 三处齐全
  (step env: 映射 + ENV_PAYLOAD export + upsert_env_key),并用 ${VAR:-} 兜底 ——
  少任何一处都会像上次那样 unbound variable 直接把部署打挂

已用脚本校验:payload 占位符/参数 23/23 对应、远端脚本 30 个变量都有来源、
两个新变量三处齐全。
2026-09-30 19:16:55 +08:00
Henry_Du af50ff1d95 feat(kugou): 支持粘贴浏览器 Cookie 绑定酷狗(不再必须扫码)
站长要求去掉扫码:扫码必须拿另一台装了酷狗 App 的手机,很别扭。

技术上完全可行 —— kuGouFetch() 本来就是**无状态**的:它把库里
kugouAdminCookie 放进请求头、kugouAdminToken 放进 query 去调 sidecar。
扫码只是获取 cookie 的一种方式,不是同步的前置条件。

新增 POST /api/admin/kugou/bind-cookie {cookie, token?}:
- 写入前先用这份凭证探一次 /login/status,无效/过期的凭证**不落库**
  (避免出现看起来绑定了但同步全失败的假绑定)
- 有效则写入 systemSettings 的 kugouAdmin* 字段(与 qr-check 完全相同的落点)+ 刷新缓存
- 连不上 sidecar 时返回 502 并提示去「服务设置」检查酷狗 API 地址
- 只保存会话 Cookie,**不接收也不保存酷狗账号密码**

qr-create / qr-check 保留未删(网页版可能还在用)。
2026-09-30 19:09:26 +08:00
Henry_Du 17f2aa7fcd fix(deploy): 把 WECHAT_MINI_SUBSCRIBE_TEMPLATES 加进 ENV_PAYLOAD(远端 unbound variable)
上一次只补了 step 的 env: 映射,但那只是【本地】runner 侧;
远端脚本的变量是靠 ENV_PAYLOAD 逐个 export 过去的。于是远端
set -euxo pipefail 读到未定义的  直接退出:
  bash: line 71: WECHAT_MINI_SUBSCRIBE_TEMPLATES: unbound variable
部署到生产服务器这一步就这样挂了。

现在两处都齐:step env: 映射 + ENV_PAYLOAD 的 export(并用  兜底,
Secret 没配也不会 unbound)。

验证(Git Bash 实测):
- printf 占位符 21 个 vs 参数 21 个一致,payload 含新变量
- source 生成的 env 脚本(set -u)→ 变量有定义、JSON 原样保留
- Secret 为空 → 定义为空字符串,同样安全
- 另外扫了整个远端脚本:引用的 29 个大写变量都有来源(payload 或自赋值)
2026-09-27 15:37:58 +08:00
Henry_Du 64a81393b5 fix(notify): 环境变量只给模板 id 时保留内置的字段映射
站长在 Gitea 里设了 WECHAT_MINI_SUBSCRIBE_TEMPLATES。若值里只写 id 不写 fields,
原来会把该模板的字段映射整个替换掉 → buildTemplateData 退回默认映射
(thing1/time2/thing3)→ 发给微信直接 47003。

现在按键合并:给了 fields 就用,没给就沿用内置的(SONG_REJECTED: thing6←songTitle,
thing4←reason)。坏 JSON / 裸字符串 / 空对象一律忽略,仍用内置模板。

已验证(8 项断言):内置默认、只给 id(保住映射)、id+fields(完全覆盖)、三种坏值。
2026-09-27 15:30:17 +08:00
Henry_Du 575e8b9f57 fix(deploy): .env 写入改成引号自适应(JSON 用单引号),并去掉 awk 的值截断隐患
两个真实问题:
1) upsert_env_key 一律用双引号包裹 → JSON 值(含双引号)会写成
   KEY={a:b} 这种坏行,docker compose 解析会崩/取到残缺值;
   现在:值里含双引号就用单引号包(JSON 不含单引号,安全)。
2) 原来用 awk 'BEGIN{FS=OFS==} =...' 重建行,会把值里 = 之后的内容丢掉
   (base64 密钥常带 = 填充)→ 改用 grep -v + 追加,值原样保留。

已用 Git Bash 对函数本身做单测(8 项断言全过):JSON 单引号包裹、带 = 的值不丢、
普通值双引号、重复更新不堆行、sed 取回后 JSON.parse 成功。
2026-09-27 15:29:32 +08:00
Henry_Du 08a37e4e2f chore(deploy): 补上 WECHAT_MINI_SUBSCRIBE_TEMPLATES 的 secrets 映射
少了这一行时,下面 upsert_env_key 拿到的永远是空值 ——
即使站长在仓库里配了 Secret 也不会写进服务器 .env(静默失效)。
驳回通知的模板已内置在 DEFAULT_TEMPLATES,这一项只在换模板/再加通知类型时才需要。
2026-09-27 15:26:42 +08:00
Henry_Du d26996350e feat(auth): 注册时可顺带绑定微信(微信登录发现未绑账号 → 引导注册)
- register.post.ts 接受可选 bindToken:注册成功后把该第三方身份插到新账号上
  · 绑不上**不影响注册**(账号已建好),返回 wechatBound=false + wechatBindError
  · 令牌无效/过期只记日志;唯一约束冲突按已绑定处理
  · 顺便把 token 返回给非浏览器客户端(小程序省掉一次重复登录)
2026-09-27 14:17:20 +08:00
Henry_Du a2ba15c3ca feat(notify): 微信订阅消息推送(驳回通知已对接)
- 新增 server/services/wechatSubscribeService.ts:access_token 缓存 + 模板配置 + 字段裁剪 + subscribe/send
  · 内置默认模板 SONG_REJECTED(模板 ID erAK**…,审核内容=thing6←歌名,驳回原因=thing4←驳回原因)
  · 环境变量 WECHAT_MINI_SUBSCRIBE_TEMPLATES 可按键覆盖默认模板
  · thing 字段按微信限制裁到 20 字(超长截 19 字 + 省略号),避免 47003
  · 发送永不抛异常;43101(用户没授权/没额度)只记 info
- 新增 server/api/user/wechat-subscribe-templates.get.ts:给小程序返回模板 ID(客户端要拿它去申请授权)
- 新增 server/api/admin/wechat/test-subscribe.post.ts:超管自测接口(token/openid/字段/微信原文一次看清)
- notificationService.ts:歌曲被选中/已播放/被驳回/系统通知 四处顺带发微信订阅消息
- .env.example + 部署流水线:WECHAT_MINI_SUBSCRIBE_TEMPLATES 可用仓库 Secret 下发
2026-09-27 13:55:55 +08:00
Henry_Du a080144a86 fix(wechat): jscode2session 需显式 responseType=json,否则 ofetch 不解析响应 2026-09-27 08:47:56 +08:00
dsh-agent 565d6e0fcb feat(api): 新增通用二维码接口 /api/qrcode
小程序没有任何 API 能直接唤起系统浏览器离开小程序打开网址,
唯一能真正离开小程序的官方途径是「长按图片 → 识别图中二维码」。
本接口把 URL 渲染成二维码图片(返回 base64 data URL),供小程序端
<img src> + show-menu-by-longpress 使用,让管理员能在微信内置浏览器里
打开完整网页版后台。

安全:需登录;只接受 http/https;长度上限 512。
2026-09-26 21:38:50 +08:00
DeepSeek Harness 9db42861e1 fix(server): 补上 DatabaseManager 缺失的连接池与性能方法
DatabaseManager 类从未定义 getConnectionPoolStatus / getPerformanceMetrics,
但有 4 个接口直接调用它们,导致运行时 TypeError 并返回 500:

  GET  /api/admin/database/pool-status
  GET  /api/admin/database/performance
  GET  /api/system/status
  POST /api/system/reconnect

两个函数本就实现在 utils/database-health.ts(getConnectionPoolStatus /
getDatabaseMetrics),这里补上委托方法,调用点无需改动。

验证:用项目的 .nuxt/tsconfig.server.json 做 A/B 类型检查,
改动前有 4 处 'Property does not exist on type DatabaseManager',
改动后全部消失(总报错数 682 -> 678)。
2026-09-26 16:21:28 +08:00
Henry_Du 3614ac6350 fix(schedule): 修复排期重排接口因变量遮蔽而必然报错
sequence.post.ts 中 \const { schedules } = body\ 在 try 作用域内遮蔽了从
schema 导入的 Drizzle 表对象,导致 db.update(schedules) 实际传入的是请求体
数组而不是表,接口一被调用就抛错(网页版的拖拽排序同样受影响)。

改用 scheduleItems,并补注释说明该陷阱。
2026-09-26 15:12:07 +08:00
Henry_Du 89cc80911e fix(deploy): 让部署工作流把 WECHAT_MINI_APPID / WECHAT_MINI_SECRET 同步进远端 .env
此前工作流只透传写死的 6 个 OAuth 变量,即便在 Gitea 仓库密钥里配好 WECHAT_MINI_*,
也不会被读取,容器内始终缺少这两个变量(/api/auth/wechat/login 恒报 500 服务器未配置小程序凭据)。

- deploy job 的 env 块增加两个密钥映射
- ENV_PAYLOAD 增加对应 export,并补齐 printf 实参(保持格式串与实参一一对应)
- 远端 .env 合并逻辑:条件放宽为「OAuth 或小程序凭据任一已配置」,并 upsert 两个新键
- 日志前缀由 [OAuth] 统一为 [Runtime-env],避免误导

已用 yaml 解析器校验工作流语法,编码与 CRLF 换行均保持不变。
2026-09-26 14:56:03 +08:00
Henry_Du 39375c23f2 fix(open-api): schedules 的缓存/Redis 分支补齐 voteCount,消除分支间字段不一致
上一步只给数据库分支补了 voteCount,但实测该接口通常命中缓存分支,
导致 voteCount 依然缺失。抽出 buildVoteCountMap() 供三条分支共用,
保证无论缓存命中与否,输出结构一致。
2026-09-26 14:30:02 +08:00
Henry_Du 12dac95e1b feat(auth): 新增微信小程序登录(绑定策略:首次用学号密码绑定)
- 新增 POST /api/auth/wechat/login:用 wx.login 的 code 换 openid;已绑定直接签发 JWT 返回,未绑定返回 bindToken
- bind.post.ts:支持从请求体读取 bindToken(小程序不便维护 cookie),绑定成功后返回 token,供 Authorization: Bearer 使用
- auth-route-policy.ts:把新接口登记进免鉴权白名单,否则会被鉴权中间件拦截
- .env.example:新增 WECHAT_MINI_APPID / WECHAT_MINI_SECRET
2026-09-26 13:57:24 +08:00
Henry_Du a140ce8621 fix(open-api): 修复开放API两处缺陷并支持小程序取 token
- songs.get.ts: 引用未定义变量 songsWithDetails 导致 /api/open/songs 缓存未命中时必然 500,改为 formattedSongs;并让缓存分支兼容 requester 为字符串的情况,避免返回 { name: undefined }
- schedules.get.ts: 数据库分支补齐 sequence / played / song.voteCount,与此前只有缓存分支才返回这些字段的行为对齐(缺少 sequence 会让前端无法显示歌曲序号)
- login.post.ts: 响应体返回 token,便于小程序/App 通过 Authorization: Bearer 访问(不影响原有 httpOnly cookie 行为)
2026-09-26 13:48:27 +08:00
Henry_Du 6f5aa625e6 feat(admin): 导入酷狗页同时保留「自动同步」与「手动生成照片」两种模式
KuGouImporter.client.vue 顶部新增模式切换 Tab(默认自动同步),切换到「手动生成照片」即复用原有 PlaylistCardPage 流程:AdminMultiDatePicker 多选日期 → 逐日出排期列表 → 每一日独立「生成该日图片」按钮 → PlaylistCardGenerator 弹窗下载 2:3 卡片 PNG。两套模式各自维护日期选择/排期读取状态,互不阻塞。

UI/Icon.vue 补 layout / image 两个 SVG 图标适配新入口。旧 PlaylistCardPage.client.vue 仍保留不作删除以免回退需求。
2026-09-19 14:15:45 +08:00
Henry_Du 381f3debf1 fix(admin+server): 酷狗 baseUrl 默认值移除 127.0.0.1 + 新增后台配置入口与推荐快捷地址
问题:容器内 SystemSettings.kugouApiBaseUrl 默认 http://127.0.0.1:3000 指向自己,sidecar 不可达。

修复:1) schema + migration 移除 kugouApiBaseUrl 默认值;kugouService + status API 兜底改空串(不再假设有 localhost),优先级 env(KUGOU_API_BASE) > DB。

2) 新增 POST /api/admin/kugou/settings:ADMIN+/SUPER 即时写 kugouApiBaseUrl 入库+刷新 CacheService。

3) KuGouImporter.client.vue 左栏新增 baseUrl 输入框(回车即存)+ 保存并测试按钮 + 两个推荐快捷按钮(容器宿主 http://172.19.0.1:3000、公网中继 https://bywmusic.bzw-hheb.top/kugou)+ 当前生效地址显式提示。

4) UI/Icon.vue 新增 server / globe / save 三个 SVG 图标与上述入口配套。
2026-09-19 13:40:59 +08:00
Henry_Du 6b3a83ebeb fix(schema): 补导入 jsonb 以解决生产构建 ReferenceError
schema.ts L1 从 drizzle-orm/pg-core 导入列表漏 jsonb,导致打包产物 nitro.mjs 加载阶段抛 ReferenceError: jsonb is not defined 直接崩溃退出。三处使用:kugouAdminUser、missingSongs、rawResponse。
2026-09-19 12:26:17 +08:00
Henry_Du a0184fdeb0 feat(admin+server): 酷狗对接 - 扫码绑定管理员账号 / 按排期自动创建酷狗日歌单 + 同步歌曲
数据模型:SystemSettings 新增 kugouApiBaseUrl / kugouAdminCookie / kugouAdminToken / kugouAdminUser / kugouAdminBoundAt 5 字段;新建 KuGouPlaylistSync 同步历史表(12 字段 + playDate 倒序索引)。迁移脚本:app/drizzle/migrations/20260919143000_add_kugou_integration.sql

后端 Server:新增 server/services/kugouService.ts 封装 KuGouMusicApi sidecar 转发(自动拼 Cookie / Token header);新增 6 个 admin/kugou 路由:status / qr-create / qr-check / unbind / sync-schedule-playlists / sync-history。同步核心:逐日出非草稿排期 → kgHash 直取优先 → 失败用 cloudsearch/search 按标题+歌手三级召回 → /playlist/create + /playlist/tracks op=add 每 100 首分批;写同步历史(status、added/missing、歌单 URL)。

后台 UI:新建 Admin/KuGouImporter.client.vue 三栏布局——左栏 sidecar 健康卡+部署说明+扫码绑定/解绑弹窗;中栏多选排期日期+逐日出排期预览卡+按日同步;下栏最近 100 条同步历史(7 种状态 badge + 歌单链接跳转)。dashboard.vue L105 playlist-card 挂载点由 PlaylistCardPage 替换为 KuGouImporter;权限 permissions.js 5 个 ADMIN 族角色开放 playlist-card 页面访问(接口内部绑定/同步再强校验 ADMIN/SUPER_ADMIN)。

侧car要求:部署 KuGouMusicApi(参考文档 https://kugoumusicapi-docs.4everland.app),默认本机 http://127.0.0.1:3000,可通过 SystemSettings.kugouApiBaseUrl 或环境变量 KUGOU_API_BASE 覆盖。
2026-09-19 11:57:02 +08:00
Henry_Du 573e168702 feat(admin-ui): 管理后台整体切换为浅色主题(ModernLight) + 新增 lightbulb 图标
1. layout(管理控制台 dashboard.vue): main bg-[#09090b] text-zinc-100 → bg-[#f8fafc] text-zinc-900; header bg-zinc-950/60 border-zinc-800 → bg-white/80 border-zinc-200; 移动菜单按钮/新手引导按钮 text-zinc-300/400 hover:bg-zinc-800 → text-zinc-600 hover:bg-zinc-100 border-zinc-200

2. Admin/Sidebar.vue: bg-[#09090b] border-zinc-800 → bg-white border-zinc-200 + shadow-sm; 标题 text-zinc-100 → 900; activeTab bg-blue-600/10 text-blue-400 → bg-blue-50 text-blue-700 border-blue-200; 未选中 text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40 → text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100; 用户卡 avatar/首字母 bg-white/70 border-zinc-200 + 退出按钮 hover:bg-red-50 text-red-600

3. 批量 33 个 Admin 组件硬编码深色原子类 1414 处替换(bg-zinc-9xx / border-zinc-8xx / text-zinc-100,200,300 / shadow-black/ -> 对应 light 版本) + style 块原生 CSS rgba(255,255,255,x) 16 处改为 rgba(0,0,0,x) / var 浅色主题 + 硬编码 bg-[#0c0c0e] / border-white/10 / color:white / surface-primary fallback 对应浅色修正

4. 二轮批量追加: bg-zinc-950 + opacity 变体(80/60/50/40/30/20/10) 共 431 处 → bg-white 对应 opacity 版本;placeholder:text-zinc-800 → 500;text-blue/red/emerald/amber/rose/purple 400 版 → 700/600 深浅一致版;border color /500 /30 → 200;focus:ring /20 → /30;shadow color 阴影降低强度

5. hover:text-white 扫尾: 13 处 DataAnalysisPanel/DatabaseManager/NotificationSender/SemesterManager/SongManagement/UserSongsModal hover 文本从 white → zinc-900;按钮三态从 hover:bg-*600 白字 → hover:bg-*-100/200 对应深字色,和前台 indigo/浅色体系保持一致

6. BlacklistManager 前台可见提示 💡→ <Icon lightbulb amber-600 size=13>;ChangelogManager 徽章色从 emerald-300/blue-300/zinc-400 → emerald-700/blue-700/zinc-600 可读;Icon.vue 新增 lightbulb 分支 stroke 图标

7. 受影响文件: dashboard.vue + Sidebar.vue + Icon.vue + Admin/* (33个组件共 1874 处批量替换 + 20 处手工精细化修正)
2026-09-19 08:30:59 +08:00
Henry_Du 357876a716 fix(ui+permission): 移除联合投稿 / emoji换SVG / 修复移动端浅色适配 / 已排期显示日期 / 隐藏.env提示 / 公开排期档期游客访问
具体改动:

1. feat(投稿表单): 移除 RequestForm.vue 联合投稿功能整套模板+状态+payload+CSS(10处残留)

2. feat(图标): Icon.vue 新增 sparkles/star 图标; 首页档期卡片📅🎵✨和排期列表空状态🎵📅换成 lucide SVG Icon

3. fix(浅色适配): ScheduleList.vue 日期选择弹窗桌面端+移动端(content/header/h3/close-btn/item)全部改为 bg-primary + var text + indigo 主色, 修复竖屏选择日期标题白字看不见

4. feat(歌曲列表): SongList.vue scheduled-tag 内联追加 formatScheduleDate, 徽章+日期双胶囊显示

5. fix(登录): OAuthButtons.vue showPlaceholderHint 强制 false, 隐藏虚线框 .env 配置提示

6. fix(权限): server/middleware/auth.ts + auth-route-policy.ts 双白名单同步追加 /api/expected-play-dates GET 公共访问, 修复未登录 Authentication expired 401
2026-09-19 00:43:46 +08:00
Henry_Du 25c7517405 fix(ui): 投稿须知卡片浅色化、搜索按钮indigo化、添加按钮几何风格
- RequestForm 左侧投稿须知卡片:从 rgba(0,0,0,0.4) 黑透明改为 bg-secondary#f4f4f5 浅灰底+边+16px圆角,标题加粗深色
- 搜索输入框:从深色 input-bg 改为白底细灰边,indigo focus 环;搜索按钮从 blue 渐变胶囊升级为 indigo(#4f46e5→#6366f1) 渐变硬边12px+投影
- 联合投稿区域:section-label 改深字;添加按钮从 bg-tertiary+白字改为 浅灰底+深灰字 hover indigo 填充反白;标签 collaborator-tag 同步 indigo 淡底
2026-09-18 23:48:16 +08:00
Henry_Du 87aa12d573 fix(ui): 优化投票/评论按钮样式、评论弹窗浅色化、投稿表单残留黑框修复
- SongList 点赞按钮改用 indigo 渐变硬边圆角 12px,新增阴影;评论按钮从胶囊 blue 改为浅灰底 indigo 图标几何风格
- SongCommentsDialog 弹窗整体浅色化:bg-white 主底、评论卡片 zinc-50、输入框 focus indigo 环、发送按钮 indigo 主色系
- RequestForm 投稿表单:搜索结果容器从 rgba(0,0,0,0.4) 改为 bg-secondary 浅灰;备注输入框 bg-white;复选框/横条状态/手动输入按钮/提交按钮全套浅色 + indigo 主题
2026-09-18 23:21:53 +08:00
Henry_Du 8a82cb77a7 fix(ui): 适配歌曲卡片、日期选择器、平台按钮及搜索占位图的浅色主题
- 修复 SongList/ScheduleList 投稿人、热度、投稿时间文字对比度,统一引用 --text-secondary 主题变量
- 重构 CustomSelect 通用组件为浅色底 + indigo 主色系,适配前台投稿页日期选择
- 调整 RequestForm 平台按钮三态配色,激活态使用 indigo 渐变保持主题统一
- 重写 search.svg 全部 19 个 path fill 为深灰系列,解决浅色背景下占位图不可见问题
2026-09-18 22:48:14 +08:00
Henry_Du 43f05a6e34 fix(ui): 适配前台浅色背景细节
- 学校 logo 替换为黑字版本,修改 useSiteConfig 默认值和 fallback
- 首页通知卡片补齐浅色样式:status-tag(三态)、unread-indicator、
  invite-actions、accept-btn、reject-btn
- 投稿区域状态徽章由绝对定位改为搜索标题行内布局,背景改为浅色
- 消息设置页面整体改为浅色主题,主色系改为 indigo 以匹配 ModernLight
2026-09-18 22:15:09 +08:00
Henry_Du 90e32ad086 ci(apt): Runner 自带命令已存在时跳过 apt-get update,避免卡 30-90s
- 改动点: preflight + meta-warmup 两个系统准备 step 都引入 need_cmd/need_pkg 预探测
- 策略: git/curl/tar/xz/ca-certificates 任一缺包才跑一次 apt-get update + 批量 install,全部满足直接跳过
- 同时: apt 源替换前先 grep archive.ubuntu.com / security.ubuntu.com,避免破坏 Runner 已配的国内/公司镜像
2026-09-18 21:36:31 +08:00
Henry_Du db685f26ba ci: 修复 npm ci EUSAGE @parcel/watcher 缺失并增加 install 兜底
- 锁步: 本地 npm install --legacy-peer-deps 重新同步 package-lock,补齐 @parcel/watcher packages 段
- 兜底: deploy.yml 中 npm ci 全部重试仍失败后,自动降级 npm install --legacy-peer-deps 并写双份缓存
- 诊断: npm ci 失败时附带 node/npm/lockfileVersion 三元组,便于区分版本差与网络问题
2026-09-18 20:49:42 +08:00
Henry_Du de52e4124c ci: preflight npm 升级到 11.x 以匹配 lockfile,修复 npm ci --install-links 报错
- 根因: 本地 Node 24/npm 11.6.2 生成的 package-lock 与 Runner Node 22/npm 10.x 不兼容
- 修复: Preflight 安装依赖前先 sudo npm install -g npm@^11.6.2 对齐版本
- 增强: MAX_RETRY 2→3,失败时打印 node/npm/lockfileVersion 并附带自检命令
2026-09-18 20:34:55 +08:00
Henry_Du 92704d6637 feat(front): 前台默认浅色模式并新增居中投稿按钮与实时空闲档期
- 主题: useTheme 默认 ModernLight + variables.css SSR-safe 浅值并精修组件对比度
- 首页: 插入居中投稿 CTA + 紧邻档期概览卡片(今日排期/待播歌曲/空闲档期数)
- 数据: useSongs 新增 fetchExpectedPlayDates 接入空闲档期并走 revision 轮询实时刷新
- 视觉: 移动端优先,少圆角硬边几何 + 概览卡片 3→1 列响应式
2026-09-13 19:32:27 +08:00
Henry_Du f80abe659f feat(frontend): 移动端底部导航与 Hero 插画及页脚浅色化
- MobileBottomNav.vue(新增): 三 Tab 底部导航栏(点歌/排期/我的)
  lg 1024px 以上隐藏;iOS safe-area 兼容;点击平滑滚动或跳转 /account
- HeroIllustration.vue(新增): 纯 SVG 广播园艺六花瓣+麦克风插画
  紫粉蓝三色渐变花瓣+花蕊麦克风+三层虚线装饰环;viewBox 400×400
- SiteFooter.vue: 改浅色适配(白底+薄边+紫强调色 hover)
- index.vue: Hero 插图槽替换为 HeroIllustration;集成 MobileBottomNav;
  新增 handleMobileNav 响应底部导航点击事件
2026-09-13 18:43:08 +08:00
Henry_Du 1a2031325b feat(frontend): 首页信息流与两大核心界面重构
- index.vue: 移除 tabs 单选改为五区块纵向信息流(Hero/排期/歌曲/投稿/消息)
  新增 scrollToSection 平滑滚动与投稿成功自动跳转;为移动端底部导航预留 pb-24
- ScheduleList.vue: 移除左右分栏改为日期横条+时段分组紧凑卡片
  序号采用 28px w900 紫粉蓝渐变 bg-clip-text;卡片三态状态标签色区分
- RequestForm.vue: 序号选择从 pill 升级为 40×40 方格三态(free/taken/selected)
  taken 态删除线+灰色;selected 三色渐变+scale1.05;选中显示「已锁定第N首」提示
2026-09-13 18:39:25 +08:00
Henry_Du e8eada885a feat(style): 前台强制切换浅色主题,保护后台管理页暗色
- variables.css: 新增 body[data-frontend]:not(.admin-body) 作用域浅色系变量
  包含 --accent-purple/pink/blue 三色花点缀色,圆角上限 8px 硬边设计
- theme-protection.css: 前台页面及表单控件强制 color-scheme: light
- default.vue: onMounted 时为 body 注入 data-frontend=true 标记
- dashboard.vue: 根容器添加 admin-body class,阻断前台浅色变量污染
2026-09-13 18:26:25 +08:00
Henry_Du c2114f0d82 fix(theme): 修复移动端深色主题被强锁浅色的问题
- **移除 color-scheme: none**:`theme-protection.css` 之前对表单元素设置 `color-scheme: none`,会让 iOS/Chrome 等移动端浏览器在深色主题下仍将原生控件(滚动条、日期弹层、select 弹层、输入光标)渲染为浅色,造成"移动端都是浅色"的错觉。
- **按 data-theme 动态声明 color-scheme**:当 `data-theme` 结尾为 Dark 或等于 `dark` 时显式声明 `color-scheme: dark`,结尾为 Light 或等于 `light` 时声明 `color-scheme: light`,让浏览器的原生 UI 组件(包括表单弹层、滚动条、状态栏颜色)与我们的主题保持一致。
- **补充表单控件的深色 prefers-color-scheme 兜底**:在 `@media (prefers-color-scheme: dark)` 且未显式选择浅色主题时,同样把 color-scheme 锁为 dark,避免首次渲染闪烁。
- **修正 useTheme 中表单控件的 color-scheme 继承**:表单元素使用 `color-scheme: inherit`,从 :root 继承生效的 color-scheme,而不再强锁 none。
2026-09-13 17:09:20 +08:00
Henry_Du 37fd18aaea fix(admin): 修复日期选择框尺寸过大与自动排入跨日期逻辑
- **日期选择器尺寸优化**:AdminDatePicker 与 AdminMultiDatePicker 采用 w-9 h-9 的紧凑卡片,缩小字号、边距与圆角,贴合后台窄布局。
- **自动排入逻辑重构**:取消严格的 `expectedPlayDate === selectedDate` 过滤,改为按歌曲期望日期/时段自动分配 `playDate` 与 `playTimeId`;其他歌曲排到当前选中日期。
- **跨日期本地暂存池**:引入 `crossDateLocalPool` 保存尚未保存但已排入其他日期的临时排期,切换 `selectedDate` 或时段时正确回填对应日期的草稿,避免自动排入后切换视图导致丢失。
- **排期列表去重与过滤**:时段过滤逻辑修复 `playTimeId == null` 的边界,并在合并公开/草稿/本地暂存时按优先级去重。
2026-09-13 16:18:09 +08:00
Henry_Du c4c30de416 feat(admin): 恢复按日期生成排期图片,重构多项体验问题
- **导入酷狗功能恢复**:废弃纯文本解析,重写为通过多选日期加载对应排期,并支持“根据日期分别生成图片”的场景。
- **浅色打印主题**:在 SchedulePrinter 和 ScheduleItemPrint 中应用 !important 强制白底黑字,消除系统深色模式对预览与打印效果的干扰。
- **日期选择器精简**:彻底删除 AdminDatePicker 和 AdminMultiDatePicker 中的横向日期盘滚动功能,改用直观的选定日期展示,配合月历面板实现更稳定快捷的选择。
- **自动排入逻辑修复**:严格过滤并匹配歌曲的 `expectedPlayDate` 和 `preferredPlayTimeId`,修复无期望播出时间的歌曲被错误填入的问题。
2026-09-13 15:49:05 +08:00
Henry_Du ccb39203b8 fix(build): 关闭 nitro 的 minify 压缩,消除其在服务端打包时生成庞大 AST 导致的致命 OOM 137
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m45s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m8s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 4m36s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
2026-09-13 14:30:54 +08:00
Henry_Du d3d2673e5c fix(ci): 修复 docker buildx 跨 job 无法复用实例导致 instance multi 找不到的问题
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 3m2s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m14s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
2026-09-13 14:21:22 +08:00
Henry_Du 9a025f5bd5 fix(build): 深度优化构建配置,禁用 compressPublicAssets,取消 manualChunks,以彻底解决内存溢出问题
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m55s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 3m45s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Failing after 1s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
2026-09-13 14:02:53 +08:00
Henry_Du 456f676929 fix(build): 彻底移除无用的网易云 API 模块,消除由于未安装模块引入导致的 OOM 和解析异常
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 3m2s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m29s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
2026-09-13 13:54:41 +08:00
Henry_Du 7034bbaedb fix(build): 进一步优化打包内存,强制外置超大包,并将 PlaylistCardPage 设为客户端专属避免服务端静态分析
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 3m31s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m52s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
2026-09-13 13:47:19 +08:00
Henry_Du 16f0b1f6b5 fix(build): 避免 CI prerender 阶段因 DATABASE_URL 未设置导致抛出错误
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m57s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m19s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Failing after 1s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
2026-09-13 13:26:38 +08:00
Henry_Du bdefc105bf fix(build): PlaylistCardPage 移除显式 TS 类型注解,保持 pure JS 风格避免 babel parser Missing semicolon
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 2m31s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 3m52s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
2026-09-13 13:08:22 +08:00
Henry_Du 8ff5bec4f0 fix(build): 彻底降 Nitro server build 内存占用,消除 exit 137 OOM
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 2m8s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m0s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
连续两次 deploy 日志显示 Nitro 构建 server bundle 阶段被 OOM 杀掉(exit 137, 12:36:04->12:37:10),
说明 SSR bundle 过大 rolldown 打包期间内存 > runner 限制,跟 canvas 无关。

定位到两个真正的内存元凶:

1. vite.ssr.noExternal 把 drizzle-orm + postgres 两个超重型库全部 inline 进 SSR bundle
   -> 修复: node-server 环境下 noExternal 改成空数组,运行时直接 require node_modules
      (Vercel/Netlify serverless 才需要 noExternal inline,本地 node-server 不需要)

2. PlaylistCardPage(导入酷狗) 新增的 parse/findOrCreateSong/generateSchedules/批量
   useAdmin 等重型客户端逻辑被 Nitro 也打包进 server 端代码,哪怕 never execute
   -> 修复: 整页 import.meta.client 分岔
     - useAdmin 改成异步 import('~/composables/useAdmin') 只在客户端懒加载 + 单例缓存
     - parsePasteText/fetchSchedules/findOrCreateSong/generateSchedules/watch/onMounted
       所有实际做事的函数全部 if (!import.meta.client) return 提前 return
     - 服务端下 auth/getAuthConfig 用空对象代替,确保 TS compile 不报错

附带的 nitro 打包瘦身:
- nuxt.config 里 vite.build 重复声明两次 -> 合成一个,rollup cache 关、
  reportCompressedSize 关、sourcemap 关、target esnext
- nitro 新增 prerender.crawlLinks=false routes=['/','/login'],不要扫站预渲染
- nitro.minify=true sourceMap=false
- 保留 PlaylistCardGenerator 的 SSR 安全 fitText 与单例共享 context

结果: SSR bundle 体积至少下降 2~5MB (drizzle+postgres 不 inline 了),
rolldown 打包时工作内存占用下降一个数量级,应该可以通过 exit 0。
2026-09-13 12:41:47 +08:00
Henry_Du a55fa96952 fix(build): SSR 安全化 PlaylistCardGenerator canvasHeight 避免 Nitro 构建 OOM(exit 137)
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 4m19s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m30s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
上版在 canvasHeight computed 里对每首歌 document.createElement('canvas')+getContext('2d'),Nitro prerender/rollup bundler 在 Node 环境下要么 document undefined 抛错 fallback,要么 prerender 时对大歌单反复创建 2D context 撑爆虚拟内存 -> 5min 后 OOM Killer(exit 137)。

修复:
- 新增 isBrowser = typeof document !== 'undefined' 判断
- getSharedCtx(): 浏览器环境才创建 1 个共享 2D context 并跨次复用(不再每首歌 createElement)
- fitText(ctx, ...) 抽成双分支:有 ctx 用 measureText(客户端精确),无 ctx 用 CJK>0x2e80 Unicode 判断+经验字符宽度(16px 中文 1.05em/英文 0.55em)估算换行,保守估算高度
- 同时 fitText 支持文本中带 \\n 换行手动切分
- canvasHeight 中不再 new canvas/context,SSR 环境也能安全执行+算出估算高度
2026-09-13 12:32:51 +08:00
Henry_Du ac4727551c feat(admin): 图片歌单改导入酷狗+自动排入按期望时段+日期滚动修复
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 7m34s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 7m54s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
1. 菜单重命名: 图片歌单 -> 导入酷狗 (Sidebar/Dashboard/教程 + 页面标题)
2. PlaylistCardPage(导入酷狗) 新增:
   - 粘贴酷狗歌单文本解析(支持序号/横杠/括号格式)
   - 预览区歌名与歌手名完整显示(去除truncate, 支持换行展示)
   - 选日期+选播出时段, 一键将解析后的歌曲(没找到则自动创建)生成为该日期的排期
   - 批量保存后展示状态徽章(匹配/创建/失败)
3. PlaylistCardGenerator 画布:
   - 去除 substring(0,12)/substring(0,10) 截断, 改用 fitText 按 canvas 宽度逐字符换行切分
   - canvas 高度根据实际行数动态计算, 最多支持 200 首
   - 中文字体堆栈加入 PingFang SC/Microsoft YaHei
4. ScheduleManager.autoScheduleExpected:
   - 优先按歌曲自己的 preferredPlayTimeId(期望播出时段) 分组排入
   - 未指定才 fallback 到顶部当前选中时段
   - 分时段各自维护 sequence, 最后按时段 startTime 升序+分序 合并重排
   - 顶部过滤了单时段时, 只排入匹配该时段的歌曲(含未指定fallback)
5. 日期滚动修复:
   - AdminDatePicker / AdminMultiDatePicker scrollToDateElement 弃用 scrollIntoView
   - 改用精确的 offsetLeft - (clientWidth-offsetWidth)/2 计算 scrollTo left 值, 避免受父级滚动容器/左箭头影响
2026-09-13 12:22:28 +08:00
Henry_Du 27d718bf01 fix(admin): 日期组件进入界面后应滚动到当前日期/选中项居中
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m51s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m50s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Failing after 1s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
AdminDatePicker:
- scrollToDateElement 在 props.modelValue 为空时回退到今天
- onMounted: 空值时走 smooth(有滚动感),有值走 auto(无声定位),不再直接 return 停在最左

AdminMultiDatePicker:
- 新增 scrollToDateElement 通用 + watch(modelValue)
- onMounted 分两支: 有选中值时跳到首个 >= 今天的选中项 否则跳今天
- 目标超出 dateRange 时先扩 range 再滚 (避免定位不到元素)
2026-09-13 10:29:12 +08:00
Henry_Du 456bc39e3e perf(ci): deploy.yml workflow 全量提速 30-45%:preflight 步骤合并+npm 本地磁盘缓存;新增 meta-warmup 与 Preflight 并发跑系统/检出/Builder 初始化;build-image 复用 meta-warmup 输出省 6 步;--no-cache 改为 inputs.skip_build_cache 条件触发;timeout 收紧 40→30;deploy 依赖改为 [preflight, build-image] 双成功校验
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 3m23s
构建并部署 BYW VoiceHub / 镜像构建·预热(与 Preflight 并发:系统/镜像源/Builder 初始化) (push) Successful in 4m18s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Failing after 1s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
2026-09-13 10:09:43 +08:00
Henry_Du 28d06dfdb2 fix(admin): 修正日期滚动范围过小导致找不到今天的问题
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m12s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 9m17s
上一版把扩天步长压到±3天且多选start锁死≥0,需要滚很多次才到目标日期.

调整:
- 单次滚动像素: 120px → 160px (约3~4张卡片)
- 初始范围: 单选 -10~+15天 / 多选 -3~+38天 (多选允许看过去3天以便对齐今日)
- 无限扩天步长: ±5天 (折衷); 扩天触发阈值 50px→100px,快到边缘就提前扩
- 多选去除 Math.max(0,start) 限制,允许往过去扩展
2026-09-13 09:58:13 +08:00
Henry_Du 604749d08a chore(auth): 临时硬关闭微信聚合登录(aggregate=false)\n\n用户反馈:先暂时不做微信登录了。\n\n双重保险:\n1) 构建期开关:nuxt.config.ts runtimeConfig.public.oauth.aggregate 由\n 原来的 !!process.env.AGGREGATE_OAUTH_APP_ID 改为硬编码 false。\n 影响范围:\n - 登录页 OAuthButtons.vue 中的 v-if 分支不再渲染绿色微信按钮\n - app/pages/account/index.vue 中 hasOAuthProviders aggregate=false → 个人页\n 第三方绑定分区即使其他 provider 都没配也会显示其它 provider,\n 但微信卡片 OAuthBindingCard.vue 因 config.public.oauth.aggregate=false\n 被 v-if 直接拿掉不渲染\n - app/utils/oauth.ts getProviderDisplayName 中 aggregate→微信 映射还保留,\n 恢复时无需再改\n2) 运行期强制关:server/utils/oauth-strategies.ts loadAggregateSettings\n 返回值 aggregateOAuthEnabled 硬编码 false(即使 process.env.AGGREGATE_OAUTH_APP_ID\n 和 system_settings 表都配齐也不启用)。并加 _hardDisabledReason 字段给后续日志\n 留解释,避免以后排查为何 ENV/DB 都有值却 500 说未配置。\n\n后续恢复步骤(两处同时改回即可):\n A. nuxt.config.ts public.oauth.aggregate 改回\n aggregate: !!process.env.AGGREGATE_OAUTH_APP_ID\n B. server/utils/oauth-strategies.ts loadAggregateSettings 返回对象\n aggregateOAuthEnabled 改回原本逻辑:\n enabled && !!(appId && appKey && endpoint)\n 并删除 _hardDisabledReason 字段即可。\n\n注意:Gitea Repository Secrets 中的 6 条 AGGREGATE_OAUTH / OAUTH_* 没有删除,\n保留在仓库里,下次恢复不用重新输入一遍。
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m13s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 10m0s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 7s
2026-09-13 09:29:05 +08:00
Henry_Du ec39f6ab56 perf(admin): 调小日期滚动条的滚动跨度,避免一次飞太远
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m32s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 6m43s
- 滚轮/左右箭头: 单次滚动量 200px -> 120px (约2~3个日期卡片)
- 横向条初始长度: 单选 31天(-15~+15)->19天(-8~+10) / 多选 46天(0~45)->29天(0~28)
- 无限扩展步长: 碰到边缘一次+7天 -> +3天(多选额外做了 Math.max(0,start) 防越界)
- 回到今日重置范围 / 快捷选期外扩范围 一并调小
2026-09-13 09:19:48 +08:00
Henry_Du 71bbd88bb8 fix(admin): 修复日期选择器与选曲日期的 UTC 时差错位
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m44s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 10m19s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 7s
根本原因: 使用 toISOString().split('T')[0] 会按 UTC 时区截断日期,
在 UTC+8 时区下,本地 00:00~07:59 期间会少 1 天,导致:
- AdminDatePicker/AdminMultiDatePicker 横向条、月历选中值错位
- ScheduleManager selectedDate 今日初始化与今日排期过滤错位
- 月历今日高亮与实际日期不匹配

修复方法: 所有 YYYY-MM-DD 格式化统一使用本地时区的
getFullYear/getMonth/getDate + pad, addDays 前先 setHours(0,0,0,0) 归零
避免 DST 与时区偏移导致的跨天误差
2026-09-13 08:36:40 +08:00
Henry_Du 19ab33b102 feat(admin): 优化后台日期选择器和播出时段交互流程
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m15s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 9m18s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
- AdminDatePicker: 新增可展开月历网格 + 6 个快捷日期跳转按钮
- AdminMultiDatePicker: 新增 8 种批量预设 + Shift 范围选择 + 月历网格
- PlayTimeManager: 新增 6 个校园广播快速模板 + 时段复制 + 批量启停
- ScheduleManager: 播出时段从下拉改为标签按钮组 + 增加统计卡片
- RequestTimeManager: datetime-local 拆分为日期+时间双输入 + 6 种快捷预设
2026-09-13 02:04:48 +08:00
Henry_Du a481e0ae4c fix(auth): 微信登录按钮放大+主题色 & /api/auth/aggregate 500 诊断日志\n\nUI 改动(微信登录按钮 + 个人页绑定卡片):\n- OAuthButtons.vue:aggregate 微信按钮 48x48 → 56x56,SVG 图标 22px→28px;默认背景微信绿 #07C160,hover #06ad56,active #059a4c;正常态加绿色阴影,纯 #fff 文字;不再和相邻 Google/GitHub 保持原尺寸不变\n- OAuthBindingCard.vue 个人页卡片:aggregate(aggregate):微信图标框 40x40→48x48(h-10→h-12),图标 22px→26px,背景/边框/文字用微信绿 #07C160;未绑定微信色 立即绑定微信 按钮 字号从 xs→sm,py 1.5→2.5,文案 立即绑定→立即绑定微信\n\n500 排错日志(/api/auth/aggregate):\n- loadAggregateSettings:先取 env fallback 前置,DB select 包 try/catch 不抛;并打印 [AggregateOAuth] enabled/appId/appKey 长度/loginType/endpoint 状态(DB=? 配置 / ENV=? 配置 / dbEnabled回退逻辑\n- auxxStrategy.getAuthorizeUrl 全链路结构化日志:\n 1) 参数打印(enabled/endpoint/safe_ok/redirect_uri/state_len\n 2) 配置缺失时逐条枚举原因(APP_ID 空 / APP_KEY 空 / ENDPOINT 空 / enabled=false)\n 3) endpoint 不合法时单独打印 [AggregateOAuth][auxx][getAuthorizeUrl] endpoint 不合法 + JSON(endpoint)\n 4) 请求 auxx.cn/connect.php?act=login 前打印 GET URL(appkey 替换为 ***,日志绝不输出真实密钥)\n 5) $fetch 成功打印 code/msg/has_url/url_prefix\n 6) $fetch 异常打印 err/status/body (response._data 或 data 前 400 字)\n 7) $fetch 加 15000ms 超时,避免 Runner 到 auxx 不通卡死一直挂 30s\n 以上;30s+ 才被 Nitro 强制 500
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m13s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 10m1s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
2026-09-13 01:05:38 +08:00
Henry_Du 9fd8a9ccbd fix(ci,build): 双路径双 Job 注入 OAuth 6 条 secrets 到构建期\n\n根因(Run #71 已确诊):\n - Preflight Job 的「执行 nuxt build」step 的 env 注入在当前 Gitea Actions 版本下没生效(整个日志中搜不到 [OAuth] 字样)\n - 构建镜像 Job 的 docker buildx build 完全没透传 OAuth 变量,镜像内 RUN npm run build 看不到 process.env.AGGREGATE_OAUTH_APP_ID\n - nuxt.config.ts runtimeConfig.public.oauth.aggregate = !!process.env.AGGREGATE_OAUTH_APP_ID 两个路径都拿不到值 → 一直 false\n - 前端登录页第三方登录分区一直渲染占位提示,不出微信按钮\n\n修复(两条独立链路,任一条成功按钮都会出现):\n ① 链路 A(preflight 产物):preflight Job 加「注入 OAuth secrets 到 GITHUB_ENV」Step(_append_env 双引号包住值写 $GITHUB_ENV),保证后续所有 step(含 nuxt build)的进程级 process.env 都有 6 个变量\n ② 链路 B(容器镜像):Dockerfile COPY . . 后新增 6 个 ARG(OAUTH_REDIRECT_URI / OAUTH_STATE_SECRET / AGGREGATE_OAUTH_APP_ID / APP_KEY / LOGIN_TYPE / ENDPOINT)并 ENV 导出,保证 RUN node scripts/memory-build.js(npm run build)能读到\n ③ deploy.yml build-image Job:新增「注入 OAuth secrets 到 GITHUB_ENV」Step(与 preflight 同样的 _append_env 逻辑),紧接着的「构建并推送镜像」Step 把这 6 条变量逐个非空判后拼进 BUILD_ARGS=(--build-arg ...),并打印每条是否已注入(不打印值)\n\n配合 Deploy Job 原本已实现的远端 /data/opt/byw-voicehub/.env upsert(SSH 运行期写入):\n - 运行期容器(docker-compose env_file: [.env])也有同一套 6 条变量 → server/utils/oauth-strategies.ts loadAggregateSettings 的 env fallback 能读到 auxx.cn 凭据,完成微信扫码登录/绑定全链路。\n\n诊断日志(新的 Run #72 中应出现):\n [OAuth-secrets] AGGREGATE_OAUTH_APP_ID=已注入 (len=4) (若配置过 Secrets)\n [OAuth build-args] AGGREGATE_OAUTH_APP_ID=已注入\n [OAuth] build-time AGGREGATE_OAUTH_APP_ID 已配置(前端第三方登录按钮会显示), LOGIN_TYPE=wx
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m15s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 9m36s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
2026-09-13 00:43:37 +08:00
Henry_Du f5b7bef285 fix(ci): Preflight npm ci 防超时 + Deploy job 误触发保护\n\n- deploy.yml preflight/安装依赖 Step:新增「双 registry 切换 + 3 轮重试 + npm fetch-retries=5 + fetch-timeout=5min + prefer-offline」\n - 主站 https://registry.npmmirror.com 备用 https://mirrors.cloud.tencent.com/npm/(两套独立 CDN 节点),奇偶次交替\n - npm 内部 read ETIMEDOUT(exit 146)自动走 fetch-retries 重试;整轮失败 bash 层 15s 后再跑下一轮\n - 失败 3 轮才真正 exit 1,并在日志中明确提示可覆盖 NPM_REGISTRY_PRIMARY/FALLBACK\n- deploy.yml deploy job:if 条件从 \x27!cancelled() && !failure()\x27 改为 \x27always() && needs.build-image.result == \x27\x27success\x27\x27\x27\n - 之前 build-image 被 skip(preflight 先失败)时,!failure() 为真导致 deploy 被触发但 IMAGE_NEW 空 → exit 9 误报为 deploy 本身错\n - 现在仅当 build-image 真的 success 时才会执行 deploy,build-image 被跳过/失败时 deploy 自动显示为 skipped(符合语义)
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m12s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 9m35s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
2026-09-13 00:21:09 +08:00
Henry_Du a64fa6bc72 ci(deploy): Gitea Actions 自动接入微信OAuth(构建期+运行期双链路注入)\n\n- deploy.yml preflight/nuxt build step: env 注入 6 条 OAuth secrets;显式 export 给子进程,构建日志打印 AGGREGATE_OAUTH_APP_ID 有无值便于排错\n- deploy.yml deploy/SSH step: env 注入 6 条 OAuth secrets 传给远端 bash -s\n - 远端新增 upsert_env_key():对 .env 做 awk 整行覆盖 / 不存在追加,幂等(保证不破坏原有 POSTGRES_* / JWT_SECRET / NUXT_PUBLIC_* 等其它变量)\n - 仅当 AGGREGATE_OAUTH_APP_ID 非空才写入,避免把空值覆盖已有配置\n - 合并写入后立即在日志输出 [OAuth] 成功/跳过提示,无需手改服务器 .env\n- docker-compose.yml 已含 env_file: .env,合并写入后的变量会被容器自动加载为 process.env
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 46s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s
2026-09-13 00:10:54 +08:00
Henry_Du 7f3e570d8a feat(auth): aggregate OAuth 支持 auxx.cn 自定义规格(按 endpoint 自动分派)\n\n- oauth-strategies.ts: endpoint 命中 auxx.cn 时走新的 auxxStrategy\n - getAuthorizeUrl: GET {endpoint}/connect.php?act=login 拿 {url} 后转发(附 state)\n - exchangeToken: 直接 GET {endpoint}/connect.php?act=callback 拿 social_uid/access_token/nickname/faceimg,10 分钟内存缓存供 getUserInfo 读取\n - getUserInfo: 命中缓存返回 OAuthUserInfo(id=social_uid, username=nickname, avatar=faceimg)\n- 标准 OAuth2 /oauth/... 规格仍由 standardAggregateStrategy 保留,非 auxx 域名不受影响
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m11s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Failing after 8m40s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Skipped
2026-09-12 23:07:05 +08:00
Henry_Du 7b605351f4 feat(auth): aggregate OAuth 支持环境变量回退配置,默认 LOGIN_TYPE=wx
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m11s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 7m12s
- oauth-strategies.ts: loadAggregateSettings() DB 未配置时回退读取 process.env.AGGREGATE_OAUTH_*,自动启用
- .env.example: AGGREGATE_OAUTH_LOGIN_TYPE 默认改为 wx,补充 auxx.cn 示例注释
2026-09-12 22:57:43 +08:00
Henry_Du 467dd38d5f feat(auth): 新增微信聚合登录入口与账号绑定卡\n\n- OAuthButtons.vue: aggregate 启用时渲染微信登录按钮,微信绿 hover + 专属 SVG 图标\n- OAuthBindingCard.vue: 新增微信 aggregate 绑定卡,支持绑定跳转 /api/auth/aggregate 和解绑\n- account/index.vue: hasOAuthProviders 判定加入 aggregate\n- oauth.ts: getProviderDisplayName 中 aggregate 映射为 微信
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m8s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 10m3s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
2026-09-12 21:06:26 +08:00
Henry_Du a937f32e6e feat(theme): 移除首页主题切换按钮,恢复默认深色主题 ClassicDark
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m12s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 8m59s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
- 首页 index.vue:删除主题切换按钮 DOM、useTheme 引入及 handleToggleTheme 处理函数
- nuxt.config.ts:defaultTheme ClassicLight → ClassicDark;enabledThemes 恢复为 4 主题(System/ClassicDark/ClassicLight/ModernLight);x-default-theme meta 和防闪烁脚本回退值同步改为 ClassicDark
- schema.ts / system-settings-defaults / site-config API / POST API / useSiteConfig:7 处主题默认配置全部从 ClassicLight/双浅色列表 同步改为 ClassicDark/四主题完整列表
2026-09-12 20:40:35 +08:00
Henry_Du a0f11ff64a chore: remove script
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m14s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 9m47s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
2026-09-12 16:57:53 +08:00
Henry_Du 78c3d878ea fix(ui): 修复浅色主题下的 Tailwind CSS 及部分残留硬编码
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Canceled after 1m35s
2026-09-12 16:56:22 +08:00
Henry_Du d8060147ed chore: remove temporary files
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m13s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 9m41s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
2026-09-12 15:57:27 +08:00
Henry_Du 9fc5e3e607 fix(ui): 批量替换所有组件中残留的硬编码深色值以彻底支持浅色主题
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Canceled after 1m27s
2026-09-12 15:56:10 +08:00
Henry_Du 8b0934f6f4 fix(ui): 彻底修复深浅色主题切换及内部页面样式
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m12s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 10m17s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
- 恢复 variables.css 完整的 ClassicDark/ModernDark 等主题定义
- 恢复 nuxt.config.ts 的主题系统逻辑(默认改为 ClassicLight,保留切换能力)
- 修复 BackupManager / VotersModal / ProfileForm / LyricsModal / RequestForm / SongList / ScheduleList / StatsSlide / ArtistSlide 等数十处组件内部硬编码深色(#1a1a1a / #040e15 等)绕过主题的问题,全部改为使用 CSS 变量 (var(--bg-primary) 等)
- 这样实现了:在浅色模式下,所有页面均正确显示浅色;在深色模式下,又能正常呈现深色设计,且恢复了右上角的深浅色切换能力
2026-09-12 15:23:26 +08:00
Henry_Du b1c3af84de fix(ui): 补齐浅色主题遗漏:index 后段弹窗 + dashboard 根 + 改密页根
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m10s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 10m4s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
- index.vue 规则弹窗 modal-content/close/rule/ripple/login-required 全改为语义化 var()
- index.vue 年度报告 year-review 卡片/按钮/brand/badge/title 全部变量化
- dashboard.vue admin-layout 根 #0a0a0a/#fff 改为 var(--bg/text-*)
- change-password.vue 改密页 auth-layout 容器、表单区、back-link 全改为浅色变量
2026-09-12 14:12:10 +08:00
Henry_Du bf7c40c4fe fix(ci): 回滚 NODE_OPTIONS 非法 --gc-interval,解决 Run#56 立即 exit 9
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m9s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Canceled after 0s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Canceled after 5m35s
- Node.js NODE_OPTIONS 不允许传 --gc-interval,导致 Run 56 刚启动 Node v0 检测时就 exit code 9 全局失败
- 保留 max-old-space-size=10240、expose-gc、drop_caches、DISABLE_ROLLDOWN=1、NITRO_MINIFY=false 所有有效内存缓解措施
2026-09-12 14:04:27 +08:00
Henry_Du 7ee5e8b9e9 fix(ci): 解决 Run#55 Nitro 再次 OOM:提升堆上限到 10G 并禁用压缩/minify
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 9s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 3s
- NODE_OPTIONS max-old-space-size 8192 → 10240,新增 --gc-interval=100000 主动 GC
- 构建前 drop_caches 清理页面缓存,降低系统级 RSS 压力
- 禁用 NITRO_MINIFY 与 DISABLE_ROLLDOWN,进一步降低峰值内存
- 构建前后打印内存快照,便于后续排障
2026-09-12 13:59:14 +08:00
Henry_Du 2ee8056ad1 fix(ui): 修复首页强制浅色不生效问题:index.vue 硬编码深色样式变量化
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 11m43s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 3s
- 根容器 .home 背景 #121318 深色硬编码改为 var(--bg-primary)
- 全站文字 color:#ffffff / rgba(255,255,255,*) 按语义改为 var(--text-primary/secondary/tertiary/quaternary)
- 选项卡、内容面板、下拉菜单、通知卡片、分页按钮等背景/边框统一变量化
- 桌面端和移动端适配样式同步处理,彻底杜绝硬编码深色值绕过 CSS 变量
2026-09-12 13:36:15 +08:00
Henry_Du 80b7c256b5 fix(ci): 修复 Run#53 Preflight 构建 Nitro 时 OOM exit 137
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m25s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 9m31s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
- 将 NODE_OPTIONS max-old-space-size 从 4096 提升至 8192,并加入 preflight job 全局 env
- 构建前打印系统内存状态,便于排障
- 显式声明 NITRO_PRESET=node-server,避免构建阶段默认解析不确定性
2026-09-12 12:57:32 +08:00
Henry_Du 94832e2c82 fix(ui): 强制浅色主题与登录页细节修正
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 2m43s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 3s
- 主题:将 CSS 变量默认值由深色改为 ClassicLight 浅色基准(背景/文字/边框/按钮/输入框/滚动条),追加 prefers-color-scheme: dark 下的 color-scheme: light 覆盖,补齐 surface-xxx 别名避免部分组件底色色值缺失
- 语言按钮:删除首页顶栏语言切换按钮及其点击处理逻辑
- 第三方登录:OAuthButtons 重写为使用语义化 CSS 变量而非无效的 --border-color,新增 alwaysShow 选项在未配置 provider 时显示占位提示;登录页强制启用提示
- 页脚与登录页:全部文字颜色与分割线由硬编码 rgba(255*) 改为 var(--text-tertiary/quaternary/border-primary),在浅色背景下保证对比度
2026-09-12 12:32:27 +08:00
Henry_Du a82ff6747e feat(site): 对齐参考站点UI与交互调整
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m8s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 9m11s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 7s
- 页脚:响应耗时文字统一为「响应耗时 XXXms」,版本链接改为 GitHub 官方仓库,默认 ICP 备案号设置为粤ICP备2025450093号
- 顶部栏:恢复主题切换按钮与语言按钮(语言暂为占位提示),放置于用户区左侧
- 主题:修复首屏内联脚本默认主题白名单校验,避免 localStorage 中残留的 light/dark/System 等非法值生效,首屏默认改为 ClassicLight(仅允许 ClassicLight/ModernLight)
- 登录页:在密码输入框下方恢复「忘记密码?」入口,新增配套的找回密码引导页 /forgot-password
- 默认配置:system-settings 兜底记录与 SYSTEM_SETTINGS_DEFAULTS 新增 icpNumber 默认值
2026-09-12 11:56:30 +08:00
Henry_Du 0c71328efd fix(nitro-import): permissions/user-avatar 引用路径统一改为 ~~/server/utils 别名,解决 Rollup UNRESOLVED_IMPORT
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Successful in 2m10s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Successful in 9m33s
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Successful in 6s
2026-09-12 10:27:07 +08:00
Henry_Du ca190a445a fix(vue-parser): BlacklistManager 与 ScheduleManager 补 lang=ts 解决 CI Babel 解析 SyntaxError(Unexpected token, expected ,)
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 1m57s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 3s
2026-09-12 08:55:29 +08:00
Henry_Du d818819fff feat: 实现主题、黑名单、排程等14项功能迭代
构建并部署 BYW VoiceHub / Preflight 语法与构建检查 (push) Failing after 1m24s
构建并部署 BYW VoiceHub / 构建并推送容器镜像 (push) Skipped
构建并部署 BYW VoiceHub / 部署到生产服务器 (push) Failing after 4s
- style(theme): 修复浅色模式不生效问题,为 ClassicLight 和 ModernLight 增加独立差异化 CSS 变量定义(主色/圆角/阴影/背景对比度)
- feat(blacklist): 后端放开 LANGUAGE/GENRE 类型校验;check API 新增语种(CJK/拉丁/假名/谚文/西里尔/阿拉伯)和曲风关键词匹配逻辑;前端 BlacklistManager UI 增加 语种/曲风 四类型切换、类型筛选、图标与标签颜色映射、输入提示
- fix(DataAnalysisPanel): 活跃用户列表容器加 max-h-[400px] + overflow-y-auto + 底部渐变遮罩;tooltip 加 max-height + truncate 防止超高;头像失败回退首字母
- feat(admin): 新增 user-avatar.ts 工具(OAuth→Gravatar→DiceBear 三级兜底);active-users/realtime/users 列表返回 avatarUrl;DataAnalysisPanel/UserManager 展示圆形头像;权限分级 ROLE_LEVELS,SUPER_ADMIN 全权限,ADMIN 仅可管理 ≤ USER 级用户的密码与角色,前端按钮联动禁用
- fix+style(schedule): ScheduleManager 标签页 sticky min-h 防抖、间距调优、表单分组、移动端 padding 统一
- feat(schedule): 自动排期新增「按投稿人数优先覆盖」策略(算法:requester + collaborators 总数降序),支持 CustomSelect 切换;新增 localStorage voicehub:schedule:auto-config 持久化 7 项配置(策略/时段/年级/排序/标签/搜索/学期)500ms debounce
- fix(schedule): songs/index.get 新增 usePool 参数,从 schedule_song_pool(status=PENDING,targetPlayDate IS NULL) 取歌并去重,排除已排期 songs.played=false;schedule.post 和 draft-bulk 分配后更新池状态为 ASSIGNED 避免重复分配
- feat(player): ScheduleManager 新增「构建播放队列」按钮,按 playDate→时段→sequence 排序后经 useAudioPlayer.playSong 整列载入并 toast 反馈
- fix(db): db.ts 检测 NETLIFY/VERCEL serverless 环境使用最小池 max=1 idle_timeout=1 keep_alive=false,Node Server 保持 max=10
- feat(site-config): site-config.get.ts 与 useSiteConfig.js 导出 statisticsCode 和 statisticsCodeEnabled;index.vue 挂载后解析 HTML 动态重插 script 元素确保第三方统计执行
- fix(schedule-printer): ScheduleItemPrint 封面 aspect-square object-cover;print-fix.css 打印时强制 48px×48px contain、去除圆角阴影
- fix(songRequest): 新增 server/utils/url.ts normalizeCoverUrl(trim/去零宽空格/相对协议补 https/非 http 丢弃/URL.parse 兜底);request.post/add.post/import.post 三处写入 cover/coverUrl 前统一规范化
2026-09-12 08:40:59 +08:00
173 changed files with 11796 additions and 3418 deletions
+35 -3
View File
@@ -106,10 +106,42 @@ CASDOOR_ENDPOINT=
GOOGLE_CLIENT_ID=
GOOGLE_CLIENT_SECRET=
# 聚合登陆 OAuth
# 聚合登陆 OAuth(如 auxx.cn 等聚合登录服务,支持微信/QQ 等通道
AGGREGATE_OAUTH_APP_ID=
AGGREGATE_OAUTH_APP_KEY=
# 支持单值、逗号分隔或 JSON 数组,例如:qq,wx,sina,baidu
# 可用值:qq,wx,alipay,sina,baidu,douyin,huawei,xiaomi,gitee,gitea,bilibili,kuaishou
AGGREGATE_OAUTH_LOGIN_TYPE=qq
AGGREGATE_OAUTH_ENDPOINT=https://example.com/connect.php
AGGREGATE_OAUTH_LOGIN_TYPE=wx
# 聚合登录服务基地址(https,不带 /oauth/... 路径),例如:https://www.auxx.cn
AGGREGATE_OAUTH_ENDPOINT=
# 微信小程序登录(可选)
# 用于小程序端 wx.login() 的 code 换取 openid,对应接口 POST /api/auth/wechat/login
# AppID / AppSecret 在微信公众平台「开发管理 → 开发设置 → 开发者ID」中获取
# 注意:这与上方聚合登录的 wx 通道不是一回事——小程序 openid 按 AppID 维度生成,两者不通用
WECHAT_MINI_APPID=
WECHAT_MINI_SECRET=
# 微信「订阅消息」模板(可选:**留空也能用**,代码里内置了站长选好的模板)
# 内置模板(唯一来源:server/utils/wechatSubscribeTemplates.ts 的 BUILTIN_TEMPLATES):
# SONG_SELECTED「歌曲排期通知」—— 管理员把歌排进排期/发布排期时发
# thing1→songTitle, phrase2→status, time3→playTime, character_string4→sequence
# (微信侧真实关键词,模板原文:歌曲名称/排期状态/播放时间/排期序号;**没有日期字段**,
# 日期含在 playTime 的 'YYYY-MM-DD HH:mm' 里)
# SONG_REJECTED「审核驳回通知」—— 投稿被驳回时发
# thing6→songTitle, thing4→reason
# 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的模板或字段名)。
# ⚠️ 必须是合法 JSON;写了坏值(例如只填一个光秃秃的模板 ID)服务端会**忽略它**并在启动日志里
# 打一行「不是合法 JSON,已忽略(仍用内置默认模板)」,属于可恢复状态,但请在体检接口里确认。
# {"SONG_SELECTED":{"id":"模板ID"}} ← 只换 ID,沿用内置字段映射
# {"SONG_REJECTED":{"id":"模板ID","fields":{"thing6":"songTitle"}}} ← 同时换字段映射
# {"SONG_PLAYED":{"id":"模板ID","fields":{"thing1":"songTitle"}}} ← 也可以补一张内置表里没有的模板
# fields 的键是模板里的字段名(thing1/phrase2/time3/character_string4,公众平台选用模板时能看到;
# **编号是微信自动分配的,不能靠命名规律猜** —— 猜错一位就是整条 47003。核不准就别写,
# 服务端会自动调 /wxaapi/newtmpl/gettemplate 读模板原文来校准),
# 值是变量名(可用:songTitle / message / time / playDate / playTime / sequence / status / reason)。
# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;只有绑定了微信的账号才有 openid;
# 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度(旧授权按模板 ID 记账,直接作废)。
# 发不出去时先看 GET /api/admin/wechat/diagnose(超管,只读不消耗额度):
# 它会把「拿不拿到 token / 有没有绑微信 / 通知开关开没开 / 还剩几条授权」逐项说清。
WECHAT_MINI_SUBSCRIBE_TEMPLATES=
+11
View File
@@ -0,0 +1,11 @@
# 强制 shell 脚本与工作流用 LF:runner 是 Linux,CRLF 会让 `run: |` 里的 heredoc / 多行脚本出问题
*.sh text eol=lf
*.yml text eol=lf
*.yaml text eol=lf
*.mjs text eol=lf
*.js text eol=lf
*.ts text eol=lf
*.json text eol=lf
# 迁移 SQL 也用 LF:这些文件会在容器里被 drizzle-kit 读取执行,不要混进 CRLF
*.sql text eol=lf
Dockerfile text eol=lf
+271 -425
View File
@@ -1,5 +1,14 @@
# 精简版部署流水线(2 个 job)
#
# 精简前是 4 个 job:preflight(npm ci + nuxt build)、meta-warmup(系统环境准备 + 装 buildx)、
# build-image(又装一次 buildx + docker build)、deploy。其中 preflight 和 meta-warmup 的产物
# 从来没被 docker build 用到(Dockerfile:24-59 自己在容器里跑 npm ci + node scripts/memory-build.js,
# 用的是镜像自己的分层缓存),两个 job 各跑 3-6 分钟纯属白烧;meta-warmup 还在 build-image 之前
# 用同一个 runner 抢内存。
#
# 现在:build-image(构建+推送) → deploy(SSH 滚动部署)。构建缓存交给 BuildKit 的
# registry 缓存(type=registry,缓存层存在 Gitea registry 的 :buildcache tag 里,跨 run 复用)。
name: 构建并部署 BYW VoiceHub
run-name: ${{ github.actor }} 触发部署 · Run ${{ github.run_number }}
on:
push:
@@ -7,291 +16,98 @@ on:
workflow_dispatch:
inputs:
deploy_target:
description: 构建完成后是否自动部署到服务器
description: "只构建不部署时不要勾选"
type: boolean
default: true
required: false
skip_build_cache:
description: 跳过 BuildKit 分层缓存,强制重新构建(排错用)
description: "跳过构建缓存(强制重新构建)"
type: boolean
default: false
required: false
concurrency:
group: deploy-production
cancel-in-progress: true
env:
GITEA_HOST: gitea.fse-media.group
DEPLOY_PATH: /data/opt/byw-voicehub
IMAGE_NAME: byw-voicehub
GH_PROXY_PREFIX: https://v4.gh-proxy.org/
APT_MIRROR_DEBIAN: https://mirrors.tuna.tsinghua.edu.cn/debian
APT_MIRROR_DEBIAN_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/debian-security
APT_MIRROR_DEBIAN_HTTP: http://mirrors.tuna.tsinghua.edu.cn/debian
APT_MIRROR_DEBIAN_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/debian-security
APT_MIRROR_UBUNTU: https://mirrors.tuna.tsinghua.edu.cn/ubuntu
APT_MIRROR_UBUNTU_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
APT_MIRROR_UBUNTU_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu
APT_MIRROR_UBUNTU_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
HTTP_PROXY_EXAMPLE: ""
HTTPS_PROXY_EXAMPLE: ""
# ========== Docker 官方镜像国内加速(Runner 拉 node:alpine / buildkit 等最常见卡点) ==========
# 注意:如果你知道哪个镜像源在内网最快,可以在仓库 Variables 里覆盖 DOCKER_REGISTRY_MIRROR_1/2/3
DOCKER_REGISTRY_MIRROR_1: https://docker.m.daocloud.io
DOCKER_REGISTRY_MIRROR_2: https://docker.1panel.live
DOCKER_REGISTRY_MIRROR_3: https://docker.1ms.run
# Dockerfile 内 FROM 前缀替换(ARG DOCKER_MIRROR=…),避免 buildx build 内部 FROM node:22-alpine 再被 Hub 限速
IMAGE_NAME: byw-voicehub
DOCKER_MIRROR_PREFIX: docker.m.daocloud.io/library/
NODE_BUILDER_TAG: "22-alpine"
NODE_RUNTIME_TAG: "22-alpine"
DOCKER_REGISTRY_MIRROR_1: https://docker.m.daocloud.io
DOCKER_REGISTRY_MIRROR_2: https://docker.1panel.live
DOCKER_REGISTRY_MIRROR_3: https://docker.1ms.run
DEPLOY_PATH: /data/opt/byw-voicehub
BUILDKIT_IMAGE: moby/buildkit:buildx-stable-1
# 构建缓存 tag(存在 Gitea registry 里,跨 run 复用)
CACHE_TAG: buildcache
jobs:
preflight:
name: Preflight 语法与构建检查
runs-on: ubuntu-latest
timeout-minutes: 15
# 关键修复 1:去掉 container.image: node:22-bookworm-slim
# Runner 在 Set up job 阶段会先 docker pull,国内连 Docker Hub 被限速会一直卡转圈。
# 关键修复 2:去掉 uses: actions/setup-node@v4
# setup-node 会直连 GitHub Releases (github.com/actions/node-versions/releases/download/...)
# 下载 Node 二进制,国内被限速 → 一直卡在 Acquiring node-v*-linux-x64.tar.gz。
# 与 build-image job「禁止第三方 uses、全部用代理/国内源」风格完全对齐:
# - Runner 自带 node ≥22 直接用
# - 否则优先 npmmirror 国内镜像下载 Node 22 二进制
# - 最后兜底用 GH_PROXY_PREFIX 代理 GitHub node-versions Releases 直链
env:
NODE_MAJOR: "22"
NODE_VERSION_FALLBACK: "22.13.0"
NODE_MIRROR_PRIMARY: "https://registry.npmmirror.com/-/binary/node"
steps:
- name: 安装基础运行依赖(git/curl/ca-certificates/tar/xz-utils,Runner 自带)
run: |
set -euxo pipefail
command -v git || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends git ca-certificates)
command -v curl || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends curl ca-certificates)
command -v tar || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends tar)
command -v xz || command -v unxz || (apt-get update -qq && DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends xz-utils) || true
# 预先打印 Runner 自带版本,方便排障
command -v node && echo "系统 node: $(node -v)" || echo "系统 node: 未安装"
command -v npm && echo "系统 npm: $(npm -v)" || echo "系统 npm: 未安装"
- name: 确保 Node.js 22 可用(npmmirror 优先 / gh-proxy 兜底 / 绝不直连 GitHub)
run: |
set -euxo pipefail
# 先判断:当前系统 node 主版本 ≥ NODE_MAJOR 直接跳过
if command -v node >/dev/null 2>&1; then
CUR_NODE_MAJOR="$(node -v | tr -d 'v' | cut -d. -f1 || echo 0)"
if [ "${CUR_NODE_MAJOR:-0}" -ge "${NODE_MAJOR}" ]; then
echo "Runner 已自带 Node $(node -v)(≥ v${NODE_MAJOR}.x),直接复用,不下载"
node -v; npm -v
exit 0
fi
echo "Runner 自带 Node v${CUR_NODE_MAJOR} 不足 v${NODE_MAJOR}.x,手动升级"
fi
ARCH="$(uname -m)"
case "$ARCH" in
x86_64|amd64) PLAT="linux-x64" ;;
aarch64|arm64) PLAT="linux-arm64" ;;
armv7l) PLAT="linux-armv7l" ;;
*) echo "不支持的架构:$ARCH" >&2; exit 1 ;;
esac
echo "目标架构:$PLAT"
INSTALL_DIR="${RUNNER_TEMP:-/tmp}/node-${NODE_MAJOR}"
mkdir -p "$INSTALL_DIR"
# 找 Node 22 最新 patch 版本号:不用 GitHub API,走 npmmirror 的 index.json 解析
NODE_VER="${NODE_VERSION_FALLBACK}"
if command -v python3 >/dev/null 2>&1; then
PY_NODE_B64="aW1wb3J0IGpzb24sIHJlLCBzeXMKdHJ5OgogICAgZGF0YSA9IGpzb24ubG9hZChzeXMuc3RkaW4pCmV4Y2VwdCBFeGNlcHRpb246CiAgICBkYXRhID0gW10KdGFyZ2V0ID0gc3lzLmFyZ3ZbMV0gaWYgbGVuKHN5cy5hcmd2KSA+IDEgZWxzZSAiMjIiCmJlc3RfdHJpcGxlID0gTm9uZQpiZXN0X3YgPSAiIgpmb3IgZCBpbiBkYXRhOgogICAgdiA9IGQuZ2V0KCJ2ZXJzaW9uIiwgIiIpCiAgICBtID0gcmUubWF0Y2gociJedihcZCspXC4oXGQrKVwuKFxkKykiLCB2KQogICAgaWYgbm90IG06IGNvbnRpbnVlCiAgICBtYWpvciwgbWlub3IsIHBhdGNoID0gbS5ncm91cHMoKQogICAgaWYgbWFqb3IgIT0gdGFyZ2V0OiBjb250aW51ZQogICAgdHJpcGxlID0gKGludChtaW5vciksIGludChwYXRjaCkpCiAgICBpZiAoYmVzdF90cmlwbGUgaXMgTm9uZSkgb3IgKHRyaXBsZSA+IGJlc3RfdHJpcGxlKToKICAgICAgICBiZXN0X3RyaXBsZSA9IHRyaXBsZQogICAgICAgIGJlc3RfdiA9IGYie21ham9yfS57bWlub3J9LntwYXRjaH0iCmlmIGJlc3RfdjoKICAgIHByaW50KGJlc3Rfdik="
REMOTE_LATEST=""
REMOTE_LATEST="$(curl -fsSL --max-time 20 "${NODE_MIRROR_PRIMARY}/index.json" 2>/dev/null \
| printf '%s' "$PY_NODE_B64" | base64 -d | python3 - "${NODE_MAJOR}" || true)"
if [ -n "$REMOTE_LATEST" ]; then
NODE_VER="$REMOTE_LATEST"
echo "npmmirror 查得 Node 22 latest: v${NODE_VER}"
else
echo "npmmirror index 查询失败,使用兜底: v${NODE_VER}"
fi
else
echo "python3 不可用,跳过 npmmirror 最新版本探测,使用兜底: v${NODE_VER}"
fi
TAR_XZ="node-v${NODE_VER}-${PLAT}.tar.xz"
mkdir -p /tmp/node-dl-cache
# 下载 URL 1:npmmirror 国内二进制镜像(首选,国内最快)
URL_1="${NODE_MIRROR_PRIMARY}/v${NODE_VER}/${TAR_XZ}"
# 下载 URL 2:nodejs.org 官方直链 + GH_PROXY 前缀兜底(绝不直连 GitHub.com)
URL_2="${GH_PROXY_PREFIX}https://nodejs.org/dist/v${NODE_VER}/${TAR_XZ}"
DL_OK=0
for URL in "$URL_1" "$URL_2"; do
echo "尝试下载:$URL"
if curl -fsSL --max-time 300 --retry 3 --retry-delay 2 -o "/tmp/node-dl-cache/${TAR_XZ}" "$URL"; then
echo "下载 OK:$URL"
DL_OK=1
break
fi
echo "下载失败,尝试下一个源:$URL"
done
if [ "$DL_OK" != "1" ]; then
echo "所有 Node 下载源均失败(npmmirror + gh-proxy),中止" >&2
exit 1
fi
# 解压并加到 PATH,GITHUB_ENV 注入所有后续 step 自动继承
tar -xJf "/tmp/node-dl-cache/${TAR_XZ}" -C "$INSTALL_DIR" --strip-components=1
if [ -f "$GITHUB_ENV" ]; then
echo "PATH=$INSTALL_DIR/bin:$PATH" >> "$GITHUB_ENV"
fi
export PATH="$INSTALL_DIR/bin:$PATH"
hash -r 2>/dev/null || true
node -v
npm -v
# 把 npm 全局前缀也设在这个目录下,避免权限问题
npm config set prefix "$INSTALL_DIR/npm-global" 2>/dev/null || true
- name: 校验 Node + npm + git 可用
run: |
set -euxo pipefail
# 全局 git 代理:任何 git clone/fetch/pull https://github.com/... 自动走 gh-proxy 前缀
git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
# npm registry 走 npmmirror,避免后续 npm ci 也卡
npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || true
git --version
node -v
npm -v
- name: 检出代码
run: |
set -euxo pipefail
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
BRANCH="${GITHUB_REF_NAME:-main}"
echo "Clone from: ${REPO_URL} (${BRANCH})"
rm -rf * .[^.]* 2>/dev/null || true
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
git rev-parse --short HEAD
- name: 安装依赖(npm ci)
run: |
set -euxo pipefail
npm config set registry https://registry.npmmirror.com || true
npm config set fund false
npm config set audit false
npm ci --legacy-peer-deps
- name: 检查 package.json 无 pnpm 残留
run: |
set -euo pipefail
if grep -qE '"packageManager"\s*:\s*"pnpm' package.json; then
echo "package.json 中发现 pnpm 包管理器声明,与要求冲突,构建中止"
exit 1
fi
echo "package.json 未发现 pnpm 声明"
- name: 执行 nuxt build
run: |
set -euxo pipefail
NODE_OPTIONS="--max-old-space-size=4096" npm run build
echo ".output/ 生成结果:"
ls -la .output/ 2>/dev/null || true
du -sh .output 2>/dev/null || true
build-image:
name: 构建并推送容器镜像
needs: preflight
name: 构建并推送镜像
runs-on: ubuntu-latest
timeout-minutes: 40
outputs:
image_tag: ${{ steps.meta.outputs.tag }}
image_full: ${{ steps.meta.outputs.full }}
image_latest: ${{ steps.meta.outputs.latest }}
image_tag: ${{ steps.meta.outputs.image_tag }}
image_full: ${{ steps.meta.outputs.image_full }}
image_latest: ${{ steps.meta.outputs.image_latest }}
steps:
- name: 系统环境准备(Ubuntu 22.04 默认镜像源换清华 + 全局 GitHub gh-proxy)
- name: 准备 Docker(registry-mirrors)
run: |
set -euxo pipefail
# ========== Step 0:先装 ca-certificates(默认 HTTP archive.ubuntu.com 不需要证书,解鸡生蛋) ==========
if command -v apt-get >/dev/null 2>&1; then
sudo apt-get update -qq 2>/dev/null || apt-get update -qq 2>/dev/null || true
sudo DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates 2>/dev/null || \
DEBIAN_FRONTEND=noninteractive apt-get install -yqq --no-install-recommends ca-certificates 2>/dev/null || true
sudo update-ca-certificates 2>/dev/null || update-ca-certificates 2>/dev/null || true
fi
# ========== Step 1:有了证书后换 HTTPS 清华源 ==========
# Ubuntu 22.04 默认是 http://archive.ubuntu.com/ubuntu 和 http://security.ubuntu.com/ubuntu
if command -v apt-get >/dev/null 2>&1 && [ -f /etc/apt/sources.list ]; then
sudo sed -i \
-e 's|http://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
-e 's|http://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
-e 's|https://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
-e 's|https://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
/etc/apt/sources.list
fi
if command -v apt-get >/dev/null 2>&1 && ls /etc/apt/sources.list.d/*.sources 1>/dev/null 2>&1; then
for f in /etc/apt/sources.list.d/*.sources; do
sudo sed -i \
-e 's|http://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
-e 's|http://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
-e 's|https://archive.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU}"'|g' \
-e 's|https://security.ubuntu.com/ubuntu|'"${APT_MIRROR_UBUNTU_SECURITY}"'|g' \
"${f}" 2>/dev/null || true
done
fi
# 全局 git:所有 https://github.com/ 自动走 gh-proxy 前缀
sudo git config --system url."${GH_PROXY_PREFIX}https://github.com/".insteadOf "https://github.com/" || true
# npm:强制用 npmmirror(与 preflight 对齐)
npm config set registry https://registry.npmmirror.com >/dev/null 2>&1 || true
# ========== 关键修复:宿主机 Docker daemon 先注入国内 registry mirrors ==========
# 原因:Runner 任何 docker pull / buildx create(moby/buildkit 拉基础镜像)都要先经过 daemon
# 如果直接连 docker.io,国内会被限速到 <200KB/s,导致 Setup / BuildKit 拉镜像一直卡。
# 注意:此处用 PY_CODE 字符串 + printf 管道传 Python 代码给 stdin,
# 避免 <<'PY' heredoc 结构与 YAML run:| 块标量缩进冲突导致的 could not find expected ':' 报错。
DOCKER_DAEMON_CONF="/etc/docker/daemon.json"
if command -v docker >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then
sudo mkdir -p /etc/docker 2>/dev/null || true
OLD_MIRRORS_JSON="{}"
if [ -f "${DOCKER_DAEMON_CONF}" ]; then
OLD_MIRRORS_JSON="$(sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || echo "{}")"
fi
PY_DAEMON_B64="aW1wb3J0IGpzb24sIHN5cwp0cnk6CiAgICBkYXRhID0ganNvbi5sb2FkcyhzeXMuYXJndlsxXSBvciAie30iKQpleGNlcHQgRXhjZXB0aW9uOgogICAgZGF0YSA9IHt9Cm1pcnJvcnNfZW52cyA9IFttIGZvciBtIGluIHN5cy5hcmd2WzI6NV0gaWYgbV0KZXhpc3RpbmcgPSBkYXRhLmdldCgicmVnaXN0cnktbWlycm9ycyIpIG9yIFtdCmlmIGlzaW5zdGFuY2UoZXhpc3RpbmcsIGxpc3QpOgogICAgY29tYmluZWQgPSBsaXN0KGRpY3QuZnJvbWtleXMoWyptaXJyb3JzX2VudnMsICpleGlzdGluZ10pKQplbHNlOgogICAgY29tYmluZWQgPSBtaXJyb3JzX2VudnMKaWYgY29tYmluZWQ6CiAgICBkYXRhWyJyZWdpc3RyeS1taXJyb3JzIl0gPSBjb21iaW5lZApkYXRhLnNldGRlZmF1bHQoImxvZy1kcml2ZXIiLCAianNvbi1maWxlIikKcHJpbnQoanNvbi5kdW1wcyhkYXRhLCBlbnN1cmVfYXNjaWk9RmFsc2UsIGluZGVudD0yKSk="
NEW_DAEMON_JSON="$(printf '%s' "$PY_DAEMON_B64" | base64 -d | python3 - "$OLD_MIRRORS_JSON" \
"${DOCKER_REGISTRY_MIRROR_1}" "${DOCKER_REGISTRY_MIRROR_2}" "${DOCKER_REGISTRY_MIRROR_3}")"
echo "${NEW_DAEMON_JSON}" | sudo tee "${DOCKER_DAEMON_CONF}" >/dev/null
echo "[Docker] daemon.json 已注入 registry-mirrors:"
sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || true
# 非侵入式重启 docker;runner 没 systemd 时用 service 兜底;失败也跳过(buildkitd.toml 内部仍有 mirrors)
sudo tee /etc/docker/daemon.json >/dev/null <<JSON
{
"registry-mirrors": [
"${DOCKER_REGISTRY_MIRROR_1}",
"${DOCKER_REGISTRY_MIRROR_2}",
"${DOCKER_REGISTRY_MIRROR_3}"
]
}
JSON
sudo systemctl restart docker 2>/dev/null \
|| sudo service docker restart 2>/dev/null \
|| echo "[Docker] systemctl/service 重启失败,跳过(后续 buildx builder 内部仍有 registry mirrors)"
sleep 3 2>/dev/null || true
|| echo "[Docker] 重启失败,继续(下面用显式 mirror 前缀拉镜像兜底)"
sleep 3 || true
fi
echo "环境准备完成"
cat /etc/os-release 2>/dev/null | head -4 || true
docker --version
docker buildx version || echo "buildx 未安装"
docker info 2>/dev/null | sed -n '/Registry Mirrors/,/^$/p' || true
- name: 检出代码
run: |
set -euxo pipefail
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
BRANCH="${GITHUB_REF_NAME:-main}"
echo "Clone from: ${REPO_URL} (${BRANCH})"
rm -rf * .[^.]* 2>/dev/null || true
git clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
git rev-parse --short HEAD
docker version
docker buildx version || echo "buildx 未安装,将使用 docker build --push"
- name: 注入 OAuth secrets(构建期 build-arg)
env:
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
run: |
set -euo pipefail
for pair in \
"OAUTH_REDIRECT_URI:${OAUTH_REDIRECT_URI:-}" \
"OAUTH_STATE_SECRET:${OAUTH_STATE_SECRET:-}" \
"AGGREGATE_OAUTH_APP_ID:${AGGREGATE_OAUTH_APP_ID:-}" \
"AGGREGATE_OAUTH_APP_KEY:${AGGREGATE_OAUTH_APP_KEY:-}" \
"AGGREGATE_OAUTH_LOGIN_TYPE:${AGGREGATE_OAUTH_LOGIN_TYPE:-}" \
"AGGREGATE_OAUTH_ENDPOINT:${AGGREGATE_OAUTH_ENDPOINT:-}"; do
K="${pair%%:*}"; V="${pair#*:}"
if [ -n "${V}" ]; then
printf '%s="%s"\n' "$K" "$V" >> "$GITHUB_ENV"
echo "[OAuth-secrets] $K=已注入 (len=${#V})"
else
echo "[OAuth-secrets] $K=未配置"
fi
done
- name: 计算镜像标签
id: meta
run: |
set -euo pipefail
# Docker Registry 规范:仓库路径、owner、repo name 必须全小写(Henry_Du/BYW-VoiceHub → henry_du/byw-voicehub)
REPO_OWNER="${GITHUB_REPOSITORY_OWNER,,}"
REPO_NAME="${IMAGE_NAME,,}"
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
@@ -299,114 +115,115 @@ jobs:
DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}"
IMG_FULL="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:${DATE_TAG}"
IMG_LATEST="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:latest"
CACHE_REF_INNER="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:buildcache"
echo "tag=${DATE_TAG}" >> "$GITHUB_OUTPUT"
echo "full=${IMG_FULL}" >> "$GITHUB_OUTPUT"
echo "latest=${IMG_LATEST}" >> "$GITHUB_OUTPUT"
echo "cache_ref=${CACHE_REF_INNER}" >> "$GITHUB_OUTPUT"
echo "repo_owner=${REPO_OWNER}" >> "$GITHUB_OUTPUT"
echo "repo_name=${REPO_NAME}" >> "$GITHUB_OUTPUT"
echo "======================================"
CACHE_REF="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:${CACHE_TAG}"
{
echo "image_tag=${DATE_TAG}"
echo "image_full=${IMG_FULL}"
echo "image_latest=${IMG_LATEST}"
echo "cache_ref=${CACHE_REF}"
} >> "$GITHUB_OUTPUT"
echo "Image tag : ${DATE_TAG}"
echo "Full image : ${IMG_FULL}"
echo "Latest image : ${IMG_LATEST}"
echo "Cache ref : ${CACHE_REF_INNER}"
echo "======================================"
echo "Cache ref : ${CACHE_REF}"
- name: 初始化 Docker Buildx builder(带国内 registry-mirrors + 内置 Gitea Basic Auth)
- name: 安装 BuildKit builder(可选)+ 登录 registry
id: buildx
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
REGISTRY_PULL_USER: ${{ secrets.REGISTRY_PULL_USER }}
REGISTRY_PULL_TOKEN: ${{ secrets.REGISTRY_PULL_TOKEN }}
run: |
set -euxo pipefail
# 先校验凭据非空(避免 buildkitd.toml 写空用户名/密码导致 push 时 401)
if [ -z "${REGISTRY_TOKEN}" ]; then
echo "ERROR: REGISTRY_TOKEN 为空。请在 Repository Secrets 中新建 REGISTRY_TOKEN(个人应用令牌,权限 read:package + write:package + read:repository)"
REGISTRY_USER="${REGISTRY_USER,,}"
# 兜底登录凭证:REGISTRY_* 优先,缺失则用 REGISTRY_PULL_*
LOGIN_USER="${REGISTRY_USER}"
LOGIN_TOKEN="${REGISTRY_TOKEN}"
if [ -z "${LOGIN_TOKEN}" ] || [ "${LOGIN_TOKEN}" = "null" ]; then
LOGIN_USER="${REGISTRY_PULL_USER:-}"
LOGIN_TOKEN="${REGISTRY_PULL_TOKEN:-}"
fi
if [ -z "${LOGIN_TOKEN}" ]; then
echo "ERROR: REGISTRY_TOKEN / REGISTRY_PULL_TOKEN 均为空,无法登录 ${GITEA_HOST}"
exit 1
fi
REGISTRY_USER="${REGISTRY_USER,,}"
BUILDER_NAME="multi"
BUILDKIT_IMAGE="moby/buildkit:buildx-stable-1"
BKK_CFG="$(mktemp -t buildkitd_XXXXXX.toml)"
# 用 printf 逐行写 buildkitd 配置,避免 YAML parser 把 heredoc 内部的 [...] 误判为 YAML key
{
printf '[worker.oci]\n max-parallelism = 2\n\n'
printf '[registry."docker.io"]\n mirrors = ["docker.m.daocloud.io","dockerproxy.com","hub-mirror.c.163.com","docker.nju.edu.cn","docker.mirrors.ustc.edu.cn"]\n\n'
printf '[registry."ghcr.io"]\n mirrors = ["ghcr.nju.edu.cn","ghcr.dockerproxy.com"]\n\n'
printf '[registry."k8s.gcr.io"]\n mirrors = ["k8s-gcr.m.daocloud.io","registry.cn-hangzhou.aliyuncs.com/google_containers"]\n\n'
# 关键:Gitea Container Registry 直接用 Basic Auth 凭据写死在 buildkitd.toml
# 绕开 buildkitd 的 OAuth2 flow(Gitea 1.27.x 有时返回空 token body:authorization server did not include a token)
printf '[registry."%s"]\n username = "%s"\n password = "%s"\n' "${GITEA_HOST}" "${REGISTRY_USER}" "${REGISTRY_TOKEN}"
} > "${BKK_CFG}"
# 预拉 buildkitd 基础镜像(优先 5 个国内镜像 fallback,避免 Docker Hub <200KB/s)
PULLED=0
for MIRROR in \
"docker.m.daocloud.io/${BUILDKIT_IMAGE}" \
"dockerproxy.com/${BUILDKIT_IMAGE}" \
"hub-mirror.c.163.com/${BUILDKIT_IMAGE}" \
"docker.nju.edu.cn/${BUILDKIT_IMAGE}" \
"docker.mirrors.ustc.edu.cn/${BUILDKIT_IMAGE}" \
# 先把 buildkitd 基础镜像拉到本地(多 mirror 轮询),装完插件后直接复用
for M in \
"${DOCKER_REGISTRY_MIRROR_1#https://}/${BUILDKIT_IMAGE}" \
"${DOCKER_REGISTRY_MIRROR_2#https://}/${BUILDKIT_IMAGE}" \
"${DOCKER_REGISTRY_MIRROR_3#https://}/${BUILDKIT_IMAGE}" \
"${BUILDKIT_IMAGE}"; do
if docker pull "${MIRROR}" 2>/dev/null; then
if [ "${MIRROR}" != "${BUILDKIT_IMAGE}" ]; then
docker tag "${MIRROR}" "${BUILDKIT_IMAGE}" 2>/dev/null || true
fi
PULLED=1
if docker pull "${M}" 2>/dev/null; then
[ "${M}" = "${BUILDKIT_IMAGE}" ] || docker tag "${M}" "${BUILDKIT_IMAGE}" 2>/dev/null || true
break
fi
done
[ "${PULLED}" = "1" ] || { echo "ERROR: 无法拉取 buildkitd 基础镜像,请检查 Docker registry-mirrors"; exit 1; }
# 宿主 Docker daemon 也同步登录(后面镜像体积与层信息 Step 的 docker image inspect / docker history 要用)
printf '%s' "${REGISTRY_TOKEN}" | docker login "${GITEA_HOST}" \
-u "${REGISTRY_USER}" --password-stdin
echo "宿主 Docker 登录成功:${REGISTRY_USER} @ ${GITEA_HOST}"
# 立即重建 buildx builder(带凭据 + registry-mirrors)
docker buildx rm "${BUILDER_NAME}" 2>/dev/null || true
docker buildx create \
--name "${BUILDER_NAME}" \
--driver docker-container \
--use \
--config "${BKK_CFG}" \
--buildkitd-flags '--allow-insecure-entitlement security.insecure --allow-insecure-entitlement network.host' \
--driver-opt network=host \
--driver-opt "image=${BUILDKIT_IMAGE}"
rm -f "${BKK_CFG}"
# gh-proxy 环境(全变量显式赋值,避免 bash tracing 边界反引号歧义)
GIT_CONFIG_KEY_0="url.${GH_PROXY_PREFIX}https://github.com/.insteadOf"
GIT_CONFIG_VALUE_0="https://github.com/"
GIT_CONFIG_KEY_1="url.${GH_PROXY_PREFIX}https://gist.github.com/.insteadOf"
GIT_CONFIG_VALUE_1="https://gist.github.com/"
export GIT_CONFIG_COUNT=2 GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0 GIT_CONFIG_KEY_1 GIT_CONFIG_VALUE_1
docker buildx inspect --bootstrap
echo "Builder 启动成功,buildkitd.toml 已内置 ${GITEA_HOST} Basic Auth 凭据,push 不再走 OAuth2 flow"
- name: 确认已登录(凭据已注入 buildkitd.toml 与宿主 Docker daemon)
run: |
set -euo pipefail
# 前面「初始化 Docker Buildx builder」Step 已完成两次登录:
# 1. 宿主 Docker daemon(镜像体积 Step 要用);2. buildkitd.toml(buildx push 要用,Basic Auth 不走 OAuth2)
# 这里只做一个快速断言,确保宿主登录成功
docker info 2>/dev/null | grep -E "Username|Registry" || echo "已存在登录凭据(跳过详细输出)"
echo "登录确认完成,REGISTRY_USER=${REGISTRY_USER:-${GITHUB_ACTOR,,}},GITEA_HOST=${GITEA_HOST}"
env:
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
printf '%s' "${LOGIN_TOKEN}" | docker login "${GITEA_HOST}" -u "${LOGIN_USER}" --password-stdin
# buildx 插件不是必须的:没有就走普通 docker build(仍是同一份 Dockerfile,
# 只是没有跨 run 的 registry 构建缓存)。绝不因为装不上而让部署失败。
if docker buildx version >/dev/null 2>&1 \
&& docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
echo "[buildx] 插件已可用:$(docker buildx version 2>&1 | head -1)"
else
echo "[buildx] 未发现可用插件,尝试安装官方 buildx"
ARCH="$(uname -m)"
case "${ARCH}" in
x86_64|amd64) BX_ARCH=amd64 ;;
aarch64|arm64) BX_ARCH=arm64 ;;
*) BX_ARCH="" ;;
esac
if [ -n "${BX_ARCH}" ]; then
BX_VER="v0.19.3"
BX_DIR="${HOME}/.docker/cli-plugins"
mkdir -p "${BX_DIR}"
curl -fsSL --retry 3 --connect-timeout 20 -o "${BX_DIR}/docker-buildx" \
"${GH_PROXY_PREFIX}https://github.com/docker/buildx/releases/download/${BX_VER}/buildx-${BX_VER}.linux-${BX_ARCH}" \
|| curl -fsSL --retry 3 --connect-timeout 20 -o "${BX_DIR}/docker-buildx" \
"https://github.com/docker/buildx/releases/download/${BX_VER}/buildx-${BX_VER}.linux-${BX_ARCH}" \
|| rm -f "${BX_DIR}/docker-buildx"
[ -f "${BX_DIR}/docker-buildx" ] && chmod +x "${BX_DIR}/docker-buildx" || true
fi
fi
if docker buildx version >/dev/null 2>&1 \
&& docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
docker buildx rm multi 2>/dev/null || true
if docker buildx create --name multi --driver docker-container --use \
--driver-opt network=host \
--driver-opt "image=${BUILDKIT_IMAGE}" \
&& docker buildx inspect --bootstrap >/dev/null 2>&1; then
echo "BUILDX_USABLE=yes" >> "$GITHUB_ENV"
echo "[buildx] builder multi 就绪(启用 registry 构建缓存)"
else
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
echo "[buildx] builder 创建失败,转 docker build 兜底"
fi
else
echo "BUILDX_USABLE=no" >> "$GITHUB_ENV"
echo "[buildx] 插件不可用,转 docker build 兜底(无跨 run 缓存)"
fi
- name: 构建并推送镜像
env:
SKIP_CACHE: ${{ inputs.skip_build_cache }}
IMG_FULL: ${{ steps.meta.outputs.full }}
IMG_LATEST: ${{ steps.meta.outputs.latest }}
IMG_FULL: ${{ steps.meta.outputs.image_full }}
IMG_LATEST: ${{ steps.meta.outputs.image_latest }}
CACHE_REF: ${{ steps.meta.outputs.cache_ref }}
OCS_SOURCE_RAW: ${{ github.server_url }}/${{ github.repository }}
OCS_REVISION: ${{ github.sha }}
OCS_CREATED_RAW: ${{ github.event.head_commit.timestamp }}
run: |
set -euxo pipefail
# 兜底空值 & 全小写规范
OCS_SOURCE="${OCS_SOURCE_RAW,,}"
OCS_CREATED="${OCS_CREATED_RAW:-$(date -u -Iseconds)}"
# 再次保险:所有镜像标签字符串都确认无大写字母(Registry 硬性规范)
IMG_FULL="${IMG_FULL,,}"
IMG_LATEST="${IMG_LATEST,,}"
CACHE_REF="${CACHE_REF,,}"
BUILD_ARGS=(
--build-arg BUILDKIT_INLINE_CACHE=1
@@ -415,55 +232,67 @@ jobs:
--build-arg NODE_BUILDER_TAG="${NODE_BUILDER_TAG}"
--build-arg NODE_RUNTIME_TAG="${NODE_RUNTIME_TAG}"
)
# 说明:暂时关闭 registry 分层缓存(buildkitd 推 buildcache 也要认证,同样会触发 Gitea 1.27 OAuth2 空 token)
# 改为本地 Docker daemon 缓存,已构建的层会自动复用,下次 build 速度几乎相同
BUILD_ARGS+=( --no-cache )
echo "已启用强制重新构建(跳过 registry 分层缓存,避免 buildkitd 推 cache 层时 OAuth2 认证失败)"
for V in OAUTH_REDIRECT_URI OAUTH_STATE_SECRET AGGREGATE_OAUTH_APP_ID \
AGGREGATE_OAUTH_APP_KEY AGGREGATE_OAUTH_LOGIN_TYPE AGGREGATE_OAUTH_ENDPOINT; do
eval "VAL=\${${V}:-}"
if [ -n "${VAL}" ]; then
BUILD_ARGS+=( --build-arg "${V}=${VAL}" )
echo "[build-arg] ${V}=已注入"
else
echo "[build-arg] ${V}=空"
fi
done
# ========== 阶段 1:构建到本地 Docker daemon(--load,不用 --push) ==========
# 关键:这一步产物在宿主 Docker 的 images 列表里(docker images 能看到),宿主 docker login 过的凭据 100% 可用
# 完全绕开 buildkitd 容器内部推镜像走 OAuth2 flow 的坑
docker buildx build \
--file ./Dockerfile \
--platform linux/amd64 \
--load \
--provenance=false \
"${BUILD_ARGS[@]}" \
--tag "${IMG_FULL}" \
--tag "${IMG_LATEST}" \
--label "org.opencontainers.image.source=${OCS_SOURCE}" \
--label "org.opencontainers.image.revision=${OCS_REVISION}" \
--label "org.opencontainers.image.created=${OCS_CREATED}" \
.
TAGS=( --tag "${IMG_FULL}" --tag "${IMG_LATEST}" )
LABELS=(
--label "org.opencontainers.image.source=${OCS_SOURCE}"
--label "org.opencontainers.image.revision=${OCS_REVISION}"
--label "org.opencontainers.image.created=${OCS_CREATED}"
)
if [ "${BUILDX_USABLE:-no}" = "yes" ]; then
echo "[build] docker buildx build(含 registry 缓存)"
CACHE_ARGS=()
if [ "${SKIP_CACHE}" = "true" ]; then
echo "[build] skip_build_cache=true → 不使用缓存,仅更新缓存"
else
CACHE_ARGS+=( --cache-from "type=registry,ref=${CACHE_REF}" )
fi
CACHE_ARGS+=( --cache-to "type=registry,ref=${CACHE_REF},mode=max" )
if ! docker buildx build \
--file ./Dockerfile \
--platform linux/amd64 \
--load \
--provenance=false \
"${BUILD_ARGS[@]}" "${CACHE_ARGS[@]}" "${TAGS[@]}" "${LABELS[@]}" . ; then
echo "[build] 带 registry 缓存的构建失败(可能是本机 docker 版本不认 registry 缓存导出),去掉缓存重试一次"
docker buildx build \
--file ./Dockerfile \
--platform linux/amd64 \
--load \
--provenance=false \
"${BUILD_ARGS[@]}" "${TAGS[@]}" "${LABELS[@]}" .
fi
else
echo "[build] docker build 兜底(无构建缓存)"
docker build \
--file ./Dockerfile \
--platform linux/amd64 \
"${BUILD_ARGS[@]}" "${TAGS[@]}" "${LABELS[@]}" .
fi
# 断言:两个 tag 都真的加载到了本地 Docker daemon
docker image inspect "${IMG_FULL}" >/dev/null
docker image inspect "${IMG_LATEST}" >/dev/null
echo "构建成功,已加载到本地 Docker daemon:${IMG_FULL} + ${IMG_LATEST}"
echo "镜像体积(Pre-push):"
docker image inspect "${IMG_FULL}" --format='{{.Size}}' | awk '{printf " Full image : %.2f MB\n", $1/1024/1024}'
# ========== 阶段 2:宿主 Docker daemon 原生 push(100% 走宿主登录凭据,不会走 OAuth2) ==========
echo "开始推送:${IMG_FULL}"
docker image inspect "${IMG_FULL}" --format='{{.Size}}' \
| awk '{printf "镜像体积:%.2f MB\n", $1/1024/1024}'
docker push "${IMG_FULL}"
echo "开始推送:${IMG_LATEST}"
docker push "${IMG_LATEST}"
echo "推送完成:2 个 tag(date-sha + latest)"
- name: 镜像体积与层信息
env:
IMG_FULL: ${{ steps.meta.outputs.full }}
run: |
set -euxo pipefail
docker pull "${IMG_FULL}" || true
docker image inspect "${IMG_FULL}" --format='{{.Size}}' 2>/dev/null \
| awk '{printf "Image size: %.2f MB\n", $1/1024/1024}' || true
docker history --no-trunc "${IMG_FULL}" 2>/dev/null | head -20 || true
echo "推送完成:${IMG_FULL} + ${IMG_LATEST}"
deploy:
name: 部署到生产服务器
needs: build-image
if: ${{ !cancelled() && !failure() && (github.event_name == 'push' || inputs.deploy_target == true) }}
needs: [build-image]
if: needs.build-image.result == 'success' && (github.event_name == 'push' || inputs.deploy_target == true)
runs-on: ubuntu-latest
timeout-minutes: 25
environment:
@@ -483,7 +312,7 @@ jobs:
[ -n "${DEPLOY_SSH_KEY}" ] || MISSING+=("DEPLOY_SSH_KEY")
if [ ${#MISSING[@]} -gt 0 ]; then
echo "缺少以下 Repository Secrets:${MISSING[*]}"
echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加(注意是 Repository Secrets,不是 Deploy Keys)"
echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加"
exit 1
fi
echo "部署密钥配置完整(DEPLOY_HOST=${DEPLOY_HOST},DEPLOY_USER=${DEPLOY_USER})"
@@ -502,9 +331,19 @@ jobs:
GITHUB_SERVER_URL: ${{ github.server_url }}
GITHUB_REPOSITORY: ${{ github.repository }}
GITHUB_REF_NAME: ${{ github.ref_name }}
OAUTH_REDIRECT_URI: ${{ secrets.OAUTH_REDIRECT_URI }}
OAUTH_STATE_SECRET: ${{ secrets.OAUTH_STATE_SECRET }}
AGGREGATE_OAUTH_APP_ID: ${{ secrets.AGGREGATE_OAUTH_APP_ID }}
AGGREGATE_OAUTH_APP_KEY: ${{ secrets.AGGREGATE_OAUTH_APP_KEY }}
AGGREGATE_OAUTH_LOGIN_TYPE: ${{ secrets.AGGREGATE_OAUTH_LOGIN_TYPE }}
AGGREGATE_OAUTH_ENDPOINT: ${{ secrets.AGGREGATE_OAUTH_ENDPOINT }}
WECHAT_MINI_APPID: ${{ secrets.WECHAT_MINI_APPID }}
WECHAT_MINI_SECRET: ${{ secrets.WECHAT_MINI_SECRET }}
WECHAT_MINI_SUBSCRIBE_TEMPLATES: ${{ secrets.WECHAT_MINI_SUBSCRIBE_TEMPLATES }}
KUGOU_ADMIN_COOKIE: ${{ secrets.KUGOU_ADMIN_COOKIE }}
KUGOU_ADMIN_TOKEN: ${{ secrets.KUGOU_ADMIN_TOKEN }}
run: |
set -euo pipefail
# 全小写规范(Docker Registry 硬性要求 + Gitea raw URL 路径大小写敏感)
IMAGE_NEW="${IMAGE_NEW,,}"
IMAGE_LATEST="${IMAGE_LATEST,,}"
GITHUB_REPOSITORY="${GITHUB_REPOSITORY,,}"
@@ -512,7 +351,6 @@ jobs:
GITEA_HOST="${GITEA_HOST,,}"
IMAGE_NAME="${IMAGE_NAME,,}"
# 写私钥到临时文件(避免 stdin 转义问题)
SSH_KEY_FILE="$(mktemp -t deploy_ssh_key.XXXXXX)"
printf '%s\n' "${DEPLOY_SSH_KEY}" > "${SSH_KEY_FILE}"
chmod 600 "${SSH_KEY_FILE}"
@@ -527,34 +365,31 @@ jobs:
-p "${DEPLOY_PORT}"
)
echo "=============================================="
echo "Deploy target: ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PORT}"
echo "Deploy image : ${IMAGE_NEW}"
echo "Deploy dir : ${DEPLOY_PATH}"
echo "=============================================="
# 本地预检查:ssh 先连上跑 whoami 确认连通,避免 20 分钟后才发现连不上
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version"
# 关键修复:远端 bash 前置 ENV=val 语法在 dash/sh 远端不认,变量全空。
# 用 printf + \n 拼接完整 shell 脚本通过 stdin 注入到 ssh 远端 cat,
# 不用 heredoc,避免 YAML 冒号/缩进解析误判。
ENV_SH="/tmp/deploy_env_$$"
ENV_PAYLOAD="$(printf 'export IMAGE_NEW=%q\nexport IMAGE_LATEST=%q\nexport DEPLOY_PATH=%q\nexport GITEA_HOST=%q\nexport GH_PROXY_PREFIX=%q\nexport REGISTRY_PULL_USER=%q\nexport REGISTRY_PULL_TOKEN=%q\nexport IMAGE_NAME=%q\nexport NUXT_PUBLIC_SITE_URL=%q\nexport GITHUB_SERVER_URL=%q\nexport GITHUB_REPOSITORY=%q\nexport GITHUB_REF_NAME=%q\n' \
ENV_PAYLOAD="$(printf 'export IMAGE_NEW=%q\nexport IMAGE_LATEST=%q\nexport DEPLOY_PATH=%q\nexport GITEA_HOST=%q\nexport GH_PROXY_PREFIX=%q\nexport REGISTRY_PULL_USER=%q\nexport REGISTRY_PULL_TOKEN=%q\nexport IMAGE_NAME=%q\nexport NUXT_PUBLIC_SITE_URL=%q\nexport GITHUB_SERVER_URL=%q\nexport GITHUB_REPOSITORY=%q\nexport GITHUB_REF_NAME=%q\nexport OAUTH_REDIRECT_URI=%q\nexport OAUTH_STATE_SECRET=%q\nexport AGGREGATE_OAUTH_APP_ID=%q\nexport AGGREGATE_OAUTH_APP_KEY=%q\nexport AGGREGATE_OAUTH_LOGIN_TYPE=%q\nexport AGGREGATE_OAUTH_ENDPOINT=%q\nexport WECHAT_MINI_APPID=%q\nexport WECHAT_MINI_SECRET=%q\nexport WECHAT_MINI_SUBSCRIBE_TEMPLATES=%q\nexport KUGOU_ADMIN_COOKIE=%q\nexport KUGOU_ADMIN_TOKEN=%q\n' \
"${IMAGE_NEW}" "${IMAGE_LATEST}" "${DEPLOY_PATH}" "${GITEA_HOST}" "${GH_PROXY_PREFIX}" \
"${REGISTRY_PULL_USER}" "${REGISTRY_PULL_TOKEN}" "${IMAGE_NAME}" "${NUXT_PUBLIC_SITE_URL}" \
"${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_REF_NAME}")"
"${GITHUB_SERVER_URL}" "${GITHUB_REPOSITORY}" "${GITHUB_REF_NAME}" \
"${OAUTH_REDIRECT_URI}" "${OAUTH_STATE_SECRET}" \
"${AGGREGATE_OAUTH_APP_ID}" "${AGGREGATE_OAUTH_APP_KEY}" \
"${AGGREGATE_OAUTH_LOGIN_TYPE}" "${AGGREGATE_OAUTH_ENDPOINT}" \
"${WECHAT_MINI_APPID}" "${WECHAT_MINI_SECRET}" \
"${WECHAT_MINI_SUBSCRIBE_TEMPLATES:-}" \
"${KUGOU_ADMIN_COOKIE:-}" "${KUGOU_ADMIN_TOKEN:-}")"
printf '%s' "${ENV_PAYLOAD}" | ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "cat > ${ENV_SH}"
echo "[Deploy] env script written to ${ENV_SH} on remote"
# 立即回读验证远端文件非空 & IMAGE_NEW 非空,避免后面空跑
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "set -e; ls -la ${ENV_SH}; grep -q IMAGE_NEW ${ENV_SH} && echo '[Deploy] env_sh IMAGE_NEW line OK'"
# ssh 远端 bash -s 第一个参数传 $ENV_SH 路径,主脚本 source 它并做非空断言
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "bash -s -- '${ENV_SH}'" <<'REMOTE_SCRIPT_MAIN'
set -euxo pipefail
ENV_SH="$1"
if [ -f "$ENV_SH" ]; then
# shellcheck disable=SC1090
. "$ENV_SH"
rm -f "$ENV_SH"
fi
@@ -562,80 +397,104 @@ jobs:
DEPLOY_DIR="${DEPLOY_PATH}"
COMPOSE="docker compose"
echo "=============================================="
echo "Executor : $(whoami)"
echo "IMAGE_NEW : ${IMAGE_NEW:-(empty!)}"
echo "DEPLOY_DIR : ${DEPLOY_DIR:-(empty!)}"
echo "=============================================="
# 防御性断言:镜像名 / 路径不能为空,否则直接 exit 9 避免起旧容器
if [ -z "${IMAGE_NEW}" ] || [ -z "${DEPLOY_DIR}" ]; then
echo "ERROR: IMAGE_NEW or DEPLOY_DIR is empty — env variables not propagated correctly."
echo "IMAGE_NEW=${IMAGE_NEW}"
echo "DEPLOY_DIR=${DEPLOY_DIR}"
exit 9
fi
mkdir -p "${DEPLOY_DIR}" 2>/dev/null || sudo mkdir -p "${DEPLOY_DIR}" 2>/dev/null || true
cd "${DEPLOY_DIR}"
# ========== 目录可写性预检查(权限排查友好化) ==========
TEST_FILE="${DEPLOY_DIR}/.deploy_writable_test_$$"
if ! (touch "${TEST_FILE}" && rm -f "${TEST_FILE}") 2>/dev/null; then
echo "================================================================="
echo "ERROR:deploy 用户对部署目录 ${DEPLOY_DIR} 没有写权限(Permission denied)"
echo "当前用户:$(whoami)"
echo "目录归属与权限:"
ls -ld "${DEPLOY_DIR}" || true
echo ""
echo "请在服务器上以 root 身份执行下面 3 行命令修复(复制粘贴):"
echo " chown -R deploy:docker '${DEPLOY_DIR}'"
echo " chmod 775 '${DEPLOY_DIR}'"
echo " setfacl -R -m u:deploy:rwx -m g:docker:rwx -m d:u:deploy:rwx -m d:g:docker:rwx '${DEPLOY_DIR}' 2>/dev/null || true"
echo "修复后重新触发此 workflow 即可。"
echo "================================================================="
exit 3
fi
if [ -n "${AGGREGATE_OAUTH_APP_ID}" ] || [ -n "${WECHAT_MINI_APPID}" ]; then
ENV_FILE="${DEPLOY_DIR}/.env"
touch "${ENV_FILE}"
MERGE_OK=1
upsert_env_key() {
_K="$1"; _V="$2"
if [ -z "${_V}" ]; then return 0; fi
# 值的引号要自适应:JSON(含双引号)必须用单引号包,否则 .env 会变成坏行。
_Q='"'
case "${_V}" in
*'"'*) _Q="'" ;;
esac
_LINE="${_K}=${_Q}${_V}${_Q}"
if grep -q "^${_K}=" "${ENV_FILE}" 2>/dev/null; then
# 用 grep -v 重写而不是 awk 改字段:awk 的 FS="=" 会吃掉值里 "=" 之后的内容
# (base64 密钥常带 "=" 填充)
if grep -v "^${_K}=" "${ENV_FILE}" > "${ENV_FILE}.tmp.$$" 2>/dev/null; then
printf '%s\n' "${_LINE}" >> "${ENV_FILE}.tmp.$$"
mv "${ENV_FILE}.tmp.$$" "${ENV_FILE}" || { rm -f "${ENV_FILE}.tmp.$$"; MERGE_OK=0; }
else
rm -f "${ENV_FILE}.tmp.$$"
MERGE_OK=0
fi
else
printf '%s\n' "${_LINE}" >> "${ENV_FILE}"
fi
}
upsert_env_key OAUTH_REDIRECT_URI "${OAUTH_REDIRECT_URI}" || MERGE_OK=0
upsert_env_key OAUTH_STATE_SECRET "${OAUTH_STATE_SECRET}" || MERGE_OK=0
upsert_env_key AGGREGATE_OAUTH_APP_ID "${AGGREGATE_OAUTH_APP_ID}" || MERGE_OK=0
upsert_env_key AGGREGATE_OAUTH_APP_KEY "${AGGREGATE_OAUTH_APP_KEY}" || MERGE_OK=0
upsert_env_key AGGREGATE_OAUTH_LOGIN_TYPE "${AGGREGATE_OAUTH_LOGIN_TYPE}" || MERGE_OK=0
upsert_env_key AGGREGATE_OAUTH_ENDPOINT "${AGGREGATE_OAUTH_ENDPOINT}" || MERGE_OK=0
upsert_env_key WECHAT_MINI_APPID "${WECHAT_MINI_APPID}" || MERGE_OK=0
upsert_env_key WECHAT_MINI_SECRET "${WECHAT_MINI_SECRET}" || MERGE_OK=0
# 变量必须同时在【本 step 的 env:】【ENV_PAYLOAD 的 export 列表】两处出现:
# 远端脚本是 set -euxo pipefail,只在一处声明 → 另一端就是 unbound variable。
upsert_env_key WECHAT_MINI_SUBSCRIBE_TEMPLATES "${WECHAT_MINI_SUBSCRIBE_TEMPLATES:-}" || MERGE_OK=0
upsert_env_key KUGOU_ADMIN_COOKIE "${KUGOU_ADMIN_COOKIE:-}" || MERGE_OK=0
upsert_env_key KUGOU_ADMIN_TOKEN "${KUGOU_ADMIN_TOKEN:-}" || MERGE_OK=0
rm -f "${ENV_FILE}.tmp.$$"
if [ "${MERGE_OK}" = "1" ]; then
echo "[Runtime-env] 运行期配置已合并写入 ${ENV_FILE}"
else
echo "[Runtime-env] WARNING:合并写入失败,请手动检查 ${ENV_FILE};仍尝试继续部署"
fi
else
echo "[Runtime-env] AGGREGATE_OAUTH_APP_ID 与 WECHAT_MINI_APPID 均未设置——跳过写入远端 .env"
fi
if [ -n "${REGISTRY_PULL_TOKEN}" ] && [ -n "${REGISTRY_PULL_USER}" ]; then
echo "使用专用 pull 凭证登录 Gitea Container Registry"
printf '%s' "${REGISTRY_PULL_TOKEN}" \
| docker login "${GITEA_HOST}" \
-u "${REGISTRY_PULL_USER}" \
--password-stdin 2>/dev/null || true
| docker login "${GITEA_HOST}" -u "${REGISTRY_PULL_USER}" --password-stdin 2>/dev/null || true
fi
echo "Pulling new image ${IMAGE_NEW} ..."
docker pull "${IMAGE_NEW}"
docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
echo "New image pulled and tagged as latest"
# 用 printf 拼 override.yml,彻底避免 bash heredoc 的三重坑:
# 1) 结束分隔符必须行首(否则 bash 不认,把后面整段代码都写进文件)
# 2) 内层 <<'XXX' 单引号包裹导致变量不展开(image: ${IMAGE_NEW} 会是字面量)
# 3) 外层 .yml parser 把 heredoc 里的 services: 当 YAML mapping key 解析
printf 'services:\n voicehub:\n image: %s\n' "${IMAGE_NEW}" > docker-compose.override.yml
echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
echo "--- override.yml content ---"
cat docker-compose.override.yml
echo "--- override.yml end ---"
if [ ! -f docker-compose.yml ]; then
echo "docker-compose.yml 不存在,自动从仓库模板下载(如访问 GitHub 会自动加 gh-proxy 前缀)"
echo "docker-compose.yml 不存在,自动从仓库模板下载"
TEMPLATE_URL="${GITHUB_SERVER_URL:-https://${GITEA_HOST}}/${GITHUB_REPOSITORY}/raw/branch/${GITHUB_REF_NAME:-main}/docker-compose.yml"
case "${TEMPLATE_URL}" in
*"://github.com/"*) TEMPLATE_URL="${GH_PROXY_PREFIX}${TEMPLATE_URL}" ;;
esac
echo "URL=${TEMPLATE_URL}"
curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
|| echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env(参考仓库根目录 .env.example)"
|| echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env"
fi
if [ ! -f .env ]; then
echo "================================================================="
echo "WARNING:${DEPLOY_DIR}/.env 不存在"
echo "首次部署请在服务器上创建 .env(至少填写 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL + 5 个 cpus 数字兜底:APP_CPUS/APP_CPUS_LIMIT/APP_CPUS_RESERVATION/PG_CPUS/REDIS_CPUS)"
echo "参考仓库根目录的 .env.example"
echo "================================================================="
echo "WARNING:${DEPLOY_DIR}/.env 不存在(首次部署请参考仓库根目录 .env.example 创建)"
fi
echo "Executing docker compose up -d ..."
@@ -650,12 +509,7 @@ jobs:
echo "voicehub healthcheck passed"
break
fi
if echo "${STATUS}" | grep -q '"State":"running"'; then
:
else
echo "voicehub not running, current status:"
${COMPOSE} ps || true
fi
echo "${STATUS}" | grep -q '"State":"running"' || ${COMPOSE} ps || true
sleep 5
WAITED=$((WAITED + 1))
done
@@ -669,16 +523,8 @@ jobs:
docker image prune -af --filter "until=72h" || true
docker builder prune -af || true
echo ""
echo "================================================================="
echo "Deploy succeeded. Visit: ${NUXT_PUBLIC_SITE_URL:-请自行查看你的站点 URL}"
echo "Rollback instructions (on server):"
echo " cd ${DEPLOY_DIR}"
echo " docker images | grep ${IMAGE_NAME} | head -5"
echo " printf 'services:\\n voicehub:\\n image: <OLD_TAG_FROM_ABOVE>\\n' > docker-compose.override.yml"
echo " docker compose up -d"
echo "================================================================="
echo "Rollback: cd ${DEPLOY_DIR} && printf 'services:\\n voicehub:\\n image: <OLD_TAG>\\n' > docker-compose.override.yml && docker compose up -d"
REMOTE_SCRIPT_MAIN
# 清理私钥临时文件
rm -f "${SSH_KEY_FILE}"
+17
View File
@@ -37,6 +37,23 @@ RUN set -eux; \
# 只有在 package.json 没变而源代码改变时,才会从这一步开始重新执行,跳过上面的耗时依赖安装
COPY . .
# ========== OAuth / 第三方登录构建期配置(process.env.AGGREGATE_OAUTH_APP_ID 控制 nuxt.config.ts public.oauth.aggregate 开关) ==========
# 说明:
# - 这些是 **构建期** 变量,只在 RUN npm run build 时用到(runtimeConfig.public 只会在 build 时固化到 .output/public 包)
# - 部署时运行期变量(同一套值)会通过 docker-compose.yml env_file: [.env] 注入到容器 process.env 供 server/utils/oauth-strategies.ts loadAggregateSettings 使用
ARG OAUTH_REDIRECT_URI=""
ARG OAUTH_STATE_SECRET=""
ARG AGGREGATE_OAUTH_APP_ID=""
ARG AGGREGATE_OAUTH_APP_KEY=""
ARG AGGREGATE_OAUTH_LOGIN_TYPE=""
ARG AGGREGATE_OAUTH_ENDPOINT=""
ENV OAUTH_REDIRECT_URI="${OAUTH_REDIRECT_URI}" \
OAUTH_STATE_SECRET="${OAUTH_STATE_SECRET}" \
AGGREGATE_OAUTH_APP_ID="${AGGREGATE_OAUTH_APP_ID}" \
AGGREGATE_OAUTH_APP_KEY="${AGGREGATE_OAUTH_APP_KEY}" \
AGGREGATE_OAUTH_LOGIN_TYPE="${AGGREGATE_OAUTH_LOGIN_TYPE}" \
AGGREGATE_OAUTH_ENDPOINT="${AGGREGATE_OAUTH_ENDPOINT}"
# 构建应用,使用内存优化脚本适应4GB总内存
RUN chmod +x scripts/memory-build.js && \
node scripts/memory-build.js
+338 -5
View File
@@ -5,9 +5,9 @@
@tailwind components;
@tailwind utilities;
/* 防止浏览器自动应用深色主题 */
/* 防止浏览器自动应用深色主题:改为跟随 data-theme 动态覆盖(见 theme-protection.css) */
:root {
color-scheme: light dark;
color-scheme: light;
}
/* 流体背景与全局样式 */
@@ -82,9 +82,9 @@ body::before {
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%);
radial-gradient(circle at 20% 50%, rgba(79, 70, 229, 0.035) 0%, transparent 55%),
radial-gradient(circle at 80% 20%, rgba(14, 165, 233, 0.025) 0%, transparent 55%),
radial-gradient(circle at 40% 80%, rgba(79, 70, 229, 0.02) 0%, transparent 55%);
z-index: -1;
pointer-events: none;
}
@@ -472,3 +472,336 @@ body::before {
max-width: none !important;
}
}
/* =========================================================
* 前台浅色模式对比度覆盖(ScheduleList / RequestForm 等组件)
* 将所有硬编码的 rgba(255,255,255,*) 白系文字统一替换为
* 适配浅底的深灰/主题色,保证 WCAG AA 可读性
* =======================================================*/
html[data-theme='light'],
html[data-theme='ClassicLight'],
html[data-theme='ModernLight'] {
/* ScheduleList.vue 主区块硬编码颜色校正 */
color-scheme: light;
}
html[data-theme='light'] .schedule-list .date-item,
html[data-theme='ClassicLight'] .schedule-list .date-item,
html[data-theme='ModernLight'] .schedule-list .date-item,
html[data-theme='light'] .song-list .date-item,
html[data-theme='ClassicLight'] .song-list .date-item,
html[data-theme='ModernLight'] .song-list .date-item {
color: var(--text-secondary) !important;
}
html[data-theme='light'] .schedule-list .empty-dates,
html[data-theme='ClassicLight'] .schedule-list .empty-dates,
html[data-theme='ModernLight'] .schedule-list .empty-dates,
html[data-theme='light'] .schedule-list .loading,
html[data-theme='ClassicLight'] .schedule-list .loading,
html[data-theme='ModernLight'] .schedule-list .loading,
html[data-theme='light'] .schedule-list .error,
html[data-theme='ClassicLight'] .schedule-list .error,
html[data-theme='ModernLight'] .schedule-list .error,
html[data-theme='light'] .schedule-list .empty,
html[data-theme='ClassicLight'] .schedule-list .empty,
html[data-theme='ModernLight'] .schedule-list .empty {
color: var(--text-tertiary) !important;
}
html[data-theme='light'] .schedule-list .playtime-header h4,
html[data-theme='ClassicLight'] .schedule-list .playtime-header h4,
html[data-theme='ModernLight'] .schedule-list .playtime-header h4 {
color: var(--text-secondary) !important;
}
html[data-theme='light'] .schedule-list .playtime-time,
html[data-theme='ClassicLight'] .schedule-list .playtime-time,
html[data-theme='ModernLight'] .schedule-list .playtime-time {
color: var(--text-tertiary) !important;
}
html[data-theme='light'] .schedule-list .playlist-modal,
html[data-theme='ClassicLight'] .schedule-list .playlist-modal,
html[data-theme='ModernLight'] .schedule-list .playlist-modal {
border-color: var(--border-secondary) !important;
}
html[data-theme='light'] .schedule-list .playlist-modal-header,
html[data-theme='ClassicLight'] .schedule-list .playlist-modal-header,
html[data-theme='ModernLight'] .schedule-list .playlist-modal-header,
html[data-theme='light'] .schedule-list .playlist-modal-footer,
html[data-theme='ClassicLight'] .schedule-list .playlist-modal-footer,
html[data-theme='ModernLight'] .schedule-list .playlist-modal-footer {
border-color: var(--border-secondary) !important;
}
html[data-theme='light'] .schedule-list .song-meta .meta-secondary,
html[data-theme='ClassicLight'] .schedule-list .song-meta .meta-secondary,
html[data-theme='ModernLight'] .schedule-list .song-meta .meta-secondary,
html[data-theme='light'] .schedule-list .song-meta .meta-placeholder,
html[data-theme='ClassicLight'] .schedule-list .song-meta .meta-placeholder,
html[data-theme='ModernLight'] .schedule-list .song-meta .meta-placeholder,
html[data-theme='light'] .schedule-list .slot-hint,
html[data-theme='ClassicLight'] .schedule-list .slot-hint,
html[data-theme='ModernLight'] .schedule-list .slot-hint,
html[data-theme='light'] .schedule-list .requestor-text,
html[data-theme='ClassicLight'] .schedule-list .requestor-text,
html[data-theme='ModernLight'] .schedule-list .requestor-text,
html[data-theme='light'] .schedule-list .pill-empty,
html[data-theme='ClassicLight'] .schedule-list .pill-empty,
html[data-theme='ModernLight'] .schedule-list .pill-empty,
html[data-theme='light'] .schedule-list .empty-slot,
html[data-theme='ClassicLight'] .schedule-list .empty-slot,
html[data-theme='ModernLight'] .schedule-list .empty-slot {
color: var(--text-tertiary) !important;
}
html[data-theme='light'] .schedule-list .chip,
html[data-theme='ClassicLight'] .schedule-list .chip,
html[data-theme='ModernLight'] .schedule-list .chip,
html[data-theme='light'] .schedule-list .tag,
html[data-theme='ClassicLight'] .schedule-list .tag,
html[data-theme='ModernLight'] .schedule-list .tag,
html[data-theme='light'] .schedule-list .badge-soft,
html[data-theme='ClassicLight'] .schedule-list .badge-soft,
html[data-theme='ModernLight'] .schedule-list .badge-soft {
background: var(--bg-secondary) !important;
border-color: var(--border-secondary) !important;
color: var(--text-secondary) !important;
}
html[data-theme='light'] .schedule-list .divider-subtle,
html[data-theme='ClassicLight'] .schedule-list .divider-subtle,
html[data-theme='ModernLight'] .schedule-list .divider-subtle,
html[data-theme='light'] .schedule-list .card-border,
html[data-theme='ClassicLight'] .schedule-list .card-border,
html[data-theme='ModernLight'] .schedule-list .card-border {
border-color: var(--border-secondary) !important;
}
/* RequestForm / SongList / Dialog 通用硬编码白字校正 */
html[data-theme='light'] .request-form .label-subtle,
html[data-theme='ClassicLight'] .request-form .label-subtle,
html[data-theme='ModernLight'] .request-form .label-subtle,
html[data-theme='light'] .request-form .hint-subtle,
html[data-theme='ClassicLight'] .request-form .hint-subtle,
html[data-theme='ModernLight'] .request-form .hint-subtle,
html[data-theme='light'] .request-form .placeholder-subtle,
html[data-theme='ClassicLight'] .request-form .placeholder-subtle,
html[data-theme='ModernLight'] .request-form .placeholder-subtle,
html[data-theme='light'] .song-list .muted,
html[data-theme='ClassicLight'] .song-list .muted,
html[data-theme='ModernLight'] .song-list .muted,
html[data-theme='light'] .song-list .subtle-text,
html[data-theme='ClassicLight'] .song-list .subtle-text,
html[data-theme='ModernLight'] .song-list .subtle-text {
color: var(--text-tertiary) !important;
}
html[data-theme='light'] .request-form .glass-surface,
html[data-theme='ClassicLight'] .request-form .glass-surface,
html[data-theme='ModernLight'] .request-form .glass-surface,
html[data-theme='light'] .request-form .surface-chip,
html[data-theme='ClassicLight'] .request-form .surface-chip,
html[data-theme='ModernLight'] .request-form .surface-chip,
html[data-theme='light'] .song-list .glass-surface,
html[data-theme='ClassicLight'] .song-list .glass-surface,
html[data-theme='ModernLight'] .song-list .glass-surface,
html[data-theme='light'] .song-list .surface-chip,
html[data-theme='ClassicLight'] .song-list .surface-chip,
html[data-theme='ModernLight'] .song-list .surface-chip {
background: var(--bg-secondary) !important;
border-color: var(--border-secondary) !important;
}
/* =========================================================
* 首页 Hero CTA 区样式 — 投稿按钮居中 + 档期概览卡片
* 移动端优先 / 少圆角 / 硬边几何 / 现代浅色专业风格
* =======================================================*/
.hero-cta-section {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 28px 16px 20px;
gap: 14px;
width: 100%;
max-width: 760px;
margin: 0 auto;
}
.hero-request-btn {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
min-width: 240px;
width: min(86%, 360px);
height: 58px;
padding: 0 22px;
border-radius: 14px;
border: 1px solid rgba(255, 255, 255, 0.18);
background: linear-gradient(135deg, var(--primary) 0%, #4338ca 100%);
color: #ffffff;
font-family: 'MiSans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: 17px;
font-weight: 600;
letter-spacing: 0.2px;
cursor: pointer;
user-select: none;
box-shadow:
0 10px 24px -10px rgba(79, 70, 229, 0.55),
0 4px 10px -4px rgba(79, 70, 229, 0.35);
transition: transform 0.18s cubic-bezier(0.2, 0.9, 0.2, 1),
box-shadow 0.18s ease, filter 0.18s ease;
-webkit-tap-highlight-color: transparent;
}
.hero-request-btn:hover {
transform: translateY(-1px);
filter: brightness(1.03);
box-shadow:
0 14px 28px -10px rgba(79, 70, 229, 0.62),
0 6px 12px -4px rgba(79, 70, 229, 0.42);
}
.hero-request-btn:active {
transform: translateY(0);
filter: brightness(0.97);
box-shadow:
0 6px 14px -6px rgba(79, 70, 229, 0.5),
0 2px 6px -2px rgba(79, 70, 229, 0.35);
}
.hero-request-btn .btn-icon {
font-size: 20px;
line-height: 1;
}
.hero-request-btn .btn-icon.pulse::after {
content: '';
position: absolute;
right: 10px;
top: 10px;
width: 8px;
height: 8px;
border-radius: 50%;
background: #22c55e;
box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.22);
}
.schedule-overview-card {
width: 100%;
max-width: 720px;
padding: 18px 18px;
border-radius: 12px;
background: var(--card-bg, #ffffff);
border: 1px solid var(--card-border, #e4e4e7);
box-shadow: var(--card-shadow);
}
.schedule-overview-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 10px;
}
@media (max-width: 520px) {
.schedule-overview-grid {
grid-template-columns: 1fr;
gap: 12px;
}
}
.overview-stat {
display: flex;
flex-direction: column;
align-items: flex-start;
justify-content: center;
padding: 14px 16px;
border-radius: 10px;
background: var(--bg-secondary);
border: 1px solid var(--border-secondary);
min-height: 96px;
}
.overview-stat .stat-icon {
font-size: 16px;
line-height: 1;
margin-bottom: 6px;
color: var(--text-tertiary);
}
.overview-stat .stat-label {
font-size: 12px;
line-height: 1.4;
color: var(--text-tertiary);
font-weight: 500;
margin-bottom: 6px;
letter-spacing: 0.2px;
}
.overview-stat .stat-value {
font-size: 22px;
font-weight: 700;
color: var(--text-primary);
line-height: 1.2;
letter-spacing: -0.3px;
display: flex;
align-items: baseline;
gap: 6px;
}
.overview-stat .stat-value .unit {
font-size: 12px;
font-weight: 500;
color: var(--text-tertiary);
letter-spacing: 0;
}
.overview-stat.primary {
background: linear-gradient(135deg, rgba(79, 70, 229, 0.08), rgba(14, 165, 233, 0.06));
border-color: rgba(79, 70, 229, 0.15);
}
.overview-stat.primary .stat-value {
color: var(--primary);
font-size: 28px;
}
.overview-stat.primary .stat-icon {
color: var(--primary);
}
.overview-stat .stat-detail {
margin-top: 4px;
font-size: 11px;
color: var(--text-tertiary);
line-height: 1.35;
}
.overview-stat .live-dot {
display: inline-block;
width: 6px;
height: 6px;
border-radius: 50%;
background: #22c55e;
margin-right: 5px;
vertical-align: middle;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
animation: live-pulse 1.6s ease-in-out infinite;
}
@keyframes live-pulse {
0%, 100% { opacity: 1; transform: scale(1); }
50% { opacity: 0.65; transform: scale(0.92); }
}
@media (max-width: 640px) {
.hero-cta-section {
padding: 22px 12px 16px;
gap: 12px;
}
.hero-request-btn {
height: 54px;
font-size: 16px;
border-radius: 12px;
width: 88%;
}
.schedule-overview-card {
padding: 14px 12px;
border-radius: 10px;
}
.overview-stat {
padding: 12px 14px;
min-height: 84px;
}
.overview-stat.primary .stat-value {
font-size: 26px;
}
}
+16 -5
View File
@@ -97,18 +97,29 @@
font-size: 14px !important;
}
/* 封面 */
/* 封面 - 打印模式下固定尺寸,移除圆角阴影,使用 contain 防止裁切 */
.schedule-item-print .cover-section {
width: 40px !important;
height: 40px !important;
width: 48px !important;
height: 48px !important;
aspect-ratio: 1 / 1 !important;
flex-shrink: 0 !important;
}
.schedule-item-print .song-cover,
.schedule-item-print .song-cover {
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.schedule-item-print .cover-placeholder {
width: 100% !important;
height: 100% !important;
border-radius: 4px !important;
aspect-ratio: 1 / 1 !important;
object-fit: contain !important;
border-radius: 0 !important;
box-shadow: none !important;
}
/* 歌曲信息 */
+45 -23
View File
@@ -1,12 +1,27 @@
/* 温和的浏览器主题保护样式 */
/* 只保护关键元素,不干扰正常的CSS */
/* 只保护关键元素,不干扰正常的CSS,并且根据 data-theme 动态切换 */
/* 全局颜色方案声明 */
/* 全局颜色方案声明:移动端优先遵循 data-theme,其次才用系统色 */
:root {
color-scheme: light dark;
}
/* 只保护表单元素的外观 */
/* 深色主题时,显式声明 color-scheme = dark,解决 iOS / Chrome 移动端原生控件浅色的问题 */
:root[data-theme='dark'],
:root[data-theme='MidnightDark'],
:root[data-theme='ModernDark'],
:root[data-theme='ClassicDark'] {
color-scheme: dark;
}
/* 浅色主题时,显式声明 color-scheme = light */
:root[data-theme='light'],
:root[data-theme='ClassicLight'],
:root[data-theme='ModernLight'] {
color-scheme: light;
}
/* 保护表单元素外观,但不强锁 color-scheme = none(none 会导致控件 fallback 到浅色) */
input[type='text'],
input[type='email'],
input[type='password'],
@@ -17,16 +32,39 @@ input[type='number'],
input[type='date'],
input[type='time'],
input[type='datetime-local'],
input[type='month'],
input[type='week'],
textarea,
select {
/* 禁用浏览器默认样式 */
/* 禁用浏览器默认样式(保留我们自定义的背景色与文字颜色) */
-webkit-appearance: none;
-moz-appearance: none;
appearance: none;
/* 在移动端根据主题继承 color-scheme,使原生滚动条/弹出控件正确变深色或浅色 */
color-scheme: inherit;
}
/* 禁用浏览器的颜色方案自动调整 */
color-scheme: none;
-webkit-color-scheme: none;
/* 深色主题下专门修复表单控件与浏览器原生 UI 的颜色方案 */
@media (prefers-color-scheme: dark) {
:root:not([data-theme='light']):not([data-theme='ClassicLight']):not([data-theme='ModernLight']) {
color-scheme: 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'],
input[type='month'],
input[type='week'],
textarea,
select {
color-scheme: dark;
}
}
/* 保护按钮外观 */
@@ -52,22 +90,6 @@ button {
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,
+283 -96
View File
@@ -1,69 +1,59 @@
/* 全局CSS变量 - 统一配色方案 */
/* SSR-safe 默认值 = ModernLight 浅色,防止 hydration 前出现深色闪屏 */
:root {
/* 主色调 */
--primary: #3b82f6;
--primary-hover: #2563eb;
--primary-light: rgba(59, 130, 246, 0.1);
--primary-border: rgba(59, 130, 246, 0.2);
--primary: #4f46e5;
--primary-hover: #4338ca;
--primary-light: rgba(79, 70, 229, 0.1);
--primary-border: rgba(79, 70, 229, 0.2);
/* 成功色 */
--success: #10b981;
--success-hover: #059669;
--success-light: rgba(16, 185, 129, 0.1);
--success-border: rgba(16, 185, 129, 0.2);
--success: #16a34a;
--success-hover: #15803d;
--success-light: rgba(22, 163, 74, 0.1);
--success-border: rgba(22, 163, 74, 0.2);
/* 警告色 */
--warning: #f59e0b;
--warning-hover: #d97706;
--warning-light: rgba(245, 158, 11, 0.1);
--warning-border: rgba(245, 158, 11, 0.2);
--warning: #ea580c;
--warning-hover: #c2410c;
--warning-light: rgba(234, 88, 12, 0.1);
--warning-border: rgba(234, 88, 12, 0.2);
/* 错误色 */
--error: #ef4444;
--error-hover: #dc2626;
--error-light: rgba(239, 68, 68, 0.1);
--error-border: rgba(239, 68, 68, 0.2);
--error: #e11d48;
--error-hover: #be123c;
--error-light: rgba(225, 29, 72, 0.1);
--error-border: rgba(225, 29, 72, 0.2);
/* 信息色 */
--info: #3b82f6;
--info-hover: #2563eb;
--info-light: rgba(59, 130, 246, 0.1);
--info-border: rgba(59, 130, 246, 0.2);
--info: #0ea5e9;
--info-hover: #0284c7;
--info-light: rgba(14, 165, 233, 0.1);
--info-border: rgba(14, 165, 233, 0.2);
/* 背景色 */
--bg-primary: #111111;
--bg-secondary: #1a1a1a;
--bg-tertiary: #2a2a2a;
--bg-quaternary: #3a3a3a;
--bg-hover: #1f1f1f;
--bg-primary: #fafafa;
--bg-secondary: #f4f4f5;
--bg-tertiary: #e4e4e7;
--bg-quaternary: #d4d4d8;
--bg-hover: #eeeeef;
/* 文字颜色 */
--text-primary: #ffffff;
--text-secondary: #cccccc;
--text-tertiary: #888888;
--text-quaternary: #666666;
--text-disabled: #444444;
--text-primary: #09090b;
--text-secondary: #3f3f46;
--text-tertiary: #71717a;
--text-quaternary: #a1a1aa;
--text-disabled: #d4d4d8;
/* 边框颜色 */
--border-primary: #1f1f1f;
--border-secondary: #2a2a2a;
--border-tertiary: #3a3a3a;
--border-quaternary: #4a4a4a;
--border-primary: #e4e4e7;
--border-secondary: #d4d4d8;
--border-tertiary: #a1a1aa;
--border-quaternary: #71717a;
/* 阴影 */
--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);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
--shadow-md: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.03);
--shadow-lg: 0 8px 24px -4px rgba(0, 0, 0, 0.06), 0 2px 8px -4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 16px 40px -8px rgba(0, 0, 0, 0.08), 0 4px 16px -8px rgba(0, 0, 0, 0.05);
/* 圆角 */
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 12px;
--radius-2xl: 16px;
--radius-xl: 10px;
--radius-2xl: 12px;
/* 间距 */
--spacing-xs: 4px;
--spacing-sm: 8px;
--spacing-md: 12px;
@@ -72,7 +62,6 @@
--spacing-2xl: 24px;
--spacing-3xl: 32px;
/* 字体大小 */
--text-xs: 12px;
--text-sm: 14px;
--text-base: 16px;
@@ -81,18 +70,15 @@
--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;
@@ -102,81 +88,72 @@
--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);
--status-pending: #eab308;
--status-pending-light: rgba(234, 179, 8, 0.12);
--status-approved: #22c55e;
--status-approved-light: rgba(34, 197, 94, 0.12);
--status-played: #71717a;
--status-played-light: rgba(113, 113, 122, 0.12);
/* 投票相关颜色 */
--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);
--table-header-bg: #f4f4f5;
--table-row-hover: #fafafa;
--table-border: #e4e4e7;
--table-selected: rgba(79, 70, 229, 0.08);
/* 卡片颜色 */
--card-bg: var(--bg-secondary);
--card-border: var(--border-secondary);
--card-hover-border: var(--border-tertiary);
--card-shadow: var(--shadow-md);
--card-bg: #ffffff;
--card-border: #e4e4e7;
--card-hover-border: #d4d4d8;
--card-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.03);
/* 按钮颜色 */
--btn-primary-bg: var(--primary);
--btn-primary-hover: var(--primary-hover);
--btn-primary-text: var(--text-primary);
--btn-primary-text: #ffffff;
--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-secondary-bg: #f4f4f5;
--btn-secondary-hover: #e4e4e7;
--btn-secondary-text: #09090b;
--btn-secondary-border: #d4d4d8;
--btn-success-bg: var(--success);
--btn-success-hover: var(--success-hover);
--btn-success-text: var(--text-primary);
--btn-success-text: #ffffff;
--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-text: #ffffff;
--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-text: #ffffff;
--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);
--input-bg: #ffffff;
--input-border: #e4e4e7;
--input-border-focus: #4f46e5;
--input-text: #09090b;
--input-placeholder: #a1a1aa;
--input-shadow-focus: 0 0 0 3px rgba(79, 70, 229, 0.12);
/* 模态框颜色 */
--modal-backdrop: rgba(0, 0, 0, 0.7);
--modal-backdrop: rgba(0, 0, 0, 0.4);
--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);
@@ -193,17 +170,56 @@
--notification-info-border: var(--info-border);
--notification-info-text: var(--info);
/* 加载动画颜色 */
--spinner-primary: var(--primary);
--spinner-secondary: var(--bg-tertiary);
/* 滚动条颜色 */
--scrollbar-track: #f4f4f5;
--scrollbar-thumb: #d4d4d8;
--scrollbar-thumb-hover: #a1a1aa;
}
:root[data-theme='dark'] {
--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;
--card-bg: #1a1a1a;
--card-border: #2a2a2a;
--btn-secondary-bg: #3a3a3a;
--btn-secondary-hover: #4a4a4a;
--btn-secondary-text: #ffffff;
--btn-secondary-border: #555555;
--input-bg: var(--bg-tertiary);
--input-border: var(--border-tertiary);
--input-text: var(--text-primary);
--input-placeholder: var(--text-quaternary);
--scrollbar-track: var(--bg-tertiary);
--scrollbar-thumb: var(--bg-quaternary);
--scrollbar-thumb-hover: #4a4a4a;
--modal-backdrop: rgba(0, 0, 0, 0.7);
--card-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
:root[data-theme='light'] {
:root[data-theme='light'],
:root[data-theme='ClassicLight'],
:root[data-theme='ModernLight'] {
--bg-primary: #f8fafc;
--bg-secondary: #f1f5f9;
--bg-tertiary: #e2e8f0;
@@ -223,6 +239,177 @@
--modal-backdrop: rgba(0, 0, 0, 0.4);
--scrollbar-thumb-hover: #94a3b8;
--btn-secondary-bg: #f1f5f9;
--btn-secondary-hover: #e2e8f0;
--btn-secondary-text: #0f172a;
--btn-secondary-border: #cbd5e1;
--card-shadow: 0 1px 3px 0 rgba(0, 0, 0, 0.1), 0 1px 2px 0 rgba(0, 0, 0, 0.06);
}
:root[data-theme='ClassicLight'] {
--primary: #1e40af;
--primary-hover: #1e3a8a;
--primary-light: rgba(30, 64, 175, 0.08);
--primary-border: rgba(30, 64, 175, 0.15);
--bg-primary: #ffffff;
--bg-secondary: #f5f7fa;
--bg-tertiary: #edf0f5;
--bg-quaternary: #d9dee7;
--bg-hover: #f0f3f8;
--success: #059669;
--success-hover: #047857;
--success-light: rgba(5, 150, 105, 0.08);
--success-border: rgba(5, 150, 105, 0.15);
--warning: #d97706;
--warning-hover: #b45309;
--warning-light: rgba(217, 119, 6, 0.08);
--warning-border: rgba(217, 119, 6, 0.15);
--error: #dc2626;
--error-hover: #b91c1c;
--error-light: rgba(220, 38, 38, 0.08);
--error-border: rgba(220, 38, 38, 0.15);
--info: #2563eb;
--info-hover: #1d4ed8;
--info-light: rgba(37, 99, 235, 0.08);
--info-border: rgba(37, 99, 235, 0.15);
--text-primary: #1a2332;
--text-secondary: #4a5568;
--text-tertiary: #6b7280;
--text-quaternary: #9ca3af;
--text-disabled: #c5cbd3;
--border-primary: #e5e7eb;
--border-secondary: #d1d5db;
--border-tertiary: #9ca3af;
--border-quaternary: #6b7280;
--input-bg: #ffffff;
--input-border: #d1d5db;
--input-border-focus: #1e40af;
--input-text: #1a2332;
--input-placeholder: #9ca3af;
--input-shadow-focus: 0 0 0 3px rgba(30, 64, 175, 0.1);
--card-bg: #ffffff;
--card-border: #e5e7eb;
--card-hover-border: #d1d5db;
--table-header-bg: #f5f7fa;
--table-row-hover: #f9fafb;
--table-border: #e5e7eb;
--table-selected: rgba(30, 64, 175, 0.06);
--scrollbar-track: #f1f5f9;
--scrollbar-thumb: #cbd5e1;
--scrollbar-thumb-hover: #94a3b8;
--status-pending: #ca8a04;
--status-pending-light: rgba(202, 138, 4, 0.08);
--status-approved: #059669;
--status-approved-light: rgba(5, 150, 105, 0.08);
--status-played: #6b7280;
--status-played-light: rgba(107, 114, 128, 0.08);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.04);
--shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.06), 0 2px 4px -2px rgba(0, 0, 0, 0.04);
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.06), 0 4px 6px -4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.06), 0 8px 10px -6px rgba(0, 0, 0, 0.04);
--radius-sm: 3px;
--radius-md: 5px;
--radius-lg: 6px;
--radius-xl: 8px;
--radius-2xl: 10px;
}
:root[data-theme='ModernLight'] {
--primary: #4f46e5;
--primary-hover: #4338ca;
--primary-light: rgba(79, 70, 229, 0.1);
--primary-border: rgba(79, 70, 229, 0.2);
--bg-primary: #fafafa;
--bg-secondary: #f4f4f5;
--bg-tertiary: #e4e4e7;
--bg-quaternary: #d4d4d8;
--bg-hover: #eeeeef;
--success: #16a34a;
--success-hover: #15803d;
--success-light: rgba(22, 163, 74, 0.1);
--success-border: rgba(22, 163, 74, 0.2);
--warning: #ea580c;
--warning-hover: #c2410c;
--warning-light: rgba(234, 88, 12, 0.1);
--warning-border: rgba(234, 88, 12, 0.2);
--error: #e11d48;
--error-hover: #be123c;
--error-light: rgba(225, 29, 72, 0.1);
--error-border: rgba(225, 29, 72, 0.2);
--info: #0ea5e9;
--info-hover: #0284c7;
--info-light: rgba(14, 165, 233, 0.1);
--info-border: rgba(14, 165, 233, 0.2);
--text-primary: #09090b;
--text-secondary: #3f3f46;
--text-tertiary: #71717a;
--text-quaternary: #a1a1aa;
--text-disabled: #d4d4d8;
--border-primary: #e4e4e7;
--border-secondary: #d4d4d8;
--border-tertiary: #a1a1aa;
--border-quaternary: #71717a;
--input-bg: #ffffff;
--input-border: #e4e4e7;
--input-border-focus: #4f46e5;
--input-text: #09090b;
--input-placeholder: #a1a1aa;
--input-shadow-focus: 0 0 0 3px rgba(79, 70, 229, 0.12);
--card-bg: #ffffff;
--card-border: #e4e4e7;
--card-hover-border: #d4d4d8;
--table-header-bg: #f4f4f5;
--table-row-hover: #fafafa;
--table-border: #e4e4e7;
--table-selected: rgba(79, 70, 229, 0.08);
--scrollbar-track: #f4f4f5;
--scrollbar-thumb: #d4d4d8;
--scrollbar-thumb-hover: #a1a1aa;
--status-pending: #eab308;
--status-pending-light: rgba(234, 179, 8, 0.12);
--status-approved: #22c55e;
--status-approved-light: rgba(34, 197, 94, 0.12);
--status-played: #71717a;
--status-played-light: rgba(113, 113, 122, 0.12);
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
--shadow-md: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.03);
--shadow-lg: 0 8px 24px -4px rgba(0, 0, 0, 0.06), 0 2px 8px -4px rgba(0, 0, 0, 0.04);
--shadow-xl: 0 16px 40px -8px rgba(0, 0, 0, 0.08), 0 4px 16px -8px rgba(0, 0, 0, 0.05);
--radius-sm: 4px;
--radius-md: 6px;
--radius-lg: 8px;
--radius-xl: 10px;
--radius-2xl: 12px;
}
/* 深色主题特定调整 */
+201 -164
View File
@@ -1,89 +1,129 @@
<template>
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
<div class="flex items-center" @touchstart.stop>
<button
:disabled="isFirstDateVisible"
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
@click="scrollDates('left')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15,18 9,12 15,6" /></svg>
</button>
<div
ref="dateSelector"
class="flex-1 flex overflow-x-auto scrollbar-hide gap-2 px-2 py-1 scroll-smooth overscroll-x-contain"
style="overscroll-behavior-x: contain; touch-action: pan-x"
@wheel="handleWheel"
@scroll="handleScroll"
>
<button
v-for="date in availableDates"
:key="date.value"
:data-date="date.value"
:class="[
'flex flex-col items-center justify-center min-w-[64px] h-16 rounded-lg transition-all duration-200 border',
modelValue === date.value
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-900 border-zinc-800 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 hover:border-zinc-700'
]"
@click="selectDate(date.value)"
>
<span class="text-[10px] font-bold uppercase tracking-wider opacity-80">{{ date.weekday }}</span>
<span class="text-lg font-black leading-none my-0.5">{{ date.day }}</span>
<span class="text-[9px] font-bold opacity-60">{{ date.month }}月</span>
</button>
<div class="relative bg-zinc-50/50 border border-zinc-200 rounded-xl p-1 overflow-hidden">
<!-- 简化版日期选择显示 -->
<div class="flex items-center justify-between px-2.5 py-1.5" @click="toggleCalendar" style="cursor: pointer">
<div class="flex items-center gap-2.5">
<div class="flex flex-col items-center justify-center w-9 h-9 rounded-md bg-blue-600 text-white shadow shadow-blue-900/20">
<span class="text-[9px] font-bold uppercase tracking-wider opacity-80 leading-tight">{{ getWeekday(modelValue) }}</span>
<span class="text-base font-black leading-none my-0">{{ getDay(modelValue) }}</span>
</div>
<div class="flex flex-col">
<span class="text-xs font-bold text-zinc-700">{{ modelValue }}</span>
<span class="text-[10px] text-zinc-500 font-medium leading-tight">{{ isToday(modelValue) ? '今天' : '' }}</span>
</div>
</div>
<button
:disabled="isLastDateVisible"
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
@click="scrollDates('right')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
</button>
<!-- 操作按钮组 -->
<div class="flex items-center border-l border-zinc-800 ml-1 pl-1">
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click="scrollToToday">
<CircleDot class="w-5 h-5" />
<div class="flex items-center border-l border-zinc-200 pl-2">
<button class="p-1.5 text-zinc-500 hover:text-emerald-700 transition-colors" title="跳转到今天" @click.stop="scrollToToday">
<CircleDot class="w-4 h-4" />
</button>
<button class="p-2 text-zinc-500 hover:text-blue-400 transition-colors" title="选择特定日期" @click="openManualDatePicker">
<CalendarIcon class="w-5 h-5" />
<button
class="p-1.5 text-zinc-500 hover:text-blue-700 transition-colors"
:class="{ 'text-blue-700': showCalendar }"
title="打开月历"
>
<CalendarIcon class="w-4 h-4" />
</button>
</div>
</div>
<!-- 手动日期选择弹窗 -->
<div
v-if="showManualDatePicker"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden" @click.stop>
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">选择日期</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showManualDatePicker = false">
<CloseIcon class="w-5 h-5" />
<!-- 快捷选择区 + 月历网格 -->
<Transition name="expand">
<div v-if="showCalendar" class="border-t border-zinc-200/60 mt-1 pt-2 px-2 pb-2">
<!-- 快捷按钮 -->
<div class="flex flex-wrap gap-2 mb-3">
<button
v-for="q in quickDates"
:key="q.label"
:class="[
'px-3 py-1.5 rounded-lg text-[11px] font-bold transition-all border',
modelValue === q.value
? 'bg-blue-600 border-blue-500 text-white'
: 'bg-white border-zinc-200 text-zinc-400 hover:text-zinc-700 hover:border-zinc-200'
]"
@click="handleQuickSelect(q.value)"
>
{{ q.label }}
</button>
</div>
<div class="p-6 space-y-6">
<input
v-model="manualSelectedDate"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-200 focus:outline-none focus:border-blue-500 transition-colors"
type="date"
>
<div class="flex gap-3">
<button class="flex-1 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider" @click="showManualDatePicker = false">取消</button>
<button class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider" @click="confirmManualDate">确认</button>
<!-- 月历网格 -->
<div class="bg-white/80 border border-zinc-200 rounded-xl p-3">
<!-- 月份切换 -->
<div class="flex items-center justify-between mb-3 px-1">
<button
class="p-1.5 text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-100 transition-colors"
@click="calendarMonth--"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15,18 9,12 15,6" /></svg>
</button>
<div class="flex items-center gap-2">
<span class="text-sm font-black text-zinc-700">{{ calendarYear }}年</span>
<select
v-model="calendarMonth"
class="bg-white border border-zinc-200 rounded-lg px-2 py-1 text-sm font-black text-zinc-700 focus:outline-none focus:border-blue-500/50"
>
<option v-for="m in 12" :key="m" :value="m - 1">{{ m }}月</option>
</select>
</div>
<div class="flex items-center gap-1">
<button
class="p-1.5 text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-100 transition-colors"
@click="goToday"
title="回到本月"
>
<CircleDot class="w-3.5 h-3.5" />
</button>
<button
class="p-1.5 text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-100 transition-colors"
@click="calendarMonth++"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
</button>
</div>
</div>
<!-- 星期表头 -->
<div class="grid grid-cols-7 mb-1">
<div
v-for="w in weekdayNames"
:key="w"
class="text-center text-[10px] font-black text-zinc-600 uppercase tracking-widest py-1.5"
>
{{ w }}
</div>
</div>
<!-- 日期网格 -->
<div class="grid grid-cols-7 gap-1">
<button
v-for="(cell, idx) in calendarCells"
:key="idx"
:disabled="!cell.inMonth"
:class="[
'relative aspect-square rounded-lg flex items-center justify-center text-sm font-bold transition-all',
!cell.inMonth ? 'text-zinc-800 cursor-default' : '',
cell.inMonth && modelValue !== cell.dateStr && !cell.isToday
? 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700' : '',
cell.inMonth && modelValue !== cell.dateStr && cell.isToday
? 'text-emerald-700 ring-1 ring-emerald-500/40 hover:bg-zinc-100' : '',
modelValue === cell.dateStr
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20' : ''
]"
@click="cell.inMonth && handleQuickSelect(cell.dateStr)"
>
{{ cell.day }}
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick, watch } from 'vue'
import { CircleDot, Calendar as CalendarIcon, X as CloseIcon } from '@lucide/vue'
import { CircleDot, Calendar as CalendarIcon } from '@lucide/vue'
const props = defineProps({
modelValue: {
@@ -94,122 +134,119 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'change'])
const dateSelector = ref(null)
const isFirstDateVisible = ref(false)
const isLastDateVisible = ref(false)
const dateRange = ref({ start: -15, end: 15 })
const showManualDatePicker = ref(false)
const manualSelectedDate = ref('')
const showCalendar = ref(false)
const availableDates = computed(() => {
const dates = []
const today = new Date()
for (let i = dateRange.value.start; i <= dateRange.value.end; i++) {
const date = new Date(today)
date.setDate(today.getDate() + i)
const dateStr = date.toISOString().split('T')[0]
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
dates.push({
value: dateStr,
day: date.getDate().toString().padStart(2, '0'),
month: (date.getMonth() + 1).toString().padStart(2, '0'),
weekday: weekdays[date.getDay()],
isToday: i === 0
const pad = (n) => n.toString().padStart(2, '0')
const formatDateStr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const addDays = (d, n) => { const x = new Date(d); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() + n); return x }
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
const weekdayNames = ['日', '一', '二', '三', '四', '五', '六']
const getWeekday = (dateStr) => {
if (!dateStr) return ''
const d = new Date(dateStr)
return weekdayNames[d.getDay()]
}
const getDay = (dateStr) => {
if (!dateStr) return ''
const d = new Date(dateStr)
return d.getDate().toString().padStart(2, '0')
}
const isToday = (dateStr) => {
if (!dateStr) return false
const d = new Date(dateStr)
return sameDay(d, new Date())
}
const today = new Date(); today.setHours(0, 0, 0, 0)
const calendarYear = ref(today.getFullYear())
const calendarMonth = ref(today.getMonth())
const quickDates = computed(() => {
const t = new Date(); t.setHours(0, 0, 0, 0)
const mk = (label, date) => ({ label, value: formatDateStr(date) })
const nextWeekday = (targetDay) => {
const r = new Date(t)
const diff = (targetDay - r.getDay() + 7) % 7 || 7
r.setDate(r.getDate() + diff)
return r
}
return [
mk('今天', t),
mk('明天', addDays(t, 1)),
mk('后天', addDays(t, 2)),
mk('下周一', nextWeekday(1)),
mk('下周三', nextWeekday(3)),
mk('下周日', nextWeekday(0))
]
})
const calendarCells = computed(() => {
const cells = []
const firstDay = new Date(calendarYear.value, calendarMonth.value, 1)
const startWeekday = firstDay.getDay()
const startDate = addDays(firstDay, -startWeekday)
for (let i = 0; i < 42; i++) {
const d = addDays(startDate, i)
cells.push({
day: d.getDate(),
dateStr: formatDateStr(d),
inMonth: d.getMonth() === calendarMonth.value,
isToday: sameDay(d, today)
})
}
return dates
return cells
})
const selectDate = (dateValue) => {
emit('update:modelValue', dateValue)
emit('change', dateValue)
}
const scrollToDateElement = (behavior = 'smooth') => {
if (!dateSelector.value || !props.modelValue) return
const el = dateSelector.value.querySelector(`[data-date="${props.modelValue}"]`)
if (el) {
el.scrollIntoView({ behavior, block: 'nearest', inline: 'center' })
}
}
const handleWheel = (event) => {
event.preventDefault()
if (!dateSelector.value) return
const scrollAmount = 200
const currentScroll = dateSelector.value.scrollLeft
dateSelector.value.scrollTo({
left: event.deltaY > 0 ? currentScroll + scrollAmount : currentScroll - scrollAmount,
behavior: 'smooth'
})
}
const scrollDates = (direction) => {
if (!dateSelector.value) return
const scrollAmount = 200
const currentScroll = dateSelector.value.scrollLeft
dateSelector.value.scrollTo({
left: direction === 'right' ? currentScroll + scrollAmount : currentScroll - scrollAmount,
behavior: 'smooth'
})
}
const handleScroll = () => {
if (!dateSelector.value) return
const { scrollLeft, scrollWidth, clientWidth } = dateSelector.value
if (scrollLeft < 50) {
const oldScrollWidth = scrollWidth
dateRange.value.start -= 7
nextTick(() => {
const newScrollWidth = dateSelector.value.scrollWidth
dateSelector.value.scrollLeft += newScrollWidth - oldScrollWidth
})
}
if (scrollWidth - scrollLeft - clientWidth < 50) {
dateRange.value.end += 7
}
showCalendar.value = false
}
const scrollToToday = () => {
const todayStr = new Date().toISOString().split('T')[0]
if (props.modelValue !== todayStr) {
selectDate(todayStr)
}
if (dateRange.value.start > 0 || dateRange.value.end < 0) {
dateRange.value.start = -15
dateRange.value.end = 15
}
nextTick(() => scrollToDateElement('smooth'))
const todayStr = formatDateStr(today)
if (props.modelValue !== todayStr) selectDate(todayStr)
}
const openManualDatePicker = () => {
manualSelectedDate.value = props.modelValue
showManualDatePicker.value = true
}
const confirmManualDate = () => {
if (manualSelectedDate.value && manualSelectedDate.value !== props.modelValue) {
selectDate(manualSelectedDate.value)
const toggleCalendar = () => {
showCalendar.value = !showCalendar.value
if (showCalendar.value && props.modelValue) {
const d = new Date(props.modelValue)
calendarYear.value = d.getFullYear()
calendarMonth.value = d.getMonth()
}
showManualDatePicker.value = false
nextTick(() => scrollToDateElement('smooth'))
}
watch(() => props.modelValue, () => {
nextTick(() => scrollToDateElement('smooth'))
})
const goToday = () => {
calendarYear.value = today.getFullYear()
calendarMonth.value = today.getMonth()
}
onMounted(() => {
nextTick(() => scrollToDateElement('auto'))
})
const handleQuickSelect = (dateValue) => {
if (props.modelValue !== dateValue) selectDate(dateValue)
}
</script>
<style scoped>
.scrollbar-hide::-webkit-scrollbar {
display: none;
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.expand-enter-active, .expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
.expand-enter-from, .expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-top: 0 !important;
}
.expand-enter-to, .expand-leave-from {
max-height: 600px;
}
</style>
+293 -177
View File
@@ -1,89 +1,151 @@
<template>
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
<div class="flex items-center" @touchstart.stop>
<button
:disabled="isFirstDateVisible"
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
@click="scrollDates('left')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15,18 9,12 15,6" /></svg>
</button>
<div
ref="dateSelector"
class="flex-1 flex overflow-x-auto scrollbar-hide gap-2 px-2 py-1 scroll-smooth overscroll-x-contain"
style="overscroll-behavior-x: contain; touch-action: pan-x"
@wheel="handleWheel"
@scroll="handleScroll"
>
<button
v-for="date in availableDates"
:key="date.value"
:data-date="date.value"
:class="[
'flex flex-col items-center justify-center min-w-[64px] h-16 rounded-lg transition-all duration-200 border',
modelValue.includes(date.value)
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-900 border-zinc-800 text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300 hover:border-zinc-700'
]"
@click="toggleDate(date.value)"
>
<span class="text-[10px] font-bold uppercase tracking-wider opacity-80">{{ date.weekday }}</span>
<span class="text-lg font-black leading-none my-0.5">{{ date.day }}</span>
<span class="text-[9px] font-bold opacity-60">{{ date.month }}月</span>
</button>
<div class="relative bg-zinc-50/50 border border-zinc-200 rounded-xl p-1 overflow-hidden">
<!-- 简化版多选日期显示 -->
<div class="flex items-center justify-between px-2.5 py-1.5" @click="toggleCalendar" style="cursor: pointer">
<div class="flex items-center gap-2.5 min-w-0 flex-1">
<div class="flex flex-col items-center justify-center w-9 h-9 rounded-md bg-blue-600 text-white shadow shadow-blue-900/20 shrink-0">
<span class="text-[9px] font-bold uppercase tracking-wider opacity-80 leading-tight">
{{ modelValue.length }}
</span>
<span class="text-[10px] font-bold leading-none opacity-80">天</span>
</div>
<div class="flex flex-col min-w-0 flex-1">
<span class="text-xs font-bold text-zinc-700 truncate">
{{ modelValue.length ? modelValue.slice(0, 3).join('、') + (modelValue.length > 3 ? ` 等${modelValue.length}天` : '') : '未选择日期' }}
</span>
<span class="text-[10px] text-zinc-500 font-medium leading-tight truncate">
{{ selectedDatesSummary }}
</span>
</div>
</div>
<button
:disabled="isLastDateVisible"
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
@click="scrollDates('right')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
</button>
<!-- 操作按钮组 -->
<div class="flex items-center border-l border-zinc-800 ml-1 pl-1">
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click="scrollToToday">
<CircleDot class="w-5 h-5" />
<div class="flex items-center border-l border-zinc-200 pl-2 ml-2 shrink-0">
<button class="p-1.5 text-zinc-500 hover:text-emerald-700 transition-colors" title="跳转到今天" @click.stop="scrollToToday">
<CircleDot class="w-4 h-4" />
</button>
<button class="p-2 text-zinc-500 hover:text-blue-400 transition-colors" title="选择特定日期" @click="openManualDatePicker">
<CalendarIcon class="w-5 h-5" />
<button
class="p-1.5 text-zinc-500 hover:text-blue-700 transition-colors"
:class="{ 'text-blue-700': showCalendar }"
title="打开月历"
>
<CalendarIcon class="w-4 h-4" />
</button>
</div>
</div>
<!-- 手动日期选择弹窗 -->
<div
v-if="showManualDatePicker"
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden" @click.stop>
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">选择日期</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showManualDatePicker = false">
<CloseIcon class="w-5 h-5" />
</button>
</div>
<div class="p-6 space-y-6">
<input
v-model="manualSelectedDate"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-200 focus:outline-none focus:border-blue-500 transition-colors"
type="date"
<!-- 扩展面板:快捷批量 + 月历 -->
<Transition name="expand">
<div v-if="showCalendar" class="border-t border-zinc-200/60 mt-1 pt-2 px-2 pb-2 space-y-3">
<!-- 批量操作按钮 -->
<div class="flex flex-wrap items-center gap-2">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">批量选择</span>
<button
v-for="b in batchActions"
:key="b.label"
class="px-2.5 py-1.5 rounded-lg text-[11px] font-bold bg-white border border-zinc-200 text-zinc-400 hover:text-zinc-700 hover:border-zinc-200 transition-all"
@click="applyBatchAction(b.action)"
>
<div class="flex gap-3">
<button class="flex-1 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider" @click="showManualDatePicker = false">取消</button>
<button class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider" @click="confirmManualDate">确认添加</button>
{{ b.label }}
</button>
<div class="w-px h-4 bg-zinc-100 mx-1" />
<button
class="px-2.5 py-1.5 rounded-lg text-[11px] font-bold bg-red-500/10 border border-red-500/20 text-red-400 hover:text-red-300 hover:bg-red-500/15 transition-all"
@click="clearAll"
>
清空所有
</button>
<div class="ml-auto flex items-center gap-2 text-[11px] font-bold text-zinc-500">
<Info :size="12" />
<span>已选 {{ modelValue.length }} 天</span>
<span class="text-zinc-700">·</span>
<span class="text-zinc-600">按住 Shift 点选可范围选择</span>
</div>
</div>
<!-- 月历网格 -->
<div class="bg-white/80 border border-zinc-200 rounded-xl p-3">
<div class="flex items-center justify-between mb-3 px-1">
<button
class="p-1.5 text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-100 transition-colors"
@click="calendarMonth--"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="15,18 9,12 15,6" /></svg>
</button>
<div class="flex items-center gap-2">
<span class="text-sm font-black text-zinc-700">{{ calendarYear }}年</span>
<select
v-model="calendarMonth"
class="bg-white border border-zinc-200 rounded-lg px-2 py-1 text-sm font-black text-zinc-700 focus:outline-none focus:border-blue-500/50"
>
<option v-for="m in 12" :key="m" :value="m - 1">{{ m }}月</option>
</select>
</div>
<div class="flex items-center gap-1">
<button
class="p-1.5 text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-100 transition-colors"
@click="goToday"
title="回到本月"
>
<CircleDot class="w-3.5 h-3.5" />
</button>
<button
class="p-1.5 text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-100 transition-colors"
@click="calendarMonth++"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
</button>
</div>
</div>
<div class="grid grid-cols-7 mb-1">
<div
v-for="w in weekdayNames"
:key="w"
class="text-center text-[10px] font-black text-zinc-600 uppercase tracking-widest py-1.5"
>
{{ w }}
</div>
</div>
<div class="grid grid-cols-7 gap-1">
<button
v-for="(cell, idx) in calendarCells"
:key="idx"
:disabled="!cell.inMonth"
:class="[
'relative aspect-square rounded-lg flex items-center justify-center text-sm font-bold transition-all',
!cell.inMonth ? 'text-zinc-800 cursor-default' : '',
cell.inMonth && !isSelected(cell.dateStr) && !cell.isToday
? 'text-zinc-400 hover:bg-zinc-100 hover:text-zinc-700' : '',
cell.inMonth && !isSelected(cell.dateStr) && cell.isToday
? 'text-emerald-700 ring-1 ring-emerald-500/40 hover:bg-zinc-100' : '',
isSelected(cell.dateStr)
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20' : '',
cell.isRangeMiddle && !isSelected(cell.dateStr)
? 'bg-blue-500/10 text-blue-300' : ''
]"
@click="cell.inMonth && toggleDate(cell.dateStr, $event)"
>
{{ cell.day }}
<svg
v-if="isSelected(cell.dateStr)"
class="absolute top-0.5 right-0.5 w-2.5 h-2.5 text-white"
fill="currentColor"
viewBox="0 0 24 24"
>
<path d="M9 16.17L4.83 12l-1.42 1.41L9 19 21 7l-1.41-1.41L9 16.17z" />
</svg>
</button>
</div>
</div>
</div>
</div>
</Transition>
</div>
</template>
<script setup>
import { ref, computed, onMounted, nextTick } from 'vue'
import { CircleDot, Calendar as CalendarIcon, X as CloseIcon } from '@lucide/vue'
import { CircleDot, Calendar as CalendarIcon, Info } from '@lucide/vue'
const props = defineProps({
modelValue: {
@@ -94,133 +156,187 @@ const props = defineProps({
const emit = defineEmits(['update:modelValue', 'change'])
const dateSelector = ref(null)
const isFirstDateVisible = ref(false)
const isLastDateVisible = ref(false)
const dateRange = ref({ start: 0, end: 30 }) // 默认展示今天起30天
const showManualDatePicker = ref(false)
const manualSelectedDate = ref('')
const showCalendar = ref(false)
const lastClickedDate = ref('')
const availableDates = computed(() => {
const dates = []
const today = new Date()
for (let i = dateRange.value.start; i <= dateRange.value.end; i++) {
const date = new Date(today)
date.setDate(today.getDate() + i)
const dateStr = date.toISOString().split('T')[0]
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
dates.push({
value: dateStr,
day: date.getDate().toString().padStart(2, '0'),
month: (date.getMonth() + 1).toString().padStart(2, '0'),
weekday: weekdays[date.getDay()],
isToday: i === 0
})
}
return dates
const pad = (n) => n.toString().padStart(2, '0')
const formatDateStr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
const addDays = (d, n) => { const x = new Date(d); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() + n); return x }
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
const sortDates = (arr) => [...new Set(arr)].sort()
const today = new Date(); today.setHours(0, 0, 0, 0)
const calendarYear = ref(today.getFullYear())
const calendarMonth = ref(today.getMonth())
const weekdayNames = ['日', '一', '二', '三', '四', '五', '六']
const selectedDatesSummary = computed(() => {
if (!props.modelValue || props.modelValue.length === 0) return '点击右侧按钮选择日期'
const sorted = sortDates(props.modelValue)
const todayStr = formatDateStr(today)
if (sorted.includes(todayStr)) return `含今天 共 ${sorted.length} 天`
const first = sorted[0]
const last = sorted[sorted.length - 1]
return `${first} ~ ${last}`
})
const toggleDate = (dateValue) => {
const newValue = [...props.modelValue]
const index = newValue.indexOf(dateValue)
if (index > -1) {
newValue.splice(index, 1)
} else {
newValue.push(dateValue)
newValue.sort() // 保持日期顺序
}
emit('update:modelValue', newValue)
emit('change', newValue)
}
const batchActions = [
{ label: '今天', action: 'today' },
{ label: '明天起7天', action: 'next7' },
{ label: '本周工作日', action: 'thisWeekWork' },
{ label: '本周全周', action: 'thisWeekAll' },
{ label: '下周工作日', action: 'nextWeekWork' },
{ label: '下周全周', action: 'nextWeekAll' },
{ label: '本月工作日', action: 'thisMonthWork' }
]
const handleWheel = (event) => {
event.preventDefault()
if (!dateSelector.value) return
const scrollAmount = 200
const currentScroll = dateSelector.value.scrollLeft
dateSelector.value.scrollTo({
left: event.deltaY > 0 ? currentScroll + scrollAmount : currentScroll - scrollAmount,
behavior: 'smooth'
})
}
const scrollDates = (direction) => {
if (!dateSelector.value) return
const scrollAmount = 200
const currentScroll = dateSelector.value.scrollLeft
dateSelector.value.scrollTo({
left: direction === 'right' ? currentScroll + scrollAmount : currentScroll - scrollAmount,
behavior: 'smooth'
})
}
const handleScroll = () => {
if (!dateSelector.value) return
const { scrollLeft, scrollWidth, clientWidth } = dateSelector.value
if (scrollLeft < 50) {
const oldScrollWidth = scrollWidth
dateRange.value.start -= 7
nextTick(() => {
const newScrollWidth = dateSelector.value.scrollWidth
dateSelector.value.scrollLeft += newScrollWidth - oldScrollWidth
const calendarCells = computed(() => {
const cells = []
const firstDay = new Date(calendarYear.value, calendarMonth.value, 1)
const startWeekday = firstDay.getDay()
const startDate = addDays(firstDay, -startWeekday)
for (let i = 0; i < 42; i++) {
const d = addDays(startDate, i)
cells.push({
day: d.getDate(),
dateStr: formatDateStr(d),
inMonth: d.getMonth() === calendarMonth.value,
isToday: sameDay(d, today)
})
}
if (scrollWidth - scrollLeft - clientWidth < 50) {
dateRange.value.end += 7
return cells
})
const isSelected = (d) => props.modelValue.includes(d)
const commitChange = (newArr) => {
const sorted = sortDates(newArr)
emit('update:modelValue', sorted)
emit('change', sorted)
}
const toggleDate = (dateValue, event) => {
const newValue = [...props.modelValue]
if (event && event.shiftKey && lastClickedDate.value && !newValue.includes(dateValue)) {
const a = new Date(lastClickedDate.value)
const b = new Date(dateValue)
const [s, e] = a < b ? [a, b] : [b, a]
for (let d = new Date(s); d <= e; d.setDate(d.getDate() + 1)) {
const ds = formatDateStr(d)
if (!newValue.includes(ds)) newValue.push(ds)
}
} else {
const index = newValue.indexOf(dateValue)
if (index > -1) {
newValue.splice(index, 1)
} else {
newValue.push(dateValue)
}
lastClickedDate.value = dateValue
}
commitChange(newValue)
}
const clearAll = () => commitChange([])
const applyBatchAction = (action) => {
const t = new Date(); t.setHours(0, 0, 0, 0)
const toAdd = []
const pushRange = (start, end) => {
for (let d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
toAdd.push(formatDateStr(d))
}
}
const getWeekStart = (base) => {
const r = new Date(base); r.setHours(0, 0, 0, 0)
r.setDate(r.getDate() - r.getDay())
return r
}
switch (action) {
case 'today':
toAdd.push(formatDateStr(t))
break
case 'next7':
pushRange(addDays(t, 1), addDays(t, 7))
break
case 'thisWeekWork': {
const wkStart = getWeekStart(t)
for (let i = 1; i <= 5; i++) toAdd.push(formatDateStr(addDays(wkStart, i)))
break
}
case 'thisWeekAll': {
const wkStart = getWeekStart(t)
pushRange(wkStart, addDays(wkStart, 6))
break
}
case 'nextWeekWork': {
const wkStart = addDays(getWeekStart(t), 7)
for (let i = 1; i <= 5; i++) toAdd.push(formatDateStr(addDays(wkStart, i)))
break
}
case 'nextWeekAll': {
const wkStart = addDays(getWeekStart(t), 7)
pushRange(wkStart, addDays(wkStart, 6))
break
}
case 'thisMonthWork': {
const ym = new Date(t.getFullYear(), t.getMonth(), 1)
const end = new Date(t.getFullYear(), t.getMonth() + 1, 0)
for (let d = new Date(ym); d <= end; d.setDate(d.getDate() + 1)) {
if (d.getDay() >= 1 && d.getDay() <= 5) toAdd.push(formatDateStr(d))
}
break
}
}
const merged = [...new Set([...props.modelValue, ...toAdd])]
commitChange(merged)
}
const scrollToToday = () => {
if (dateRange.value.start > 0 || dateRange.value.end < 0) {
dateRange.value.start = -15
dateRange.value.end = 15
const todayStr = formatDateStr(today)
if (!props.modelValue.includes(todayStr)) {
toggleDate(todayStr, { shiftKey: false })
}
nextTick(() => {
if (!dateSelector.value) return
const todayStr = new Date().toISOString().split('T')[0]
const el = dateSelector.value.querySelector(`[data-date="${todayStr}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
}
})
}
const openManualDatePicker = () => {
manualSelectedDate.value = new Date().toISOString().split('T')[0]
showManualDatePicker.value = true
}
const confirmManualDate = () => {
if (manualSelectedDate.value) {
const newValue = [...props.modelValue]
if (!newValue.includes(manualSelectedDate.value)) {
newValue.push(manualSelectedDate.value)
newValue.sort()
emit('update:modelValue', newValue)
emit('change', newValue)
}
const toggleCalendar = () => {
showCalendar.value = !showCalendar.value
if (showCalendar.value && props.modelValue.length > 0) {
const d = new Date(props.modelValue[props.modelValue.length - 1])
calendarYear.value = d.getFullYear()
calendarMonth.value = d.getMonth()
}
showManualDatePicker.value = false
nextTick(() => {
if (!dateSelector.value) return
const el = dateSelector.value.querySelector(`[data-date="${manualSelectedDate.value}"]`)
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
}
})
}
onMounted(() => {
nextTick(() => scrollToToday())
})
const goToday = () => {
calendarYear.value = today.getFullYear()
calendarMonth.value = today.getMonth()
}
</script>
<style scoped>
.scrollbar-hide::-webkit-scrollbar {
display: none;
.scrollbar-hide::-webkit-scrollbar { display: none; }
.scrollbar-hide { -ms-overflow-style: none; scrollbar-width: none; }
.expand-enter-active, .expand-leave-active {
transition: all 0.25s ease;
overflow: hidden;
}
.scrollbar-hide {
-ms-overflow-style: none;
scrollbar-width: none;
.expand-enter-from, .expand-leave-to {
opacity: 0;
max-height: 0;
padding-top: 0 !important;
padding-bottom: 0 !important;
margin-top: 0 !important;
}
.expand-enter-to, .expand-leave-from {
max-height: 800px;
}
</style>
@@ -5,15 +5,15 @@
class="fixed inset-0 z-[70] bg-black/70 backdrop-blur-sm px-4 py-6 flex items-end sm:items-center justify-center"
>
<div
class="w-full max-w-xl rounded-2xl border border-zinc-800 bg-zinc-950 shadow-2xl overflow-hidden"
class="w-full max-w-xl rounded-2xl border border-zinc-200 bg-white shadow-2xl overflow-hidden"
>
<div class="px-5 py-4 border-b border-zinc-800 flex items-center justify-between gap-3">
<div class="px-5 py-4 border-b border-zinc-200 flex items-center justify-between gap-3">
<div>
<p class="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-black">后台引导</p>
<h3 class="text-lg font-black text-zinc-100 mt-1">{{ currentStep.title }}</h3>
<h3 class="text-lg font-black text-zinc-900 mt-1">{{ currentStep.title }}</h3>
</div>
<button
class="h-9 w-9 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900 transition-colors"
class="h-9 w-9 rounded-lg border border-zinc-200 text-zinc-400 hover:text-zinc-700 hover:bg-white transition-colors"
@click="handleClose"
>
<X :size="16" class="mx-auto" />
@@ -21,29 +21,29 @@
</div>
<div class="px-5 py-5 space-y-4">
<p class="text-sm leading-relaxed text-zinc-300">{{ currentStep.description }}</p>
<div class="rounded-xl border border-blue-500/20 bg-blue-500/5 p-3">
<p class="text-sm leading-relaxed text-zinc-600">{{ currentStep.description }}</p>
<div class="rounded-xl border border-blue-200 bg-blue-500/5 p-3">
<p class="text-xs text-zinc-400">
建议操作:
<span class="text-zinc-200 font-semibold">{{ currentStep.actionHint }}</span>
<span class="text-zinc-700 font-semibold">{{ currentStep.actionHint }}</span>
</p>
</div>
</div>
<div
class="px-5 py-4 border-t border-zinc-800 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
class="px-5 py-4 border-t border-zinc-200 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3"
>
<p class="text-xs text-zinc-500 font-semibold">{{ stepIndex + 1 }} / {{ steps.length }}</p>
<div class="flex items-center gap-2">
<button
v-if="stepIndex > 0"
class="px-3.5 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-sm"
class="px-3.5 py-2 rounded-lg border border-zinc-200 text-zinc-600 hover:bg-white text-sm"
@click="prevStep"
>
上一步
</button>
<button
class="px-3.5 py-2 rounded-lg border border-zinc-700 text-zinc-200 hover:bg-zinc-900 text-sm"
class="px-3.5 py-2 rounded-lg border border-zinc-200 text-zinc-700 hover:bg-white text-sm"
@click="goCurrentStep"
>
前往此功能
+62 -62
View File
@@ -3,7 +3,7 @@
<!-- 头部区域 -->
<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">API密钥管理</h2>
<h2 class="text-2xl font-black text-zinc-900 tracking-tight">API密钥管理</h2>
<p class="text-xs text-zinc-500 mt-1">管理开放API的访问密钥,控制第三方应用的访问权限</p>
</div>
<button
@@ -16,7 +16,7 @@
<!-- 过滤器栏 -->
<div
class="bg-zinc-900/40 border border-zinc-800/60 rounded-2xl p-3 flex flex-col lg:flex-row gap-3 items-center"
class="bg-zinc-50/40 border border-zinc-200/60 rounded-2xl p-3 flex flex-col lg:flex-row gap-3 items-center"
>
<div class="relative flex-1 w-full">
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-700" :size="16" />
@@ -24,7 +24,7 @@
v-model="filters.search"
type="text"
placeholder="搜索API密钥名称或描述..."
class="w-full bg-zinc-950 border border-zinc-800/80 rounded-xl pl-10 pr-4 py-2.5 text-xs focus:outline-none focus:border-blue-500/30 transition-all placeholder:text-zinc-800 text-zinc-200"
class="w-full bg-white border border-zinc-200/80 rounded-xl pl-10 pr-4 py-2.5 text-xs focus:outline-none focus:border-blue-200 transition-all placeholder:text-zinc-500 text-zinc-700"
@input="debouncedSearch"
>
</div>
@@ -42,12 +42,12 @@
v-model="filters.createdBy"
type="text"
placeholder="创建者用户名"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl pl-9 pr-4 py-2.5 text-xs focus:outline-none text-zinc-400 placeholder:text-zinc-800"
class="w-full bg-white border border-zinc-200 rounded-xl pl-9 pr-4 py-2.5 text-xs focus:outline-none text-zinc-400 placeholder:text-zinc-500"
@input="debouncedSearch"
>
</div>
<button
class="p-2.5 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-600 hover:text-blue-400 transition-all"
class="p-2.5 bg-white border border-zinc-200 rounded-xl text-zinc-600 hover:text-blue-700 transition-all"
@click="loadApiKeys"
>
<RefreshCw :size="14" :class="{ 'animate-spin': loading }" />
@@ -69,16 +69,16 @@
class="flex flex-col items-center justify-center py-20 px-4 text-center"
>
<div
class="w-20 h-20 rounded-3xl bg-zinc-900 border border-zinc-800 flex items-center justify-center mb-6 text-zinc-700 shadow-xl"
class="w-20 h-20 rounded-3xl bg-white border border-zinc-200 flex items-center justify-center mb-6 text-zinc-700 shadow-xl"
>
<Key :size="32" :stroke-width="1.5" />
</div>
<h3 class="text-lg font-bold text-zinc-200">暂无API密钥</h3>
<h3 class="text-lg font-bold text-zinc-700">暂无API密钥</h3>
<p class="text-xs text-zinc-500 mt-2 max-w-xs leading-relaxed">
您还没有创建任何访问密钥。创建密钥后,您可以安全地将 VoiceHub 集成到第三方应用程序中。
您还没有创建任何访问密钥。创建密钥后,您可以安全地将宝一外校园点歌系统集成到第三方应用程序中。
</p>
<button
class="mt-8 flex items-center gap-2 px-6 py-3 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-300 text-xs font-bold rounded-2xl transition-all"
class="mt-8 flex items-center gap-2 px-6 py-3 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-600 text-xs font-bold rounded-2xl transition-all"
@click="openCreateModal"
>
<Plus :size="16" /> 创建您的第一个密钥
@@ -89,12 +89,12 @@
<div
v-for="apiKey in apiKeys"
:key="apiKey.id"
class="bg-zinc-900/30 border border-zinc-800/60 rounded-2xl p-6 group hover:border-zinc-700 transition-all relative overflow-hidden"
class="bg-zinc-50/30 border border-zinc-200/60 rounded-2xl p-6 group hover:border-zinc-200 transition-all relative overflow-hidden"
>
<div class="flex items-start justify-between relative z-10">
<div class="space-y-1">
<div class="flex items-center gap-3">
<h4 class="text-sm font-black text-zinc-100 uppercase tracking-widest">
<h4 class="text-sm font-black text-zinc-900 uppercase tracking-widest">
{{ apiKey.name }}
</h4>
<span
@@ -104,7 +104,7 @@
>
<span
v-else-if="apiKey.status === 'inactive'"
class="inline-flex items-center gap-1.5 px-2 py-0.5 bg-zinc-800 text-zinc-500 text-[10px] font-black rounded uppercase border border-zinc-700/50"
class="inline-flex items-center gap-1.5 px-2 py-0.5 bg-zinc-100 text-zinc-500 text-[10px] font-black rounded uppercase border border-zinc-200"
>非活跃</span
>
<span
@@ -119,13 +119,13 @@
class="flex items-center gap-1 opacity-100 lg:opacity-0 group-hover:opacity-100 transition-all"
>
<button
class="p-2 text-zinc-500 hover:text-blue-400 transition-colors"
class="p-2 text-zinc-500 hover:text-blue-700 transition-colors"
@click="viewApiKey(apiKey)"
>
<Eye :size="14" />
</button>
<button
class="p-2 text-zinc-500 hover:text-amber-400 transition-colors"
class="p-2 text-zinc-500 hover:text-amber-700 transition-colors"
@click="editApiKey(apiKey)"
>
<Edit2 :size="14" />
@@ -139,7 +139,7 @@
</div>
</div>
<div class="mt-8 grid grid-cols-2 gap-4 border-t border-zinc-800/40 pt-6 relative z-10">
<div class="mt-8 grid grid-cols-2 gap-4 border-t border-zinc-200 pt-6 relative z-10">
<div class="space-y-0.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>创建者</span
@@ -162,7 +162,7 @@
<span
v-for="perm in apiKey.permissions"
:key="perm"
class="text-[9px] font-mono bg-zinc-950 px-1.5 py-0.5 rounded text-zinc-500 border border-zinc-800/50"
class="text-[9px] font-mono bg-white px-1.5 py-0.5 rounded text-zinc-500 border border-zinc-200"
>
{{ perm }}
</span>
@@ -197,14 +197,14 @@
<!-- 创建/编辑模态框 -->
<div
v-if="showCreateModal || showEditModal"
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
class="relative w-full max-w-lg bg-white border border-zinc-200 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-100 uppercase tracking-widest">
<div class="p-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-900 uppercase tracking-widest">
{{ showCreateModal ? '创建API密钥' : '编辑API密钥' }}
</h3>
<button
class="text-zinc-500 hover:text-zinc-200 transition-colors"
class="text-zinc-500 hover:text-zinc-700 transition-colors"
@click="closeModals"
>
<X :size="20" />
@@ -220,7 +220,7 @@
v-model="form.name"
type="text"
placeholder="输入API密钥名称"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-blue-500/30 text-zinc-200 placeholder:text-zinc-800"
class="w-full bg-white border border-zinc-200 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-blue-200 text-zinc-700 placeholder:text-zinc-500"
>
</div>
<div class="space-y-1.5">
@@ -230,7 +230,7 @@
<textarea
v-model="form.description"
placeholder="输入API密钥描述 (可选)"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-blue-500/30 text-zinc-200 min-h-[80px] resize-none placeholder:text-zinc-800"
class="w-full bg-white border border-zinc-200 rounded-xl px-3 py-2.5 text-xs focus:outline-none focus:border-blue-200 text-zinc-700 min-h-[80px] resize-none placeholder:text-zinc-500"
/>
</div>
<div class="space-y-1.5">
@@ -259,22 +259,22 @@
<label
v-for="perm in availablePermissions"
:key="perm.value"
class="flex items-start gap-3 p-3 bg-zinc-950 border border-zinc-800 rounded-xl cursor-pointer hover:border-zinc-600 transition-all"
class="flex items-start gap-3 p-3 bg-white border border-zinc-200 rounded-xl cursor-pointer hover:border-zinc-300 transition-all"
:class="{
'border-blue-500/30 bg-blue-500/5': form.permissions.includes(perm.value)
'border-blue-200 bg-blue-500/5': form.permissions.includes(perm.value)
}"
>
<input
v-model="form.permissions"
type="checkbox"
:value="perm.value"
class="mt-1 w-3.5 h-3.5 rounded border-zinc-800 bg-zinc-900 accent-blue-600"
class="mt-1 w-3.5 h-3.5 rounded border-zinc-200 bg-white accent-blue-600"
>
<div>
<p
class="text-xs font-bold"
:class="
form.permissions.includes(perm.value) ? 'text-blue-400' : 'text-zinc-300'
form.permissions.includes(perm.value) ? 'text-blue-700' : 'text-zinc-600'
"
>
{{ perm.label }}
@@ -291,18 +291,18 @@
<input
v-model="form.isActive"
type="checkbox"
class="w-3.5 h-3.5 rounded border-zinc-800 bg-zinc-900 accent-blue-600"
class="w-3.5 h-3.5 rounded border-zinc-200 bg-white accent-blue-600"
>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
class="text-xs font-bold text-zinc-600 group-hover:text-blue-700 transition-colors"
>启用此API密钥</span
>
</label>
</div>
<div class="p-6 border-t border-zinc-800 flex gap-2 justify-end">
<div class="p-6 border-t border-zinc-200 flex gap-2 justify-end">
<button
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-600"
@click="closeModals"
>
取消
@@ -320,14 +320,14 @@
<!-- 成功模态框 -->
<div
v-if="showSuccessModal"
class="relative w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
class="relative w-full max-w-lg bg-white border border-zinc-200 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-100 uppercase tracking-widest">
<div class="p-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-900 uppercase tracking-widest">
API密钥创建成功
</h3>
<button
class="text-zinc-500 hover:text-zinc-200 transition-colors"
class="text-zinc-500 hover:text-zinc-700 transition-colors"
@click="closeModals"
>
<X :size="20" />
@@ -343,7 +343,7 @@
>
<Check :size="24" :stroke-width="3" />
</div>
<h4 class="text-lg font-black text-emerald-400">API密钥创建成功!</h4>
<h4 class="text-lg font-black text-emerald-700">API密钥创建成功!</h4>
<p class="text-xs text-zinc-500 mt-2">请妥善保管以下API密钥,它只会显示这一次。</p>
</div>
@@ -353,7 +353,7 @@
>
<div class="flex items-center gap-2">
<div
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 font-mono text-xs text-blue-400 break-all select-all"
class="flex-1 bg-white border border-zinc-200 rounded-xl px-4 py-3 font-mono text-xs text-blue-700 break-all select-all"
>
{{ newApiKey?.apiKey }}
</div>
@@ -362,7 +362,7 @@
:class="
copied
? 'bg-emerald-600 text-white'
: 'bg-zinc-800 hover:bg-zinc-700 text-zinc-300'
: 'bg-zinc-100 hover:bg-zinc-200 text-zinc-600'
"
@click="copyToClipboard(newApiKey?.apiKey)"
>
@@ -381,9 +381,9 @@
</div>
</div>
<div class="p-6 border-t border-zinc-800">
<div class="p-6 border-t border-zinc-200">
<button
class="w-full py-2.5 bg-zinc-950 border border-zinc-800 hover:border-zinc-700 text-zinc-200 text-xs font-black rounded-xl transition-all"
class="w-full py-2.5 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-700 text-xs font-black rounded-xl transition-all"
@click="closeModals"
>
我已保存,关闭窗口
@@ -394,12 +394,12 @@
<!-- 详情模态框 -->
<div
v-if="showViewModal && selectedApiKey"
class="relative w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
class="relative w-full max-w-2xl bg-white border border-zinc-200 rounded-3xl shadow-2xl overflow-hidden animate-in fade-in zoom-in duration-200"
>
<div class="p-6 border-b border-zinc-800 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-100 uppercase tracking-widest">API密钥详情</h3>
<div class="p-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-lg font-black text-zinc-900 uppercase tracking-widest">API密钥详情</h3>
<button
class="text-zinc-500 hover:text-zinc-200 transition-colors"
class="text-zinc-500 hover:text-zinc-700 transition-colors"
@click="closeModals"
>
<X :size="20" />
@@ -409,14 +409,14 @@
<div class="p-6 space-y-8 max-h-[80vh] overflow-y-auto custom-scrollbar">
<section class="space-y-4">
<h5
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest border-b border-zinc-800 pb-2"
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest border-b border-zinc-200 pb-2"
>
基本信息
</h5>
<div class="grid grid-cols-2 gap-6">
<div class="space-y-1">
<span class="text-[10px] text-zinc-600 font-bold">名称:</span>
<p class="text-xs font-bold text-zinc-200">{{ selectedApiKey.name }}</p>
<p class="text-xs font-bold text-zinc-700">{{ selectedApiKey.name }}</p>
</div>
<div class="space-y-1">
<span class="text-[10px] text-zinc-600 font-bold">状态:</span>
@@ -428,7 +428,7 @@
>
<span
v-else-if="selectedApiKey.status === 'inactive'"
class="inline-flex items-center gap-1.5 px-2 py-0.5 bg-zinc-800 text-zinc-500 text-[10px] font-black rounded uppercase border border-zinc-700/50"
class="inline-flex items-center gap-1.5 px-2 py-0.5 bg-zinc-100 text-zinc-500 text-[10px] font-black rounded uppercase border border-zinc-200"
>非活跃</span
>
<span
@@ -446,13 +446,13 @@
</div>
<div class="space-y-1">
<span class="text-[10px] text-zinc-600 font-bold">创建者:</span>
<p class="text-xs font-bold text-zinc-200">
<p class="text-xs font-bold text-zinc-700">
{{ selectedApiKey.creatorName || '未知' }}
</p>
</div>
<div class="space-y-1">
<span class="text-[10px] text-zinc-600 font-bold">创建时间:</span>
<p class="text-xs font-bold text-zinc-200">
<p class="text-xs font-bold text-zinc-700">
{{ formatDate(selectedApiKey.createdAt) }}
</p>
</div>
@@ -460,7 +460,7 @@
<span class="text-[10px] text-zinc-600 font-bold">过期时间:</span>
<p
class="text-xs font-bold"
:class="selectedApiKey.isExpired ? 'text-red-400' : 'text-zinc-200'"
:class="selectedApiKey.isExpired ? 'text-red-400' : 'text-zinc-700'"
>
{{ formatDate(selectedApiKey.expiresAt) }}
</p>
@@ -470,19 +470,19 @@
<section class="space-y-4">
<h5
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest border-b border-zinc-800 pb-2"
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest border-b border-zinc-200 pb-2"
>
使用统计
</h5>
<div class="grid grid-cols-2 gap-4">
<div
class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl flex items-center justify-between group hover:border-blue-500/30 transition-all"
class="p-4 bg-white border border-zinc-200 rounded-2xl flex items-center justify-between group hover:border-blue-200 transition-all"
>
<div class="space-y-0.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>总调用次数</span
>
<p class="text-xl font-black text-zinc-100">
<p class="text-xl font-black text-zinc-900">
{{ selectedApiKey.usageCount || 0 }}
</p>
</div>
@@ -493,13 +493,13 @@
</div>
</div>
<div
class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl flex items-center justify-between group hover:border-amber-500/30 transition-all"
class="p-4 bg-white border border-zinc-200 rounded-2xl flex items-center justify-between group hover:border-amber-200 transition-all"
>
<div class="space-y-0.5">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>最后使用时间</span
>
<p class="text-xs font-bold text-zinc-100">
<p class="text-xs font-bold text-zinc-900">
{{
selectedApiKey.lastUsedAt
? formatDate(selectedApiKey.lastUsedAt)
@@ -517,7 +517,7 @@
</section>
<section class="space-y-4">
<div class="flex items-center justify-between border-b border-zinc-800 pb-2">
<div class="flex items-center justify-between border-b border-zinc-200 pb-2">
<h5 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
最近调用日志
</h5>
@@ -538,7 +538,7 @@
</div>
<div
v-else-if="apiLogs.length === 0"
class="flex flex-col items-center justify-center py-10 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl"
class="flex flex-col items-center justify-center py-10 bg-white/70 border border-zinc-200 rounded-2xl"
>
<History :size="24" class="text-zinc-800 mb-2" />
<span class="text-[10px] font-bold text-zinc-700 uppercase tracking-widest"
@@ -549,7 +549,7 @@
v-for="log in apiLogs"
v-else
:key="log.id"
class="p-3 bg-zinc-950 border border-zinc-800/40 rounded-xl flex items-center justify-between group hover:border-zinc-700 transition-all"
class="p-3 bg-white border border-zinc-200 rounded-xl flex items-center justify-between group hover:border-zinc-200 transition-all"
>
<div class="flex items-center gap-4">
<div
@@ -1002,11 +1002,11 @@ const copyToClipboard = async (text) => {
const getMethodClass = (method) => {
const map = {
GET: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
POST: 'bg-blue-500/10 text-blue-500 border-blue-500/20',
POST: 'bg-blue-500/10 text-blue-500 border-blue-200',
PUT: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
DELETE: 'bg-red-500/10 text-red-500 border-red-500/20'
}
return map[method] || 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20'
return map[method] || 'bg-zinc-100 text-zinc-500 border-zinc-200'
}
const getStatusColorClass = (code) => {
@@ -1025,8 +1025,8 @@ onMounted(() => {
.loading-spinner {
width: 24px;
height: 24px;
border: 2px solid rgba(255, 255, 255, 0.1);
border-top-color: #3b82f6;
border: 2px solid rgba(0, 0, 0, 0.08);
border-top-color: var(--border-secondary, #3b82f6);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@@ -1044,7 +1044,7 @@ onMounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+33 -33
View File
@@ -520,8 +520,8 @@ const formatFileSize = (bytes) => {
/* 主容器 */
.backup-manager {
min-height: 100vh;
background: #1a1a1a;
color: #e5e5e5;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #e5e5e5);
padding: 2rem;
position: relative;
}
@@ -535,13 +535,13 @@ const formatFileSize = (bytes) => {
.header h3 {
font-size: 2.5rem;
font-weight: 700;
color: #ffffff;
color: var(--text-primary, #ffffff);
margin: 0 0 1rem 0;
}
.header .description {
font-size: 1.125rem;
color: #a0a0a0;
color: var(--text-secondary, #a0a0a0);
margin: 0;
}
@@ -556,8 +556,8 @@ const formatFileSize = (bytes) => {
/* 操作卡片 */
.action-card {
background: #2a2a2a;
border: 1px solid #404040;
background: var(--bg-tertiary, #2a2a2a);
border: 1px solid var(--border-secondary, #404040);
border-radius: 12px;
padding: 2rem;
text-align: center;
@@ -566,7 +566,7 @@ const formatFileSize = (bytes) => {
.action-card:hover {
border-color: #606060;
background: #2f2f2f;
background: var(--bg-tertiary, #2f2f2f);
}
.action-card .card-content {
@@ -587,7 +587,7 @@ const formatFileSize = (bytes) => {
display: flex;
align-items: center;
justify-content: center;
color: #ffffff;
color: var(--text-primary, #ffffff);
}
.action-icon svg {
@@ -599,7 +599,7 @@ const formatFileSize = (bytes) => {
.action-card h4 {
font-size: 1.5rem;
font-weight: 600;
color: #ffffff;
color: var(--text-primary, #ffffff);
margin: 0 0 0.75rem 0;
}
@@ -623,7 +623,7 @@ const formatFileSize = (bytes) => {
transition: all 0.2s ease;
display: block;
text-align: center;
color: white;
color: var(--text-primary, #09090b);
box-sizing: border-box;
margin-bottom: 0.5rem;
}
@@ -672,8 +672,8 @@ const formatFileSize = (bytes) => {
}
.modal {
background: #2a2a2a;
border: 1px solid #404040;
background: var(--bg-tertiary, #2a2a2a);
border: 1px solid var(--border-secondary, #404040);
border-radius: 12px;
max-width: 600px;
width: 100%;
@@ -724,7 +724,7 @@ const formatFileSize = (bytes) => {
.progress-details h5,
.progress-errors h5 {
margin: 0 0 10px 0;
color: #ffffff;
color: var(--text-primary, #ffffff);
font-size: 14px;
font-weight: 600;
}
@@ -736,14 +736,14 @@ const formatFileSize = (bytes) => {
border: 1px solid #505050;
border-radius: 6px;
padding: 10px;
background: #333333;
background: var(--bg-quaternary, #333333);
}
.detail-item {
padding: 4px 0;
color: #a0a0a0;
font-size: 13px;
border-bottom: 1px solid #404040;
border-bottom: 1px solid var(--border-secondary, #404040);
}
.detail-item:last-child {
@@ -754,7 +754,7 @@ const formatFileSize = (bytes) => {
padding: 4px 0;
color: #ff6b6b;
font-size: 13px;
border-bottom: 1px solid #4a2a2a;
border-bottom: 1px solid var(--border-secondary, #4a2a2a);
}
.error-item:last-child {
@@ -772,7 +772,7 @@ const formatFileSize = (bytes) => {
.modal-title {
font-size: 1.75rem;
font-weight: 600;
color: #ffffff;
color: var(--text-primary, #ffffff);
margin: 0;
}
@@ -788,7 +788,7 @@ const formatFileSize = (bytes) => {
.close-btn:hover {
background: #505050;
color: #ffffff;
color: var(--text-primary, #ffffff);
}
.close-btn svg {
@@ -802,11 +802,11 @@ const formatFileSize = (bytes) => {
.modal-footer {
padding: 1.5rem 2rem;
border-top: 1px solid #404040;
border-top: 1px solid var(--border-secondary, #404040);
display: flex;
gap: 1rem;
justify-content: flex-end;
background: #252525;
background: var(--bg-tertiary, #252525);
}
.modal-footer button {
@@ -822,7 +822,7 @@ const formatFileSize = (bytes) => {
display: block;
font-size: 1.125rem;
font-weight: 600;
color: #ffffff;
color: var(--text-primary, #ffffff);
margin-bottom: 1rem;
}
@@ -838,7 +838,7 @@ const formatFileSize = (bytes) => {
align-items: flex-start;
gap: 1rem;
padding: 1.25rem;
background: #333333;
background: var(--bg-quaternary, #333333);
border: 1px solid #505050;
border-radius: 8px;
cursor: pointer;
@@ -870,7 +870,7 @@ const formatFileSize = (bytes) => {
display: block;
font-size: 1rem;
font-weight: 500;
color: #ffffff;
color: var(--text-primary, #ffffff);
margin-bottom: 0.25rem;
}
@@ -907,12 +907,12 @@ const formatFileSize = (bytes) => {
text-align: center;
cursor: pointer;
transition: all 0.2s ease;
background: #333333;
background: var(--bg-quaternary, #333333);
}
.upload-area:hover,
.upload-area.drag-over {
border-color: #4a90e2;
border-color: var(--border-secondary, #4a90e2);
background: #383838;
}
@@ -926,7 +926,7 @@ const formatFileSize = (bytes) => {
.upload-area h4 {
font-size: 1.25rem;
font-weight: 600;
color: #ffffff;
color: var(--text-primary, #ffffff);
margin: 0 0 0.5rem 0;
}
@@ -941,7 +941,7 @@ const formatFileSize = (bytes) => {
align-items: center;
justify-content: space-between;
padding: 1.25rem;
background: #333333;
background: var(--bg-quaternary, #333333);
border: 1px solid #505050;
border-radius: 8px;
margin-top: 1rem;
@@ -969,7 +969,7 @@ const formatFileSize = (bytes) => {
display: block;
font-size: 1rem;
font-weight: 500;
color: #ffffff;
color: var(--text-primary, #ffffff);
margin-bottom: 0.25rem;
}
@@ -1043,7 +1043,7 @@ const formatFileSize = (bytes) => {
right: 1.5rem;
padding: 1rem 1.5rem;
border-radius: 8px;
color: white;
color: var(--text-primary, #09090b);
font-weight: 500;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
z-index: 1001;
@@ -1056,8 +1056,8 @@ const formatFileSize = (bytes) => {
}
.notification.success {
background: #2e7d32;
border: 1px solid #4caf50;
background: var(--bg-tertiary, #2e7d32);
border: 1px solid var(--border-secondary, #4caf50);
}
.notification.error {
@@ -1071,8 +1071,8 @@ const formatFileSize = (bytes) => {
}
.notification.info {
background: #1976d2;
border: 1px solid #2196f3;
background: var(--bg-secondary, #1976d2);
border: 1px solid var(--border-secondary, #2196f3);
}
@keyframes slideInRight {
+39 -39
View File
@@ -13,13 +13,13 @@
@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]"
class="bg-white border border-zinc-200 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 class="p-8 pb-4 flex items-center justify-between border-b border-zinc-200">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<h3 class="text-xl font-black text-zinc-900 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"
>
@@ -32,7 +32,7 @@
</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"
class="p-3 bg-zinc-100/50 hover:bg-zinc-100 text-zinc-500 hover:text-zinc-700 rounded-xl transition-all"
@click="$emit('close')"
>
<X :size="20" />
@@ -47,7 +47,7 @@
'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'
: 'bg-white border-zinc-200 hover:border-zinc-200'
]"
>
<input v-model="updateType" type="radio" value="grade-only" class="sr-only" >
@@ -57,7 +57,7 @@
'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'
: 'bg-zinc-100 text-zinc-500 group-hover:text-zinc-600'
]"
>
<Calendar :size="18" />
@@ -69,7 +69,7 @@
<Check :size="12" class="text-white" />
</div>
</div>
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
<span class="text-sm font-black text-zinc-700 uppercase tracking-widest"
>仅更新年级</span
>
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
@@ -82,7 +82,7 @@
'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'
: 'bg-white border-zinc-200 hover:border-zinc-200'
]"
>
<input v-model="updateType" type="radio" value="excel-batch" class="sr-only" >
@@ -92,7 +92,7 @@
'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'
: 'bg-zinc-100 text-zinc-500 group-hover:text-zinc-600'
]"
>
<FileSpreadsheet :size="18" />
@@ -104,7 +104,7 @@
<Check :size="12" class="text-white" />
</div>
</div>
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
<span class="text-sm font-black text-zinc-700 uppercase tracking-widest"
>Excel 批量更新</span
>
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
@@ -117,7 +117,7 @@
'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'
: 'bg-white border-zinc-200 hover:border-zinc-200'
]"
>
<input v-model="updateType" type="radio" value="status-batch" class="sr-only" >
@@ -127,7 +127,7 @@
'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'
: 'bg-zinc-100 text-zinc-500 group-hover:text-zinc-600'
]"
>
<ShieldAlert :size="18" />
@@ -139,7 +139,7 @@
<Check :size="12" class="text-white" />
</div>
</div>
<span class="text-sm font-black text-zinc-200 uppercase tracking-widest"
<span class="text-sm font-black text-zinc-700 uppercase tracking-widest"
>设置账户状态</span
>
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
@@ -153,7 +153,7 @@
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="p-6 bg-white/70 border border-zinc-200 rounded-xl space-y-6">
<div
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest"
>
@@ -203,7 +203,7 @@
</button>
</div>
<div
class="max-h-48 overflow-y-auto rounded-lg border border-zinc-800 bg-zinc-950 p-2 custom-scrollbar"
class="max-h-48 overflow-y-auto rounded-lg border border-zinc-200 bg-white p-2 custom-scrollbar"
>
<div
v-if="filteredStudents.length === 0"
@@ -215,17 +215,17 @@
<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"
class="flex items-center gap-3 p-3 rounded-lg hover:bg-zinc-50/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"
class="w-4 h-4 rounded-md border-zinc-200 bg-white text-purple-700 focus:ring-purple-500/30"
>
<div class="flex flex-col">
<span
class="text-xs font-bold text-zinc-200 group-hover:text-purple-400 transition-colors"
class="text-xs font-bold text-zinc-700 group-hover:text-purple-400 transition-colors"
>{{ student.name }}</span
>
<span class="text-[10px] text-zinc-600 font-mono">{{
@@ -261,21 +261,21 @@
/>
</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"
class="flex items-center gap-3 p-3 bg-white border border-zinc-200 rounded-lg cursor-pointer hover:border-zinc-200 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"
class="w-5 h-5 rounded-md border-zinc-200 bg-white text-purple-700 focus:ring-purple-500/30"
>
<span class="text-xs font-bold text-zinc-300">保持原有班级不变</span>
<span class="text-xs font-bold text-zinc-600">保持原有班级不变</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">
<div class="flex items-center gap-2 text-xs font-black text-amber-700 uppercase tracking-widest">
<Save :size="14" />
目标状态设置
</div>
@@ -299,7 +299,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-lg pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-200 transition-all text-zinc-700 resize-none"
></textarea>
</div>
</div>
@@ -336,19 +336,19 @@
'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'
: 'border-zinc-200 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',
'w-16 h-16 rounded-lg bg-white 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">
<p class="text-base font-black text-zinc-700 tracking-tight">
拖拽 Excel 文件到此处
</p>
<p class="text-xs text-zinc-500 mt-1">
@@ -362,7 +362,7 @@
<!-- 模板与说明 -->
<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"
class="md:col-span-2 p-5 bg-white border border-zinc-200 rounded-xl space-y-3"
>
<div
class="flex items-center gap-2 text-[10px] font-black text-zinc-500 uppercase tracking-widest"
@@ -372,15 +372,15 @@
</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" />
<div class="w-1 h-1 rounded-full bg-zinc-200 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" />
<div class="w-1 h-1 rounded-full bg-zinc-200 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" />
<div class="w-1 h-1 rounded-full bg-zinc-200 mt-1.5" />
字段留空则保持原值不变,不进行覆盖更新
</li>
</ul>
@@ -417,11 +417,11 @@
</div>
</div>
</div>
<div class="rounded-xl border border-zinc-800 bg-zinc-950 overflow-hidden shadow-xl">
<div class="rounded-xl border border-zinc-200 bg-white 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"
class="bg-zinc-50/80 text-[10px] font-black text-zinc-500 uppercase tracking-widest border-b border-zinc-200"
>
<tr>
<th class="px-5 py-4 whitespace-nowrap">匹配用户</th>
@@ -435,7 +435,7 @@
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'
row.error ? 'bg-red-500/5' : 'hover:bg-zinc-50/30 transition-colors'
]"
>
<td class="px-5 py-4">
@@ -443,7 +443,7 @@
<span
:class="[
'text-xs font-bold',
row.error ? 'text-red-400' : 'text-zinc-200'
row.error ? 'text-red-400' : 'text-zinc-700'
]"
>{{ row.username }}</span
>
@@ -459,11 +459,11 @@
</td>
<td class="px-5 py-4">
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-emerald-400">{{
<span class="text-xs font-bold text-emerald-700">{{
row.newGrade || row.currentGrade || '-'
}}</span>
<span class="text-[10px] text-zinc-700">/</span>
<span class="text-xs font-bold text-emerald-400">{{
<span class="text-xs font-bold text-emerald-700">{{
row.newClass || row.currentClass || '-'
}}</span>
</div>
@@ -488,7 +488,7 @@
</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"
class="p-4 text-center border-t border-zinc-900 bg-zinc-50/20 text-[10px] text-zinc-600 font-bold uppercase tracking-widest"
>
以及另外 {{ excelPreviewData.length - 10 }} 条记录已在队列中
</div>
@@ -507,9 +507,9 @@
</div>
<!-- 底部按钮 -->
<div class="p-8 pt-4 border-t border-zinc-800/50 bg-zinc-900/50 flex gap-3">
<div class="p-8 pt-4 border-t border-zinc-200 bg-zinc-50/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"
class="flex-1 px-6 py-4 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="$emit('close')"
>
取消操作
+127 -50
View File
@@ -5,7 +5,7 @@
<!-- 页面标题 -->
<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 class="text-2xl font-black text-zinc-900 tracking-tight flex items-center gap-3">
歌曲黑名单管理
</h2>
<p class="text-xs text-zinc-500 mt-1 font-medium">
@@ -15,47 +15,71 @@
</div>
<!-- 添加黑名单项表单 -->
<section class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-8 shadow-xl">
<section class="bg-zinc-50/40 border border-zinc-200 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">
<div class="xl:col-span-3 space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>封禁类型</label
>
<div class="flex p-1 bg-zinc-950 border border-zinc-800 rounded-lg">
<div class="flex p-1 bg-white border border-zinc-200 rounded-lg gap-1">
<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'"
:class="newItem.type === 'SONG' ? 'bg-zinc-100 text-blue-700' : 'text-zinc-600'"
@click="newItem.type = 'SONG'"
>
<Music :size="12" /> 具体歌曲
<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'"
:class="newItem.type === 'KEYWORD' ? 'bg-zinc-100 text-purple-400' : 'text-zinc-600'"
@click="newItem.type = 'KEYWORD'"
>
<Type :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 === 'LANGUAGE' ? 'bg-zinc-100 text-amber-700' : 'text-zinc-600'"
@click="newItem.type = 'LANGUAGE'"
>
<Languages :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 === 'GENRE' ? 'bg-zinc-100 text-emerald-700' : 'text-zinc-600'"
@click="newItem.type = 'GENRE'"
>
<Disc3 :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' ? '歌曲名称 - 艺术家' : '关键词内容' }}
{{ typeValueLabel }}
</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"
:placeholder="typePlaceholder"
class="w-full bg-white border border-zinc-200 rounded-lg px-5 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 placeholder:text-zinc-700 transition-all"
>
</div>
<div v-if="newItem.type === 'LANGUAGE' || newItem.type === 'GENRE'" class="xl:col-span-5 xl:col-start-auto space-y-1">
<p class="text-[10px] text-zinc-600 font-bold leading-relaxed px-1 flex items-start gap-1.5">
<Icon name="lightbulb" :size="13" class="text-amber-600 shrink-0 mt-[2px]" />
<template v-if="newItem.type === 'LANGUAGE'">
支持多语种匹配:中文 / 英文 / 日文 / 韩文 / 俄文 / 阿拉伯文 等,支持逗号分隔,例如:<span class="text-amber-700">日文,韩文</span>
</template>
<template v-else>
支持曲风关键词,可逗号分隔,例如:<span class="text-emerald-700">DJ,摇滚,rap</span>
</template>
</p>
</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
@@ -64,7 +88,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-lg px-5 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 placeholder:text-zinc-700 transition-all"
>
</div>
@@ -87,7 +111,7 @@
<!-- 筛选和搜索 -->
<div
class="bg-zinc-900/20 border border-zinc-800 rounded-xl p-4 flex flex-col md:flex-row gap-4 items-center"
class="bg-zinc-50/20 border border-zinc-200 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" />
@@ -95,7 +119,7 @@
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"
class="w-full bg-white/70 border border-zinc-200 rounded-lg pl-12 pr-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 placeholder:text-zinc-700 transition-all"
@input="debounceSearch"
>
</div>
@@ -103,12 +127,12 @@
<CustomSelect
label="类型筛选"
:value="typeFilterLabel"
:options="['全部类型', '具体歌曲', '关键词']"
: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"
class="p-3 bg-white border border-zinc-200 rounded-lg text-zinc-600 hover:text-blue-700 transition-all active:scale-95"
@click="loadBlacklist"
>
<Filter :size="18" />
@@ -136,35 +160,29 @@
<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']"
class="group flex flex-col lg:flex-row lg:items-center gap-6 p-6 bg-zinc-50/30 border rounded-xl transition-all hover:border-zinc-200"
:class="[item.isActive ? 'border-zinc-200' : 'border-zinc-200 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'
]"
:class="getTypeIconClass(item.type)"
>
<Music v-if="item.type === 'SONG'" :size="22" />
<Type v-else :size="22" />
<Type v-else-if="item.type === 'KEYWORD'" :size="22" />
<Languages v-else-if="item.type === 'LANGUAGE'" :size="22" />
<Disc3 v-else :size="22" />
</div>
<div class="space-y-1.5 min-w-0">
<div class="flex items-center gap-3">
<span
class="text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded border"
:class="[
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'
]"
:class="getTypeBadgeClass(item.type)"
>
{{ item.type === 'SONG' ? '具体歌曲' : '关键词' }}
{{ getTypeLabel(item.type) }}
</span>
<h5 class="text-base font-black text-zinc-100 truncate tracking-tight">
<h5 class="text-base font-black text-zinc-900 truncate tracking-tight">
{{ item.value }}
</h5>
</div>
@@ -185,14 +203,14 @@
</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"
class="flex items-center justify-end gap-3 pt-4 lg:pt-0 border-t lg:border-t-0 border-zinc-200"
>
<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-zinc-100 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)"
@@ -202,7 +220,7 @@
</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"
class="p-3 bg-white border border-zinc-200 rounded-xl text-zinc-700 hover:text-red-500 hover:border-red-200 transition-all active:scale-95"
@click="deleteItem(item)"
>
<Trash2 :size="16" />
@@ -217,7 +235,7 @@
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"
class="w-12 h-12 border-4 border-zinc-200 border-t-blue-500 rounded-full animate-spin"
/>
<p class="text-zinc-500 text-xs font-bold uppercase tracking-widest">加载中...</p>
</div>
@@ -225,10 +243,10 @@
<!-- 空状态 -->
<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"
class="py-20 flex flex-col items-center justify-center text-center space-y-4 bg-zinc-50/10 border border-zinc-200 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"
class="w-16 h-16 rounded-lg bg-white border border-zinc-200 flex items-center justify-center text-zinc-800 shadow-xl"
>
<Ban :size="32" />
</div>
@@ -245,19 +263,19 @@
<div v-if="pagination.pages > 1" class="flex items-center justify-center gap-4 mt-8">
<button
:disabled="pagination.page <= 1 || loading"
class="p-3 bg-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"
class="p-3 bg-white border border-zinc-200 rounded-lg text-zinc-400 hover:text-zinc-900 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"
class="px-6 py-3 bg-zinc-50/50 border border-zinc-200 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"
class="p-3 bg-white border border-zinc-200 rounded-lg text-zinc-400 hover:text-zinc-900 disabled:opacity-30 disabled:cursor-not-allowed transition-all active:scale-95"
@click="changePage(pagination.page + 1)"
>
<ChevronRight :size="20" />
@@ -276,10 +294,10 @@
>
<div
v-if="showDeleteDialog"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
class="fixed inset-0 z-[100] flex items-center justify-center p-4 bg-white/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"
class="w-full max-w-md bg-white border border-zinc-200 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">
@@ -289,16 +307,16 @@
<Trash2 :size="28" />
</div>
<div class="space-y-2">
<h4 class="text-lg font-bold text-zinc-100">确认从黑名单移除</h4>
<h4 class="text-lg font-bold text-zinc-900">确认从黑名单移除</h4>
<p class="text-xs text-zinc-500 leading-relaxed">
确定要移除
<span class="text-zinc-300 font-bold">"{{ deleteTargetItem?.value }}"</span>
<span class="text-zinc-600 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"
class="flex-1 px-4 py-4 bg-zinc-100 hover:bg-zinc-200 text-zinc-400 text-[10px] font-black rounded-lg transition-all uppercase tracking-widest"
@click="showDeleteDialog = false"
>
取消
@@ -318,7 +336,7 @@
</div>
</template>
<script setup>
<script setup lang="ts">
import { onMounted, reactive, ref, computed } from 'vue'
import {
ShieldAlert,
@@ -334,7 +352,9 @@ import {
Ban,
ChevronLeft,
ChevronRight,
ChevronDown
ChevronDown,
Languages,
Disc3
} from '@lucide/vue'
import { useToast } from '~/composables/useToast'
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
@@ -371,9 +391,65 @@ const pagination = reactive({
const typeFilterLabel = computed(() => {
if (filters.type === 'SONG') return '具体歌曲'
if (filters.type === 'KEYWORD') return '关键词'
if (filters.type === 'LANGUAGE') return '语种'
if (filters.type === 'GENRE') return '曲风'
return '全部类型'
})
const typeValueLabel = computed(() => {
if (newItem.type === 'SONG') return '歌曲名称 - 艺术家'
if (newItem.type === 'KEYWORD') return '关键词内容'
if (newItem.type === 'LANGUAGE') return '语种名称'
if (newItem.type === 'GENRE') return '曲风名称'
return '内容'
})
const typePlaceholder = computed(() => {
if (newItem.type === 'SONG') return '例如: 歌曲名 - 艺术家'
if (newItem.type === 'KEYWORD') return '例如: 敏感词、特定风格'
if (newItem.type === 'LANGUAGE') return '例如: 日文 或 中文,英文,日文'
if (newItem.type === 'GENRE') return '例如: DJ 或 流行,摇滚,rap'
return '请输入内容'
})
const getTypeLabel = (type: string) => {
if (type === 'SONG') return '具体歌曲'
if (type === 'KEYWORD') return '关键词'
if (type === 'LANGUAGE') return '语种'
if (type === 'GENRE') return '曲风'
return type
}
const getTypeIconClass = (type: string) => {
switch (type) {
case 'SONG':
return 'bg-blue-50 text-blue-500 border-blue-200 shadow-lg shadow-blue-900/5'
case 'KEYWORD':
return 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5'
case 'LANGUAGE':
return 'bg-amber-600/10 text-amber-500 border-amber-500/20 shadow-lg shadow-amber-900/5'
case 'GENRE':
return 'bg-emerald-600/10 text-emerald-500 border-emerald-500/20 shadow-lg shadow-emerald-900/5'
default:
return 'bg-zinc-600/10 text-zinc-500 border-zinc-200'
}
}
const getTypeBadgeClass = (type: string) => {
switch (type) {
case 'SONG':
return 'bg-blue-50 text-blue-700 border-blue-200'
case 'KEYWORD':
return 'bg-purple-600/10 text-purple-400 border-purple-500/20'
case 'LANGUAGE':
return 'bg-amber-600/10 text-amber-700 border-amber-500/20'
case 'GENRE':
return 'bg-emerald-600/10 text-emerald-700 border-emerald-500/20'
default:
return 'bg-zinc-600/10 text-zinc-400 border-zinc-200'
}
}
let searchTimeout = null
// 加载黑名单
@@ -404,10 +480,11 @@ const loadBlacklist = async () => {
}
}
// 处理类型筛选变更
const handleTypeFilterChange = (label) => {
if (label === '具体歌曲') filters.type = 'SONG'
else if (label === '关键词') filters.type = 'KEYWORD'
else if (label === '语种') filters.type = 'LANGUAGE'
else if (label === '曲风') filters.type = 'GENRE'
else filters.type = ''
pagination.page = 1
@@ -569,7 +646,7 @@ onMounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
</style>
+22 -22
View File
@@ -2,12 +2,12 @@
<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>
<h2 class="text-2xl font-black text-zinc-900 tracking-tight">更新日志</h2>
<p class="text-xs text-zinc-500 mt-1 font-medium">管理前台“更新日志”页面的发布内容</p>
</div>
<div class="flex gap-3">
<button
class="flex items-center gap-2 px-5 py-2 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
class="flex items-center gap-2 px-5 py-2 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
:disabled="loading"
@click="load"
>
@@ -23,7 +23,7 @@
</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" />
<div class="w-8 h-8 border-4 border-zinc-200 border-t-blue-500 rounded-full animate-spin mb-4" />
<p class="text-zinc-500 text-sm">加载中...</p>
</div>
@@ -31,20 +31,20 @@
<div
v-for="item in entries"
:key="item.id"
class="bg-zinc-900/40 border border-zinc-800 rounded-2xl p-5 flex flex-col md:flex-row md:items-center md:justify-between gap-4"
class="bg-zinc-50/40 border border-zinc-200 rounded-2xl p-5 flex flex-col md:flex-row md:items-center md:justify-between gap-4"
>
<div class="min-w-0">
<div class="flex items-center gap-2 flex-wrap">
<span
class="px-2.5 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest"
:class="item.published ? 'bg-emerald-500/10 text-emerald-300' : 'bg-zinc-800/50 text-zinc-400'"
:class="item.published ? 'bg-emerald-500/10 text-emerald-700' : 'bg-zinc-100 text-zinc-600'"
>
{{ item.published ? '已发布' : '草稿' }}
</span>
<span class="px-2.5 py-1 rounded-lg bg-blue-500/10 text-blue-300 text-[10px] font-black">
<span class="px-2.5 py-1 rounded-lg bg-blue-50 text-blue-700 text-[10px] font-black">
v{{ item.version }}
</span>
<p class="text-sm font-black text-zinc-100 truncate">{{ item.title }}</p>
<p class="text-sm font-black text-zinc-900 truncate">{{ item.title }}</p>
</div>
<p class="text-[10px] text-zinc-500 mt-2">
{{ formatDate(item.publishedAt || item.createdAt) }}
@@ -53,13 +53,13 @@
<div class="flex items-center gap-2">
<button
class="px-3 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-xs font-bold"
class="px-3 py-2 rounded-lg border border-zinc-200 text-zinc-600 hover:bg-white text-xs font-bold"
@click="openEdit(item)"
>
<Pencil :size="14" class="inline-block mr-1" /> 编辑
</button>
<button
class="px-3 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-xs font-bold"
class="px-3 py-2 rounded-lg border border-zinc-200 text-zinc-600 hover:bg-white text-xs font-bold"
:disabled="savingId === item.id"
@click="togglePublish(item)"
>
@@ -67,7 +67,7 @@
{{ item.published ? '取消发布' : '发布' }}
</button>
<button
class="px-3 py-2 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 text-xs font-bold disabled:opacity-50"
class="px-3 py-2 rounded-lg border border-red-200 text-red-300 hover:bg-red-500/10 text-xs font-bold disabled:opacity-50"
:disabled="savingId === item.id"
@click="remove(item)"
>
@@ -83,11 +83,11 @@
<Teleport to="body">
<div v-if="showModal" class="fixed inset-0 z-[80] bg-black/60 backdrop-blur-sm p-4 flex items-end sm:items-center justify-center">
<div class="w-full max-w-2xl bg-zinc-950 border border-zinc-800 rounded-2xl overflow-hidden">
<div class="px-5 py-4 border-b border-zinc-800 flex items-center justify-between">
<p class="text-sm font-black text-zinc-100">{{ editingId ? '编辑更新日志' : '新建更新日志' }}</p>
<div class="w-full max-w-2xl bg-white border border-zinc-200 rounded-2xl overflow-hidden">
<div class="px-5 py-4 border-b border-zinc-200 flex items-center justify-between">
<p class="text-sm font-black text-zinc-900">{{ editingId ? '编辑更新日志' : '新建更新日志' }}</p>
<button
class="h-9 w-9 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900 transition-colors"
class="h-9 w-9 rounded-lg border border-zinc-200 text-zinc-400 hover:text-zinc-700 hover:bg-white transition-colors"
@click="closeModal"
>
<X :size="16" class="mx-auto" />
@@ -98,11 +98,11 @@
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
<div>
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">版本号</label>
<input v-model="form.version" type="text" class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all" placeholder="如:2.4.1" >
<input v-model="form.version" type="text" class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-2.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all" placeholder="如:2.4.1" >
</div>
<div>
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">标题</label>
<input v-model="form.title" type="text" class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-2.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all" placeholder="如:投稿体验优化" >
<input v-model="form.title" type="text" class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-2.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all" placeholder="如:投稿体验优化" >
</div>
</div>
@@ -111,23 +111,23 @@
<textarea
v-model="form.content"
:rows="10"
class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all font-mono"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all font-mono"
placeholder="支持纯文本(建议用分点)。"
/>
</div>
<div class="flex items-center justify-between gap-4 p-3 rounded-xl border border-zinc-800 bg-zinc-900/40">
<div class="flex items-center justify-between gap-4 p-3 rounded-xl border border-zinc-200 bg-zinc-50/40">
<div>
<p class="text-xs font-bold text-zinc-200">立即发布</p>
<p class="text-xs font-bold text-zinc-700">立即发布</p>
<p class="text-[10px] text-zinc-500 mt-0.5">发布后前台更新日志页面可见</p>
</div>
<input v-model="form.published" type="checkbox" class="w-5 h-5 rounded border-zinc-800 bg-zinc-900 accent-blue-600 cursor-pointer" >
<input v-model="form.published" type="checkbox" class="w-5 h-5 rounded border-zinc-200 bg-white accent-blue-600 cursor-pointer" >
</div>
</div>
<div class="px-5 py-4 border-t border-zinc-800 flex items-center justify-end gap-3">
<div class="px-5 py-4 border-t border-zinc-200 flex items-center justify-end gap-3">
<button
class="px-4 py-2 rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-900 text-sm font-bold"
class="px-4 py-2 rounded-lg border border-zinc-200 text-zinc-600 hover:bg-white text-sm font-bold"
@click="closeModal"
>
取消
+47 -47
View File
@@ -6,7 +6,7 @@
class="flex flex-col items-center justify-center min-h-[60vh] space-y-6"
>
<div class="relative w-24 h-24">
<div class="absolute inset-0 border-4 border-blue-500/20 rounded-full" />
<div class="absolute inset-0 border-4 border-blue-200 rounded-full" />
<div class="absolute inset-0 border-4 border-t-blue-500 rounded-full animate-spin" />
<div class="absolute inset-0 flex items-center justify-center">
<Activity class="text-blue-500 animate-pulse" :size="32" />
@@ -32,7 +32,7 @@
<h3 class="text-xl font-black text-white tracking-tight">数据加载失败</h3>
<p class="text-sm text-zinc-500 mt-2 max-w-md">{{ error }}</p>
<button
class="mt-6 px-8 py-3 bg-zinc-900 border border-zinc-800 rounded-full text-sm font-black text-white hover:bg-zinc-800 transition-all flex items-center gap-2 mx-auto"
class="mt-6 px-8 py-3 bg-white border border-zinc-200 rounded-full text-sm font-black text-white hover:bg-zinc-100 transition-all flex items-center gap-2 mx-auto"
@click="refreshAllData"
>
<RefreshCw :size="16" />
@@ -52,16 +52,16 @@
<div class="flex items-center gap-3">
<button
:disabled="isLoading"
class="p-2.5 bg-zinc-900/50 border border-zinc-800 rounded-full text-zinc-400 hover:text-white transition-all group disabled:opacity-50"
class="p-2.5 bg-zinc-50/50 border border-zinc-200 rounded-full text-zinc-400 hover:text-zinc-900 transition-all group disabled:opacity-50"
@click="refreshAllData"
>
<RefreshCw :size="18" :class="{ 'animate-spin': isLoading }" />
</button>
<div
class="px-3 py-1.5 bg-blue-500/10 border border-blue-500/20 rounded-full flex items-center gap-2"
class="px-3 py-1.5 bg-blue-500/10 border border-blue-200 rounded-full flex items-center gap-2"
>
<div class="w-2 h-2 bg-blue-500 rounded-full animate-pulse" />
<span class="text-[10px] font-black text-blue-400 uppercase tracking-widest"
<span class="text-[10px] font-black text-blue-700 uppercase tracking-widest"
>实时模式</span
>
</div>
@@ -82,11 +82,11 @@
<div
v-for="(stat, i) in kpiStats"
:key="i"
class="group relative p-6 bg-zinc-900/40 border border-zinc-800/60 rounded-[2rem] overflow-hidden hover:border-zinc-700 transition-all hover:shadow-2xl hover:shadow-black/40"
class="group relative p-6 bg-zinc-50/40 border border-zinc-200/60 rounded-[2rem] overflow-hidden hover:border-zinc-200 transition-all hover:shadow-2xl hover:shadow-black/40"
>
<div class="flex justify-between items-start">
<div
:class="`p-3 rounded-2xl bg-zinc-950 border border-zinc-800 text-zinc-400 group-hover:text-${stat.color}-400 group-hover:border-${stat.color}-500/30 transition-all`"
:class="`p-3 rounded-2xl bg-white border border-zinc-200 text-zinc-400 group-hover:text-${stat.color}-400 group-hover:border-${stat.color}-500/30 transition-all`"
>
<component :is="stat.icon" :size="20" />
</div>
@@ -103,7 +103,7 @@
</div>
</div>
<div class="mt-4">
<h4 class="text-3xl font-black text-zinc-100 tracking-tighter">
<h4 class="text-3xl font-black text-zinc-900 tracking-tighter">
{{ formatNumber(stat.value) }}
</h4>
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] mt-1">
@@ -122,7 +122,7 @@
<!-- 实时脉冲部分 -->
<div class="lg:col-span-12 grid grid-cols-1 md:grid-cols-2 gap-6">
<div
class="p-8 bg-zinc-900/20 border border-zinc-800/40 rounded-3xl flex items-center justify-between relative overflow-hidden group cursor-help"
class="p-8 bg-zinc-50/20 border border-zinc-200 rounded-3xl flex items-center justify-between relative overflow-hidden group cursor-help"
@mouseenter="handleMouseEnter"
@mouseleave="handleMouseLeave"
>
@@ -142,7 +142,7 @@
</div>
</div>
<div
class="p-8 bg-zinc-900/20 border border-zinc-800/40 rounded-3xl flex items-center justify-between relative overflow-hidden group"
class="p-8 bg-zinc-50/20 border border-zinc-200 rounded-3xl flex items-center justify-between relative overflow-hidden group"
>
<div class="relative z-10">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
@@ -166,7 +166,7 @@
<!-- 趋势分析图表卡片 -->
<div
class="lg:col-span-8 bg-zinc-900/40 border border-zinc-800 rounded-[3rem] p-8 shadow-2xl overflow-hidden flex flex-col min-h-[500px]"
class="lg:col-span-8 bg-zinc-50/40 border border-zinc-200 rounded-[3rem] p-8 shadow-2xl overflow-hidden flex flex-col min-h-[500px]"
>
<div class="flex items-center justify-between mb-10">
<div>
@@ -196,7 +196,7 @@
class="flex-1 flex flex-col items-center justify-center space-y-4"
>
<div
class="w-12 h-12 border-4 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
class="w-12 h-12 border-4 border-blue-200 border-t-blue-500 rounded-full animate-spin"
/>
<p class="text-xs font-black text-zinc-600 uppercase tracking-widest">
正在加载趋势数据...
@@ -213,7 +213,7 @@
{{ panelStates.trends.error }}
</p>
<button
class="px-6 py-2 bg-zinc-800 hover:bg-zinc-700 text-white text-[10px] font-black uppercase tracking-widest rounded-full transition-all"
class="px-6 py-2 bg-zinc-100 hover:bg-zinc-200 text-white text-[10px] font-black uppercase tracking-widest rounded-full transition-all"
@click="loadTrends"
>
立即重试
@@ -233,10 +233,10 @@
:style="{
height: `${(item.count / Math.max(...trendData.map((d) => d.count), 1)) * 100}%`
}"
class="w-full bg-gradient-to-t from-blue-600/10 to-blue-500/40 rounded-t-xl group-hover:from-blue-600/30 group-hover:to-blue-400 transition-all border-x border-t border-blue-500/20 group-hover:border-blue-500/40 min-h-[4px]"
class="w-full bg-gradient-to-t from-blue-600/10 to-blue-500/40 rounded-t-xl group-hover:from-blue-600/30 group-hover:to-blue-400 transition-all border-x border-t border-blue-200 group-hover:border-blue-500/40 min-h-[4px]"
/>
<div
class="absolute left-1/2 -translate-x-1/2 -translate-y-full mb-2 text-[10px] font-black text-blue-400 opacity-0 group-hover:opacity-100 transition-all whitespace-nowrap pointer-events-none z-10"
class="absolute left-1/2 -translate-x-1/2 -translate-y-full mb-2 text-[10px] font-black text-blue-700 opacity-0 group-hover:opacity-100 transition-all whitespace-nowrap pointer-events-none z-10"
:style="{
bottom: `${(item.count / Math.max(...trendData.map((d) => d.count), 1)) * 100}%`
}"
@@ -259,7 +259,7 @@
<!-- 热门歌曲排行榜 -->
<div
class="lg:col-span-4 bg-zinc-900/40 border border-zinc-800 rounded-[3rem] p-8 flex flex-col min-h-[500px]"
class="lg:col-span-4 bg-zinc-50/40 border border-zinc-200 rounded-[3rem] p-8 flex flex-col min-h-[500px]"
>
<div class="flex items-center justify-between mb-8">
<h3 class="text-xl font-bold flex items-center gap-3 text-white">
@@ -292,7 +292,7 @@
v-if="panelStates.topSongs.loading && topSongs.length === 0"
class="flex-1 space-y-4"
>
<div v-for="i in 5" :key="i" class="h-20 bg-zinc-800/20 animate-pulse rounded-2xl" />
<div v-for="i in 5" :key="i" class="h-20 bg-zinc-100/20 animate-pulse rounded-2xl" />
</div>
<div
v-else-if="panelStates.topSongs.error && topSongs.length === 0"
@@ -303,7 +303,7 @@
{{ panelStates.topSongs.error }}
</p>
<button
class="px-4 py-2 bg-zinc-800 text-[10px] font-black uppercase rounded-full hover:bg-zinc-700 text-white transition-colors"
class="px-4 py-2 bg-zinc-100 text-[10px] font-black uppercase rounded-full hover:bg-zinc-200 text-white transition-colors"
@click="loadTopSongs"
>
重试
@@ -316,7 +316,7 @@
<div
v-for="(song, i) in topSongs.slice(0, 5)"
:key="i"
class="p-4 bg-zinc-950/50 border border-zinc-800/40 rounded-2xl flex items-center gap-4 group hover:bg-zinc-800/30 transition-all"
class="p-4 bg-white/70 border border-zinc-200 rounded-2xl flex items-center gap-4 group hover:bg-zinc-100/30 transition-all"
>
<div
:class="`w-8 h-8 rounded-lg flex items-center justify-center font-black text-xs ${
@@ -326,14 +326,14 @@
? 'bg-zinc-300 text-black'
: i === 2
? 'bg-amber-800 text-white'
: 'text-zinc-600 border border-zinc-800'
: 'text-zinc-600 border border-zinc-200'
}`"
>
{{ i + 1 }}
</div>
<div class="flex-1 min-w-0">
<h4
class="text-sm font-bold text-zinc-200 truncate group-hover:text-white transition-colors"
class="text-sm font-bold text-zinc-700 truncate group-hover:text-zinc-900 transition-colors"
>
{{ song.title }}
</h4>
@@ -360,7 +360,7 @@
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
<!-- 活跃用户排行榜 -->
<div
class="bg-zinc-900/40 border border-zinc-800 rounded-[3rem] p-8 flex flex-col min-h-[500px]"
class="bg-zinc-50/40 border border-zinc-200 rounded-[3rem] p-8 flex flex-col min-h-[500px]"
>
<div class="flex items-center justify-between mb-8">
<h3 class="text-xl font-bold flex items-center gap-3 text-white">
@@ -376,7 +376,7 @@
v-if="panelStates.activeUsers.loading && activeUsers.length === 0"
class="flex-1 space-y-4"
>
<div v-for="i in 4" :key="i" class="h-24 bg-zinc-800/20 animate-pulse rounded-3xl" />
<div v-for="i in 4" :key="i" class="h-24 bg-zinc-100/20 animate-pulse rounded-3xl" />
</div>
<div
v-else-if="panelStates.activeUsers.error && activeUsers.length === 0"
@@ -387,7 +387,7 @@
{{ panelStates.activeUsers.error }}
</p>
<button
class="px-4 py-2 bg-zinc-800 text-[10px] font-black uppercase rounded-full hover:bg-zinc-700 text-white transition-colors"
class="px-4 py-2 bg-zinc-100 text-[10px] font-black uppercase rounded-full hover:bg-zinc-200 text-white transition-colors"
@click="loadActiveUsers"
>
重试
@@ -401,31 +401,31 @@
<div
v-for="(user, i) in activeUsers.slice(0, 5)"
:key="i"
class="relative p-5 bg-zinc-950/30 border border-zinc-800/50 rounded-3xl overflow-hidden group"
class="relative p-5 bg-white/60 border border-zinc-200 rounded-3xl overflow-hidden group"
>
<div class="flex items-center gap-4 relative z-10">
<img
v-if="user.avatarUrl && !failedAvatars[user.id]"
:src="user.avatarUrl"
:alt="user.name"
class="w-12 h-12 rounded-2xl object-cover border border-zinc-700/50 shrink-0"
class="w-12 h-12 rounded-2xl object-cover border border-zinc-200 shrink-0"
@error="handleAvatarError($event, user)"
>
<div
v-else
class="w-12 h-12 rounded-2xl bg-zinc-800 flex items-center justify-center font-black text-zinc-500 group-hover:text-zinc-200 transition-colors shrink-0"
class="w-12 h-12 rounded-2xl bg-zinc-100 flex items-center justify-center font-black text-zinc-500 group-hover:text-zinc-700 transition-colors shrink-0"
>
{{ user.name.charAt(0) }}
</div>
<div class="flex-1 min-w-0">
<h4 class="text-sm font-bold text-zinc-100 truncate">{{ user.name }}</h4>
<h4 class="text-sm font-bold text-zinc-900 truncate">{{ user.name }}</h4>
<p class="text-xs text-zinc-600 font-medium mt-1 truncate">
{{ user.contributions }}首投稿 · {{ user.likes }}次点赞
</p>
</div>
<div class="text-right shrink-0">
<span
class="text-xl font-black text-zinc-300 group-hover:text-purple-400 transition-colors"
class="text-xl font-black text-zinc-600 group-hover:text-purple-400 transition-colors"
>{{ user.activityScore }}</span
>
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
@@ -451,7 +451,7 @@
<!-- 学期对比分析 -->
<div
class="bg-zinc-900/40 border border-zinc-800 rounded-[3rem] p-8 flex flex-col min-h-[500px]"
class="bg-zinc-50/40 border border-zinc-200 rounded-[3rem] p-8 flex flex-col min-h-[500px]"
>
<div class="flex items-center justify-between mb-8">
<h3 class="text-xl font-bold flex items-center gap-3 text-white">
@@ -470,7 +470,7 @@
v-if="panelStates.semesterComparison.loading && semesterComparison.length === 0"
class="flex-1 space-y-4"
>
<div v-for="i in 3" :key="i" class="h-32 bg-zinc-800/20 animate-pulse rounded-[2rem]" />
<div v-for="i in 3" :key="i" class="h-32 bg-zinc-100/20 animate-pulse rounded-[2rem]" />
</div>
<div
v-else-if="panelStates.semesterComparison.error && semesterComparison.length === 0"
@@ -481,7 +481,7 @@
{{ panelStates.semesterComparison.error }}
</p>
<button
class="px-4 py-2 bg-zinc-800 text-[10px] font-black uppercase rounded-full hover:bg-zinc-700 text-white transition-colors"
class="px-4 py-2 bg-zinc-100 text-[10px] font-black uppercase rounded-full hover:bg-zinc-200 text-white transition-colors"
@click="loadSemesterComparison"
>
重试
@@ -494,10 +494,10 @@
<div
v-for="(sem, i) in semesterComparison"
:key="i"
:class="`p-6 border rounded-[2rem] transition-all ${sem.isActive ? 'bg-emerald-500/5 border-emerald-500/20 shadow-lg shadow-emerald-500/5' : 'bg-zinc-950/20 border-zinc-800/60 opacity-60 hover:opacity-100'}`"
:class="`p-6 border rounded-[2rem] transition-all ${sem.isActive ? 'bg-emerald-500/5 border-emerald-500/20 shadow-lg shadow-emerald-900/5' : 'bg-white/50 border-zinc-200/60 opacity-60 hover:opacity-100'}`"
>
<div class="flex items-center justify-between mb-4">
<span class="text-xs font-black text-zinc-300 uppercase tracking-widest">{{
<span class="text-xs font-black text-zinc-600 uppercase tracking-widest">{{
sem.semester
}}</span>
<span
@@ -509,19 +509,19 @@
</div>
<div class="grid grid-cols-3 gap-4">
<div>
<h5 class="text-lg font-black text-zinc-100">{{ sem.totalSongs }}</h5>
<h5 class="text-lg font-black text-zinc-900">{{ sem.totalSongs }}</h5>
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-tighter mt-1">
歌曲总量
</p>
</div>
<div>
<h5 class="text-lg font-black text-zinc-100">{{ sem.totalSchedules }}</h5>
<h5 class="text-lg font-black text-zinc-900">{{ sem.totalSchedules }}</h5>
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-tighter mt-1">
排期总数
</p>
</div>
<div>
<h5 class="text-lg font-black text-zinc-100">{{ sem.totalRequests }}</h5>
<h5 class="text-lg font-black text-zinc-900">{{ sem.totalRequests }}</h5>
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-tighter mt-1">
获赞总数
</p>
@@ -547,16 +547,16 @@
@mouseleave="handleTooltipMouseLeave"
>
<div
class="bg-zinc-900 border border-zinc-800 rounded-3xl shadow-2xl overflow-hidden min-w-[320px] max-w-[380px] backdrop-blur-xl bg-opacity-90 animate-in fade-in zoom-in duration-200"
class="bg-white border border-zinc-200 rounded-3xl shadow-2xl overflow-hidden min-w-[320px] max-w-[380px] backdrop-blur-xl bg-opacity-90 animate-in fade-in zoom-in duration-200"
>
<div
class="p-6 border-b border-zinc-800/50 bg-gradient-to-br from-blue-500/10 to-transparent max-h-[500px] overflow-y-auto custom-scrollbar"
class="p-6 border-b border-zinc-200 bg-gradient-to-br from-blue-500/10 to-transparent max-h-[500px] overflow-y-auto custom-scrollbar"
>
<div class="flex items-center justify-between mb-4">
<h4 class="text-sm font-black text-white uppercase tracking-widest truncate max-h-[20px] overflow-hidden">活跃用户详情</h4>
<div class="flex items-center gap-2 px-2 py-1 bg-blue-500/20 rounded-full shrink-0">
<div class="w-1.5 h-1.5 bg-blue-500 rounded-full animate-pulse" />
<span class="text-[10px] font-black text-blue-400"
<span class="text-[10px] font-black text-blue-700"
>{{ realtimeStats.activeUsers }} 在线</span
>
</div>
@@ -569,28 +569,28 @@
<div
v-for="user in realtimeStats.activeUsersList.slice(0, 5)"
:key="user.id"
class="flex items-center gap-3 p-3 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl group hover:border-blue-500/30 transition-all"
class="flex items-center gap-3 p-3 bg-white/70 border border-zinc-200 rounded-2xl group hover:border-blue-200 transition-all"
>
<img
v-if="user.avatarUrl && !failedAvatars[user.id]"
:src="user.avatarUrl"
:alt="user.name"
class="w-10 h-10 rounded-xl object-cover border border-zinc-700/50 shrink-0"
class="w-10 h-10 rounded-xl object-cover border border-zinc-200 shrink-0"
@error="handleAvatarError($event, user)"
>
<div
v-else
class="w-10 h-10 rounded-xl bg-zinc-800 flex items-center justify-center font-black text-zinc-500 group-hover:text-blue-400 transition-colors shrink-0"
class="w-10 h-10 rounded-xl bg-zinc-100 flex items-center justify-center font-black text-zinc-500 group-hover:text-blue-700 transition-colors shrink-0"
>
{{ user.name.charAt(0) }}
</div>
<div class="flex-1 min-w-0">
<div class="text-xs font-bold text-zinc-200 truncate max-h-[16px] overflow-hidden">{{ user.name }}</div>
<div class="text-xs font-bold text-zinc-700 truncate max-h-[16px] overflow-hidden">{{ user.name }}</div>
<div class="text-[10px] text-zinc-500 font-medium truncate max-h-[14px] overflow-hidden">
@{{ user.username }}
</div>
</div>
<div class="text-[10px] font-black text-zinc-600 bg-zinc-900 px-2 py-1 rounded-lg shrink-0">
<div class="text-[10px] font-black text-zinc-600 bg-white px-2 py-1 rounded-lg shrink-0">
{{ user.lastActive }}
</div>
</div>
@@ -605,7 +605,7 @@
<p class="text-xs font-black uppercase tracking-widest truncate max-h-[16px] overflow-hidden">暂无在线用户</p>
</div>
</div>
<div class="px-6 py-4 bg-zinc-950/50 flex items-center justify-between">
<div class="px-6 py-4 bg-white/70 flex items-center justify-between">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest truncate max-h-[14px] overflow-hidden"
>实时数据同步中</span
>
@@ -1147,7 +1147,7 @@ const formatDateShort = (dateStr) => {
width: 6px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: rgba(255, 255, 255, 0.05);
background: rgba(0, 0, 0, 0.04);
border-radius: 3px;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
+49 -49
View File
@@ -2,7 +2,7 @@
<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>
<h2 class="text-2xl font-black text-zinc-900 tracking-tight">数据库操作</h2>
<p class="text-xs text-zinc-500 font-medium">
执行系统底层维护任务,包括备份、恢复及全局数据重置
</p>
@@ -14,7 +14,7 @@
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',
'group relative bg-zinc-50/40 border border-zinc-200 rounded-2xl p-8 transition-all hover:border-zinc-200 hover:shadow-2xl hover:shadow-black/40',
card.isDanger ? 'hover:border-rose-500/20' : ''
]"
>
@@ -22,7 +22,7 @@
<div class="flex items-center justify-between">
<div
:class="[
'p-3.5 rounded-2xl bg-zinc-950 border border-zinc-800 transition-all',
'p-3.5 rounded-2xl bg-white border border-zinc-200 transition-all',
card.isDanger
? 'text-rose-500 border-rose-500/10'
: `text-${card.color}-500 border-${card.color}-500/10 shadow-lg`
@@ -38,7 +38,7 @@
</div>
<div class="flex-1 space-y-2">
<h3 class="text-lg font-bold text-zinc-100 group-hover:text-blue-400 transition-colors">
<h3 class="text-lg font-bold text-zinc-900 group-hover:text-blue-700 transition-colors">
{{ card.title }}
</h3>
<p class="text-xs text-zinc-500 leading-relaxed font-medium">
@@ -48,10 +48,10 @@
<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="w-full py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all active:scale-95 bg-white border border-zinc-200 text-zinc-400 hover:bg-zinc-100 hover:text-zinc-900 hover:border-zinc-200"
: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'
? 'bg-white border border-rose-200 text-rose-600 hover:bg-rose-600 hover:text-zinc-900 hover:border-rose-600 shadow-lg shadow-rose-900/5'
: ''
"
@click="openModal(card.id)"
@@ -75,7 +75,7 @@
<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-[11px] font-bold text-zinc-600">数据库维护建议</p>
<p class="text-[10px] text-zinc-500 leading-relaxed">
建议每周进行一次全量备份。在执行“恢复备份”或“重置数据库”前,请务必先创建一份当前的数据备份,以免误操作造成不可挽回的损失。
</p>
@@ -89,12 +89,12 @@
>
<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"
class="relative bg-white border border-zinc-200 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>
<div class="px-8 py-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-900 tracking-tight">创建数据库备份</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
class="p-2 hover:bg-zinc-100 rounded-xl transition-colors text-zinc-500 hover:text-zinc-700"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
@@ -109,18 +109,18 @@
<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"
class="flex items-start gap-4 p-4 bg-white/70 border border-zinc-200 rounded-xl cursor-pointer hover:border-zinc-200 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"
class="w-4 h-4 rounded border-zinc-200 bg-white accent-blue-600"
>
</div>
<div>
<p
class="text-xs font-bold text-zinc-200 group-hover:text-blue-400 transition-colors"
class="text-xs font-bold text-zinc-700 group-hover:text-blue-700 transition-colors"
>
{{ item.label }}
</p>
@@ -135,9 +135,9 @@
</p>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<div class="px-8 py-6 bg-white/70 border-t border-zinc-200 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"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-600 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
@@ -160,12 +160,12 @@
>
<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"
class="relative bg-white border border-zinc-200 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>
<div class="px-8 py-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-900 tracking-tight">恢复数据库备份</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
class="p-2 hover:bg-zinc-100 rounded-xl transition-colors text-zinc-500 hover:text-zinc-700"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
@@ -173,7 +173,7 @@
</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"
class="border-2 border-dashed border-zinc-200 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"
@@ -181,11 +181,11 @@
<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">
<h5 class="text-sm font-bold text-zinc-600">
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
</h5>
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
仅支持 VoiceHub 导出的 .json 格式
仅支持宝一外校园点歌系统导出的 .json 格式
</p>
<input
ref="fileInput"
@@ -205,15 +205,15 @@
: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'
? 'bg-white border-emerald-200'
: 'bg-white border-zinc-200 hover:border-zinc-200'
]"
@click="restoreForm.mode = 'merge'"
>
<h6
:class="[
'text-xs font-bold',
restoreForm.mode === 'merge' ? 'text-emerald-400' : 'text-zinc-500'
restoreForm.mode === 'merge' ? 'text-emerald-700' : 'text-zinc-500'
]"
>
增量模式
@@ -224,15 +224,15 @@
: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'
? 'bg-white border-emerald-200'
: 'bg-white border-zinc-200 hover:border-zinc-200'
]"
@click="restoreForm.mode = 'replace'"
>
<h6
:class="[
'text-xs font-bold',
restoreForm.mode === 'replace' ? 'text-emerald-400' : 'text-zinc-500'
restoreForm.mode === 'replace' ? 'text-emerald-700' : 'text-zinc-500'
]"
>
覆盖模式
@@ -244,7 +244,7 @@
<div
v-if="restoreForm.mode === 'replace' && hasSuperAdminInBackup"
class="p-4 bg-zinc-950 border border-zinc-800 rounded-xl"
class="p-4 bg-white border border-zinc-200 rounded-xl"
>
<label class="flex items-start gap-3 cursor-pointer">
<input
@@ -253,7 +253,7 @@
class="mt-0.5 accent-emerald-500"
>
<div>
<p class="text-xs font-bold text-zinc-200">覆盖备份中的超级管理员账号数据</p>
<p class="text-xs font-bold text-zinc-700">覆盖备份中的超级管理员账号数据</p>
<p class="text-[10px] text-zinc-500 mt-1">
关闭时将保留当前超级管理员账号及其第三方绑定、2FA等关联数据
</p>
@@ -270,9 +270,9 @@
</p>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<div class="px-8 py-6 bg-white/70 border-t border-zinc-200 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"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-600 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
@@ -295,12 +295,12 @@
>
<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"
class="relative bg-white border border-zinc-200 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>
<div class="px-8 py-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-900 tracking-tight">重置数据表序列</h3>
<button
class="p-2 hover:bg-zinc-800 rounded-xl transition-colors text-zinc-500 hover:text-zinc-200"
class="p-2 hover:bg-zinc-100 rounded-xl transition-colors text-zinc-500 hover:text-zinc-700"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
@@ -318,14 +318,14 @@
/>
</div>
<div class="p-6 bg-zinc-950/50 border border-zinc-800 rounded-2xl space-y-4">
<div class="p-6 bg-white/70 border border-zinc-200 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 class="text-xs font-bold text-zinc-600 uppercase tracking-widest">
什么是重置序列?
</h6>
</div>
@@ -336,9 +336,9 @@
</p>
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex gap-3 justify-end">
<div class="px-8 py-6 bg-white/70 border-t border-zinc-200 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"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-600 transition-colors uppercase tracking-widest"
@click="activeModal = 'none'"
>
取消
@@ -361,12 +361,12 @@
>
<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"
class="relative bg-white border border-zinc-200 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">
<div class="px-8 py-6 border-b border-zinc-200 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"
class="p-2 hover:bg-zinc-100 rounded-xl transition-colors text-zinc-500 hover:text-zinc-700"
@click="activeModal = 'none'"
>
<X class="w-5 h-5" />
@@ -382,7 +382,7 @@
</h4>
<p class="text-xs text-zinc-500 mt-2 font-medium leading-relaxed">
重置操作将永久删除系统中的所有
<span class="text-zinc-300 font-bold"
<span class="text-zinc-600 font-bold"
>歌曲、投稿记录、排期文件、通知、日志及除您以外的用户账号</span
>。
</p>
@@ -395,7 +395,7 @@
请输入以下代码以确认操作
</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"
class="bg-white border border-rose-200 rounded-xl px-4 py-3 font-mono text-[10px] text-rose-700 text-center select-all"
>
{{ CONFIRM_CODE }}
</div>
@@ -403,20 +403,20 @@
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"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-rose-200 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"
class="py-3 bg-white hover:bg-zinc-100 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="py-3 rounded-xl text-xs font-black uppercase tracking-widest transition-all shadow-lg disabled:bg-zinc-100 disabled:text-zinc-600 disabled:cursor-not-allowed disabled:border-zinc-200"
:class="
resetConfirmText === CONFIRM_CODE
? 'bg-rose-600 hover:bg-rose-500 text-white shadow-rose-900/20 active:scale-95'
+26 -26
View File
@@ -1,22 +1,22 @@
<template>
<div class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] overflow-hidden">
<div class="bg-zinc-50/30 border border-zinc-200 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 justify-between p-6 border-b border-zinc-200 bg-zinc-50/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"
class="w-10 h-10 rounded-2xl bg-blue-50 flex items-center justify-center border border-blue-200"
>
<Mail :size="20" class="text-blue-500" />
</div>
<div>
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">邮件模板管理</h3>
<h3 class="text-sm font-black text-zinc-900 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"
class="flex items-center gap-2 px-4 py-2 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-400 text-[11px] font-bold rounded-xl transition-all"
:disabled="saving"
@click="doPreview"
>
@@ -35,14 +35,14 @@
<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="lg:col-span-3 border-r border-zinc-200 bg-zinc-50/10 flex flex-col">
<div class="p-4 border-b border-zinc-200">
<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"
class="w-full bg-white border border-zinc-200 rounded-lg pl-9 pr-3 py-2 text-[10px] text-zinc-400 focus:outline-none focus:border-blue-200"
>
</div>
</div>
@@ -53,8 +53,8 @@
class="w-full group flex flex-col gap-1 p-3 rounded-2xl transition-all text-left border"
:class="[
selectedKey === t.key
? 'bg-blue-600/10 border-blue-500/20'
: 'bg-transparent border-transparent hover:bg-zinc-800/40'
? 'bg-blue-50 border-blue-200'
: 'bg-transparent border-transparent hover:bg-zinc-100/40'
]"
@click="select(t)"
>
@@ -63,8 +63,8 @@
class="text-[11px] font-black tracking-tight transition-colors"
:class="
selectedKey === t.key
? 'text-blue-400'
: 'text-zinc-300 group-hover:text-zinc-100'
? 'text-blue-700'
: 'text-zinc-600 group-hover:text-zinc-900'
"
>
{{ t.name }}
@@ -72,7 +72,7 @@
<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"
class="px-1.5 py-0.5 rounded-md bg-zinc-100 text-[8px] font-black text-zinc-500 uppercase tracking-tighter"
>
内置
</span>
@@ -90,7 +90,7 @@
</div>
<!-- 右侧:编辑器区域 -->
<div class="lg:col-span-9 flex flex-col bg-zinc-950/20 overflow-hidden">
<div class="lg:col-span-9 flex flex-col bg-white/50 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">
<!-- 基础信息与主题 -->
@@ -102,7 +102,7 @@
<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"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-3 text-xs text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
<div class="space-y-2">
@@ -116,7 +116,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-3 text-xs text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
</div>
@@ -129,7 +129,7 @@
<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 class="text-[10px] font-black text-blue-700 uppercase tracking-widest">
可用变量
</h4>
<p class="text-[11px] text-zinc-500 leading-relaxed font-mono">
@@ -151,13 +151,13 @@
>HTML 内容</label
>
<span
class="px-2 py-0.5 rounded-md bg-zinc-900 text-[8px] font-black text-zinc-500 uppercase"
class="px-2 py-0.5 rounded-md bg-white 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"
class="flex items-center gap-1.5 text-[10px] font-bold text-rose-500 hover:text-rose-700 transition-colors"
@click="restore"
>
<RotateCcw :size="12" /> 恢复默认配置
@@ -165,14 +165,14 @@
</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"
class="absolute right-4 top-4 p-2 rounded-lg bg-zinc-50/50 border border-zinc-200 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"
class="w-full bg-white border border-zinc-200 rounded-2xl p-6 text-[11px] text-zinc-600 font-mono leading-relaxed focus:outline-none focus:border-blue-200 custom-scrollbar"
placeholder="<html>..."
/>
</div>
@@ -184,7 +184,7 @@
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 v-if="previewHtml" class="space-y-4 pt-4 border-t border-zinc-200">
<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"
@@ -193,13 +193,13 @@
<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"
class="text-[10px] font-bold text-zinc-500 hover:text-zinc-600"
@click="previewHtml = ''"
>
隐藏预览
</button>
</div>
<div class="rounded-2xl overflow-hidden border border-zinc-800 bg-white shadow-2xl">
<div class="rounded-2xl overflow-hidden border border-zinc-200 bg-white shadow-2xl">
<iframe :srcdoc="previewHtml" class="w-full h-[500px] border-none" />
</div>
</div>
@@ -213,7 +213,7 @@
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"
class="w-16 h-16 rounded-3xl bg-zinc-50/50 flex items-center justify-center border border-zinc-200"
>
<Mail :size="32" class="text-zinc-800" />
</div>
@@ -336,7 +336,7 @@ onMounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+35 -35
View File
@@ -1,8 +1,8 @@
<template>
<div class="space-y-6">
<div class="flex justify-between items-center">
<h3 class="text-lg font-bold text-zinc-100 flex items-center gap-2">
<CalendarDays class="w-5 h-5 text-blue-400" />
<h3 class="text-lg font-bold text-zinc-900 flex items-center gap-2">
<CalendarDays class="w-5 h-5 text-blue-700" />
期望播出时段设置
</h3>
<button
@@ -17,39 +17,39 @@
<Loader2 class="w-6 h-6 animate-spin mx-auto text-zinc-500" />
</div>
<div v-else-if="dates.length === 0" class="text-center py-10 text-zinc-500 bg-zinc-900/50 rounded-xl border border-zinc-800">
<div v-else-if="dates.length === 0" class="text-center py-10 text-zinc-500 bg-zinc-50/50 rounded-xl border border-zinc-200">
暂未设置任何开放日期
</div>
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div v-for="date in dates" :key="date.id" class="bg-zinc-900 border border-zinc-800 rounded-xl p-4 flex flex-col gap-3">
<div v-for="date in dates" :key="date.id" class="bg-white border border-zinc-200 rounded-xl p-4 flex flex-col gap-3">
<div class="flex justify-between items-start">
<div class="font-bold text-zinc-100 flex items-center gap-2">
<div class="font-bold text-zinc-900 flex items-center gap-2">
{{ date.date }}
<span class="text-xs font-normal text-zinc-500 bg-zinc-800 px-2 py-0.5 rounded">{{ date.weekday }}</span>
<span class="text-xs font-normal text-zinc-500 bg-zinc-100 px-2 py-0.5 rounded">{{ date.weekday }}</span>
</div>
<button @click="openDeleteDialog(date)" class="text-red-400 hover:text-red-300 p-1 transition-colors" title="删除">
<Trash2 class="w-4 h-4" />
</button>
</div>
<div class="flex flex-col gap-2.5 bg-zinc-950/50 p-3 rounded-lg border border-zinc-800/50">
<div class="flex flex-col gap-2.5 bg-white/70 p-3 rounded-lg border border-zinc-200">
<div class="flex items-center justify-between">
<div class="text-xs text-zinc-500">开放范围</div>
<div class="text-xs font-mono text-blue-400 font-medium break-all text-right ml-4">{{ formatSequences(date.availableSequences) }}</div>
<div class="text-xs font-mono text-blue-700 font-medium break-all text-right ml-4">{{ formatSequences(date.availableSequences) }}</div>
</div>
<div class="flex items-center justify-between">
<div class="text-xs text-zinc-500">序号状态</div>
<div class="text-xs flex gap-2">
<span class="text-emerald-500 bg-emerald-500/10 px-1.5 py-0.5 rounded border border-emerald-500/20">空闲 {{ getAvailableCount(date) }}</span>
<span class="text-zinc-400 bg-zinc-800 px-1.5 py-0.5 rounded border border-zinc-700">共 {{ getTotalCount(date) }}</span>
<span class="text-zinc-400 bg-zinc-100 px-1.5 py-0.5 rounded border border-zinc-200">共 {{ getTotalCount(date) }}</span>
</div>
</div>
</div>
<!-- 新增详细占用情况列表 -->
<div v-if="date.takenSequences && date.takenSequences.length > 0" class="mt-1 pt-3 border-t border-zinc-800">
<div v-if="date.takenSequences && date.takenSequences.length > 0" class="mt-1 pt-3 border-t border-zinc-200">
<div class="text-xs font-bold text-zinc-500 mb-2 flex items-center justify-between">
<span>已占用明细</span>
<span class="text-red-400">{{ date.takenSequences.length }} 个</span>
@@ -61,12 +61,12 @@
class="flex items-center gap-2 text-[11px]"
>
<span class="w-5 h-5 rounded flex items-center justify-center bg-red-500/10 text-red-500 font-bold border border-red-500/20 shrink-0">{{ seq }}</span>
<span class="text-zinc-300 truncate" :title="date.takenDetails?.[seq]?.title">{{ date.takenDetails?.[seq]?.title || '未知歌曲' }}</span>
<span class="text-zinc-600 truncate" :title="date.takenDetails?.[seq]?.title">{{ date.takenDetails?.[seq]?.title || '未知歌曲' }}</span>
<span class="text-zinc-500 truncate" :title="date.takenDetails?.[seq]?.artist">- {{ date.takenDetails?.[seq]?.artist || '未知歌手' }}</span>
</div>
</div>
</div>
<div v-else class="mt-2 pt-3 border-t border-zinc-800 text-xs text-emerald-500/70 font-bold">
<div v-else class="mt-2 pt-3 border-t border-zinc-200 text-xs text-emerald-500/70 font-bold">
所有序号目前空闲
</div>
</div>
@@ -74,22 +74,22 @@
<!-- 新增/编辑弹窗 -->
<div v-if="showModal" class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-lg p-6">
<div class="bg-white border border-zinc-200 rounded-xl shadow-2xl w-full max-w-lg p-6">
<div class="flex justify-between items-center mb-6">
<h3 class="text-lg font-bold text-zinc-100">新增开放日期</h3>
<h3 class="text-lg font-bold text-zinc-900">新增开放日期</h3>
</div>
<!-- Tabs -->
<div class="flex p-1 bg-zinc-950 rounded-lg mb-6">
<div class="flex p-1 bg-white rounded-lg mb-6">
<button
@click="activeTab = 'auto'"
:class="['flex-1 py-2 text-xs font-bold rounded-md transition-colors', activeTab === 'auto' ? 'bg-blue-600 text-white' : 'text-zinc-500 hover:text-zinc-300']"
:class="['flex-1 py-2 text-xs font-bold rounded-md transition-colors', activeTab === 'auto' ? 'bg-blue-600 text-white' : 'text-zinc-500 hover:text-zinc-600']"
>
批量自动分配
</button>
<button
@click="activeTab = 'manual'; nextTick(() => { setTimeout(() => { setStartSeq(manualFormData.startSeq); setEndSeq(manualFormData.endSeq) }, 100) })"
:class="['flex-1 py-2 text-xs font-bold rounded-md transition-colors', activeTab === 'manual' ? 'bg-blue-600 text-white' : 'text-zinc-500 hover:text-zinc-300']"
:class="['flex-1 py-2 text-xs font-bold rounded-md transition-colors', activeTab === 'manual' ? 'bg-blue-600 text-white' : 'text-zinc-500 hover:text-zinc-600']"
>
手动配置单日
</button>
@@ -104,22 +104,22 @@
</div>
<div>
<label class="block text-xs font-bold text-zinc-500 mb-1">总序号数量</label>
<input type="number" min="1" v-model="autoFormData.totalSequences" class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-zinc-200">
<input type="number" min="1" v-model="autoFormData.totalSequences" class="w-full bg-white border border-zinc-200 rounded-lg px-3 py-2 text-zinc-700">
<div class="flex items-center gap-2 mt-2">
<button @click="autoFormData.totalSequences = 21" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">21</button>
<button @click="autoFormData.totalSequences = 28" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">28</button>
<button @click="autoFormData.totalSequences = 35" class="text-[10px] px-2 py-1 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">35</button>
<button @click="autoFormData.totalSequences = 21" class="text-[10px] px-2 py-1 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 rounded transition-colors">21</button>
<button @click="autoFormData.totalSequences = 28" class="text-[10px] px-2 py-1 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 rounded transition-colors">28</button>
<button @click="autoFormData.totalSequences = 35" class="text-[10px] px-2 py-1 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 rounded transition-colors">35</button>
</div>
</div>
<div class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
<p class="text-xs text-blue-400 font-bold mb-1">自动分配预览:</p>
<div class="p-3 bg-blue-500/10 border border-blue-200 rounded-lg">
<p class="text-xs text-blue-700 font-bold mb-1">自动分配预览:</p>
<p class="text-[10px] text-blue-300/80">
<span v-if="autoFormData.dates.length === 0">请先选择日期</span>
<span v-else>将会把 1 - {{ autoFormData.totalSequences }} 号平均分配到选中的 {{ autoFormData.dates.length }} 天中。每天约 {{ Math.ceil(autoFormData.totalSequences / autoFormData.dates.length) }} 个序号。</span>
</p>
</div>
<div class="flex gap-3 pt-4">
<button @click="showModal = false" class="flex-1 py-2 bg-zinc-800 text-zinc-300 rounded-lg text-sm font-bold">取消</button>
<button @click="showModal = false" class="flex-1 py-2 bg-zinc-100 text-zinc-600 rounded-lg text-sm font-bold">取消</button>
<button @click="saveAuto" :disabled="saving || autoFormData.dates.length === 0" class="flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-50">批量保存</button>
</div>
</div>
@@ -133,24 +133,24 @@
<div>
<label class="block text-xs font-bold text-zinc-500 mb-1">开放的空闲序号</label>
<div class="flex flex-col items-center bg-zinc-950 border border-zinc-800 rounded-xl p-4">
<div class="flex flex-col items-center bg-white border border-zinc-200 rounded-xl p-4">
<!-- 模拟 iOS 滚轮 (这里用简化的网格选择代替复杂的滚轮组件以保证稳定性和易用性) -->
<div class="text-sm font-bold text-zinc-400 mb-3 w-full flex justify-between items-center">
<span>选择开放范围</span>
<span class="text-blue-400">{{ manualFormData.startSeq }} - {{ manualFormData.endSeq }}</span>
<span class="text-blue-700">{{ manualFormData.startSeq }} - {{ manualFormData.endSeq }}</span>
</div>
<div class="flex gap-4 items-center w-full justify-center py-4">
<div class="flex flex-col items-center flex-1">
<span class="text-[10px] text-zinc-500 mb-2">起始序号</span>
<div ref="startScrollRef" class="relative w-full h-32 bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden snap-y snap-mandatory hide-scrollbar" style="scroll-behavior: smooth;" @scroll="handleStartScroll">
<div class="absolute top-1/2 left-0 right-0 h-10 -mt-5 bg-blue-500/10 border-y border-blue-500/30 pointer-events-none z-10"></div>
<div ref="startScrollRef" class="relative w-full h-32 bg-white border border-zinc-200 rounded-lg overflow-hidden snap-y snap-mandatory hide-scrollbar" style="scroll-behavior: smooth;" @scroll="handleStartScroll">
<div class="absolute top-1/2 left-0 right-0 h-10 -mt-5 bg-blue-500/10 border-y border-blue-200 pointer-events-none z-10"></div>
<div class="h-[44px]"></div> <!-- Padding top -->
<div
v-for="n in 100"
:key="'start-'+n"
class="h-10 flex items-center justify-center text-lg font-bold snap-center cursor-pointer transition-colors"
:class="manualFormData.startSeq === n ? 'text-blue-400' : 'text-zinc-500 hover:text-zinc-300'"
:class="manualFormData.startSeq === n ? 'text-blue-700' : 'text-zinc-500 hover:text-zinc-600'"
@click="setStartSeq(n)"
:data-val="n"
>
@@ -164,14 +164,14 @@
<div class="flex flex-col items-center flex-1">
<span class="text-[10px] text-zinc-500 mb-2">结束序号</span>
<div ref="endScrollRef" class="relative w-full h-32 bg-zinc-900 border border-zinc-700 rounded-lg overflow-hidden snap-y snap-mandatory hide-scrollbar" style="scroll-behavior: smooth;" @scroll="handleEndScroll">
<div class="absolute top-1/2 left-0 right-0 h-10 -mt-5 bg-blue-500/10 border-y border-blue-500/30 pointer-events-none z-10"></div>
<div ref="endScrollRef" class="relative w-full h-32 bg-white border border-zinc-200 rounded-lg overflow-hidden snap-y snap-mandatory hide-scrollbar" style="scroll-behavior: smooth;" @scroll="handleEndScroll">
<div class="absolute top-1/2 left-0 right-0 h-10 -mt-5 bg-blue-500/10 border-y border-blue-200 pointer-events-none z-10"></div>
<div class="h-[44px]"></div> <!-- Padding top -->
<div
v-for="n in 100"
:key="'end-'+n"
class="h-10 flex items-center justify-center text-lg font-bold snap-center cursor-pointer transition-colors"
:class="manualFormData.endSeq === n ? 'text-blue-400' : 'text-zinc-500 hover:text-zinc-300'"
:class="manualFormData.endSeq === n ? 'text-blue-700' : 'text-zinc-500 hover:text-zinc-600'"
@click="setEndSeq(n)"
:data-val="n"
>
@@ -185,7 +185,7 @@
<!-- 如果有特定跳过的序号,保留文本框以供进阶编辑 -->
<div class="w-full mt-4">
<div
class="flex justify-between items-center text-[10px] text-zinc-500 cursor-pointer py-2 hover:text-zinc-300 transition-colors"
class="flex justify-between items-center text-[10px] text-zinc-500 cursor-pointer py-2 hover:text-zinc-600 transition-colors"
@click="showAdvancedManual = !showAdvancedManual"
>
<span>进阶:自定义跳过某些序号</span>
@@ -193,7 +193,7 @@
</div>
<div v-if="showAdvancedManual" class="mt-2">
<textarea v-model="manualFormData.sequences" rows="2" placeholder="例如: 1,2,3,5 (跳过了4)" class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-3 py-2 text-xs text-zinc-300 resize-none focus:outline-none focus:border-blue-500"></textarea>
<textarea v-model="manualFormData.sequences" rows="2" placeholder="例如: 1,2,3,5 (跳过了4)" class="w-full bg-white border border-zinc-200 rounded-lg px-3 py-2 text-xs text-zinc-600 resize-none focus:outline-none focus:border-blue-500"></textarea>
<p class="text-[10px] text-zinc-600 mt-1">手动编辑后将以此处文本框内容为准</p>
</div>
</div>
@@ -202,7 +202,7 @@
<p class="text-[10px] text-zinc-600 mt-2">注意:手动配置会覆盖该日期原有的设置</p>
</div>
<div class="flex gap-3 pt-4">
<button @click="showModal = false" class="flex-1 py-2 bg-zinc-800 text-zinc-300 rounded-lg text-sm font-bold">取消</button>
<button @click="showModal = false" class="flex-1 py-2 bg-zinc-100 text-zinc-600 rounded-lg text-sm font-bold">取消</button>
<button @click="saveManual" :disabled="saving || !manualFormData.date" class="flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-50">保存</button>
</div>
</div>
@@ -0,0 +1,876 @@
<template>
<div class="flex flex-col space-y-8 pb-12 lg:pb-20 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="flex flex-col space-y-2">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900">导入酷狗</h2>
<p class="text-sm text-zinc-500">
支持两种方式:自动同步(sidecar 一键建歌单加歌)/ 手动生成照片卡片后扫码导入
</p>
</div>
<!-- 模式切换 Tab -->
<div class="inline-flex self-start rounded-2xl border border-zinc-200 bg-zinc-50 p-1.5 gap-1.5">
<button
:class="[
'h-[40px] px-5 rounded-xl text-[12px] font-bold transition-all inline-flex items-center gap-1.5',
mode === 'auto'
? 'bg-indigo-600 text-white shadow-md shadow-indigo-900/10'
: 'text-zinc-600 hover:text-zinc-900 hover:bg-white',
]"
@click="mode = 'auto'"
>
<Icon name="upload-cloud" :size="14" /> 自动同步(推荐)
</button>
<button
:class="[
'h-[40px] px-5 rounded-xl text-[12px] font-bold transition-all inline-flex items-center gap-1.5',
mode === 'manual-card'
? 'bg-indigo-600 text-white shadow-md shadow-indigo-900/10'
: 'text-zinc-600 hover:text-zinc-900 hover:bg-white',
]"
@click="mode = 'manual-card'"
>
<Icon name="image" :size="14" /> 手动生成照片
</button>
</div>
<!-- ========== 模式一:自动同步 ========== -->
<div v-if="mode === 'auto'" class="grid grid-cols-1 xl:grid-cols-3 gap-6">
<!-- 左:账号绑定 & sidecar 状态 -->
<div class="xl:col-span-1 flex flex-col gap-6">
<div class="bg-white/90 border border-zinc-200 rounded-2xl p-6 shadow-sm">
<div class="flex items-center gap-2 mb-4">
<div class="w-9 h-9 rounded-xl bg-blue-50 flex items-center justify-center text-blue-600">
<Icon name="user" :size="18" />
</div>
<div>
<h3 class="text-base font-bold text-zinc-900">管理员酷狗账号</h3>
<p class="text-[11px] text-zinc-500">扫码绑定后,歌单会同步到此账号的收藏列表</p>
</div>
</div>
<div v-if="status" class="space-y-3 text-[12px]">
<div class="flex items-center justify-between">
<span class="text-zinc-500">Sidecar 地址</span>
<code class="text-[11px] px-2 py-0.5 rounded-lg bg-zinc-100 text-zinc-700 font-mono">{{ status.service.baseUrl }}</code>
</div>
<div class="flex items-center gap-2">
<span class="text-zinc-500">服务状态</span>
<span
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[11px] font-bold',
status.service.serviceOk
? 'bg-emerald-50 text-emerald-700'
: 'bg-amber-50 text-amber-700',
]"
>
<span
:class="[
'w-1.5 h-1.5 rounded-full',
status.service.serviceOk ? 'bg-emerald-500' : 'bg-amber-500',
]"
></span>
{{ status.service.serviceOk ? '在线' : '待启动' }}
</span>
</div>
<p class="text-[11px] leading-relaxed text-zinc-600 whitespace-pre-wrap break-words">
{{ status.service.serviceMessage }}
</p>
<div class="flex items-center justify-between">
<span class="text-zinc-500">绑定状态</span>
<span
:class="[
'inline-flex items-center gap-1 px-2 py-0.5 rounded-lg text-[11px] font-bold',
status.bound.hasToken || status.bound.hasCookie
? 'bg-blue-50 text-blue-700'
: 'bg-zinc-100 text-zinc-500',
]"
>
{{ status.bound.hasToken || status.bound.hasCookie ? '已绑定' : '未绑定' }}
</span>
</div>
<div v-if="status.bound.boundAt" class="text-[11px] text-zinc-500">
绑定时间:{{ formatDateTime(status.bound.boundAt) }}
</div>
</div>
<div v-else class="space-y-2 text-[12px]">
<LoadingState :height="72" />
</div>
<div class="mt-5 space-y-4">
<!-- baseUrl 配置 -->
<div class="rounded-xl border border-zinc-200 bg-zinc-50 p-4 space-y-3">
<div class="flex items-center justify-between">
<label class="text-[11px] font-black uppercase tracking-wider text-zinc-600">
KuGouMusicApi 地址
</label>
<span
v-if="baseUrlSaving"
class="text-[11px] text-zinc-500"
>保存中...</span>
<span
v-else-if="baseUrlSavedTick"
class="text-[11px] text-emerald-600 font-bold"
>已保存</span>
</div>
<input
v-model="baseUrlInput"
type="url"
placeholder="例:http://172.19.0.1:3000 或 https://bywmusic.bzw-hheb.top/kugou,留空则走环境变量 KUGOU_API_BASE"
class="w-full h-[42px] px-3 rounded-xl border border-zinc-200 bg-white text-[12px] text-zinc-900 placeholder:text-zinc-400 focus:outline-none focus:ring-2 focus:ring-indigo-500/40 focus:border-indigo-400"
@keydown.enter="saveBaseUrl"
/>
<div class="flex flex-wrap gap-2">
<button
class="h-[34px] px-3 rounded-lg text-[11px] font-bold bg-white hover:bg-zinc-100 border border-zinc-200 text-zinc-700 transition-colors inline-flex items-center gap-1"
@click="baseUrlInput = 'http://172.19.0.1:3000'"
>
<Icon name="server" :size="13" /> 容器直连宿主 http://172.19.0.1:3000
</button>
<button
class="h-[34px] px-3 rounded-lg text-[11px] font-bold bg-white hover:bg-zinc-100 border border-zinc-200 text-zinc-700 transition-colors inline-flex items-center gap-1"
@click="baseUrlInput = 'https://bywmusic.bzw-hheb.top/kugou'"
>
<Icon name="globe" :size="13" /> 中继 https://bywmusic.bzw-hheb.top/kugou
</button>
<button
class="h-[34px] px-3 rounded-lg text-[11px] font-bold bg-zinc-900 hover:bg-zinc-800 text-white transition-colors inline-flex items-center gap-1"
@click="saveBaseUrl"
:disabled="baseUrlSaving"
>
<Icon name="save" :size="13" /> 保存并测试
</button>
</div>
<p v-if="status?.service?.baseUrl" class="text-[11px] text-zinc-500 leading-relaxed">
当前生效地址:<code class="px-1.5 py-0.5 rounded-md bg-white border border-zinc-200 text-zinc-700 font-mono">{{ status.service.baseUrl }}</code>
</p>
</div>
<div class="flex gap-2">
<button
class="flex-1 h-[44px] px-3 rounded-xl text-[12px] font-bold bg-zinc-100 hover:bg-zinc-200 text-zinc-700 transition-colors flex items-center justify-center gap-1.5"
@click="reloadStatus"
>
<Icon name="rotate-cw" :size="14" /> 刷新状态
</button>
<button
v-if="status?.bound?.hasToken || status?.bound?.hasCookie"
class="flex-1 h-[44px] px-3 rounded-xl text-[12px] font-bold bg-red-50 hover:bg-red-100 text-red-600 transition-colors flex items-center justify-center gap-1.5 border border-red-100"
@click="handleUnbind"
>
<Icon name="log-out" :size="14" /> 解除绑定
</button>
</div>
<button
v-if="!(status?.bound?.hasToken || status?.bound?.hasCookie)"
:disabled="qrLoading"
class="w-full h-[48px] px-3 rounded-xl text-[12px] font-bold bg-blue-600 hover:bg-blue-500 disabled:bg-blue-600/60 text-white transition-colors flex items-center justify-center gap-2 shadow-md shadow-blue-900/10"
@click="startQrLogin"
>
<Icon name="qr-code" :size="16" />
{{ qrLoading ? '正在获取二维码...' : '扫码绑定管理员酷狗账号' }}
</button>
</div>
</div>
<!-- 扫码弹窗 -->
<div
v-if="qrOpen"
class="bg-white/90 border border-zinc-200 rounded-2xl p-6 shadow-sm flex flex-col items-center text-center"
>
<h3 class="text-base font-bold text-zinc-900 mb-1">扫码绑定酷狗账号</h3>
<p class="text-xs text-zinc-500 mb-4">使用酷狗 App 扫描下方二维码完成绑定</p>
<div
class="w-[260px] h-[260px] rounded-2xl border border-zinc-200 bg-zinc-50 flex items-center justify-center overflow-hidden"
>
<img
v-if="qrImg"
:src="qrImg"
alt="kugou-qr"
class="w-full h-full object-contain"
/>
<LoadingState v-else :height="260" />
</div>
<p class="mt-4 text-xs font-semibold" :class="qrStateColor">
{{ qrHint || '等待扫码...' }}
</p>
<button
class="mt-4 h-[40px] px-4 rounded-xl text-[12px] font-bold bg-zinc-100 hover:bg-zinc-200 text-zinc-700 transition-colors"
@click="closeQr"
>
取消
</button>
</div>
<!-- sidecar 部署说明 -->
<div class="bg-zinc-50 border border-dashed border-zinc-300 rounded-2xl p-5 text-[12px] leading-relaxed text-zinc-600 space-y-2">
<h4 class="text-zinc-900 font-bold flex items-center gap-1.5 text-sm">
<Icon name="info" :size="14" /> 如何启动酷狗 sidecar?
</h4>
<p>推荐方式 B:部署开源 KuGouMusicApi 项目到本机 3000 端口(与宝一外校园点歌系统同一台机器即可)。</p>
<pre class="bg-zinc-900 text-zinc-100 text-[11px] rounded-xl p-3 overflow-x-auto font-mono"><code>git clone https://github.com/MakcRe/KuGouMusicApi
cd KuGouMusicApi
npm install
PORT=3000 npm start</code></pre>
<p>启动后点「刷新状态」验证连通性;如需自定义端口,可在站点配置管理中设置 kugouApiBaseUrl。</p>
</div>
</div>
<!-- 中:排期同步主面板 -->
<div class="xl:col-span-2 flex flex-col gap-6">
<div class="bg-white/90 border border-zinc-200 rounded-2xl p-6 shadow-sm">
<div class="flex flex-wrap items-center justify-between gap-3 mb-5">
<div class="flex items-center gap-2">
<div class="w-9 h-9 rounded-xl bg-indigo-50 flex items-center justify-center text-indigo-600">
<Icon name="music" :size="18" />
</div>
<div>
<h3 class="text-base font-bold text-zinc-900">按排期日期创建酷狗歌单</h3>
<p class="text-[11px] text-zinc-500">
每个日期创建 1 个歌单,命名:<span class="font-mono text-zinc-700">BYW广播 YYYY-MM-DD (周X)</span>
</p>
</div>
</div>
<button
:disabled="syncing || selectedDates.length === 0 || !(status?.bound?.hasToken || status?.bound?.hasCookie)"
class="h-[48px] px-5 rounded-xl text-[12px] font-bold bg-indigo-600 hover:bg-indigo-500 disabled:bg-indigo-600/50 text-white transition-colors inline-flex items-center justify-center gap-2 shadow-md shadow-indigo-900/10"
@click="syncToKugou"
>
<Icon name="upload-cloud" :size="16" />
{{ syncing ? '同步中...' : `同步 ${selectedDates.length} 个排期日到酷狗` }}
</button>
</div>
<div class="space-y-3">
<div>
<label class="text-[11px] font-black uppercase tracking-wider text-zinc-600">
选择排期日期(可多选)
</label>
<div class="mt-2">
<AdminMultiDatePicker v-model="selectedDates" />
</div>
</div>
<div class="pt-2 grid grid-cols-1 md:grid-cols-3 gap-3 text-[12px]">
<div class="rounded-xl p-3 bg-zinc-50 border border-zinc-200">
<p class="text-zinc-500">已选排期数</p>
<p class="mt-1 text-lg font-bold text-zinc-900">{{ selectedDates.length }} 天</p>
</div>
<div class="rounded-xl p-3 bg-zinc-50 border border-zinc-200">
<p class="text-zinc-500">读取到歌曲总数</p>
<p class="mt-1 text-lg font-bold text-zinc-900">{{ totalLoadedSongs }} 首</p>
</div>
<div class="rounded-xl p-3 bg-zinc-50 border border-zinc-200">
<p class="text-zinc-500">最近一次同步</p>
<p class="mt-1 text-[13px] font-bold text-zinc-900 truncate">
{{ lastSyncText }}
</p>
</div>
</div>
<div class="flex gap-2">
<button
:disabled="loadingSchedules || selectedDates.length === 0"
class="h-[42px] px-4 rounded-xl text-[12px] font-bold bg-zinc-100 hover:bg-zinc-200 text-zinc-700 transition-colors inline-flex items-center gap-1.5 disabled:opacity-60"
@click="loadSchedules"
>
<Icon name="calendar" :size="14" />
{{ loadingSchedules ? '读取排期中...' : '读取所选日期排期' }}
</button>
<button
class="h-[42px] px-4 rounded-xl text-[12px] font-bold bg-zinc-100 hover:bg-zinc-200 text-zinc-700 transition-colors inline-flex items-center gap-1.5"
@click="loadSyncHistory"
>
<Icon name="list" :size="14" />
刷新历史
</button>
</div>
</div>
<!-- 预览 -->
<div v-if="Object.keys(schedulesByDate).length > 0" class="mt-6 space-y-4">
<div
v-for="(items, date) in schedulesByDate"
:key="String(date)"
class="rounded-2xl border border-zinc-200 bg-white p-4 shadow-sm"
>
<div class="flex items-center justify-between mb-3">
<div>
<h4 class="text-sm font-bold text-zinc-900">{{ date }}</h4>
<p class="text-[11px] text-zinc-500">共 {{ items.length }} 首已发布排期歌曲</p>
</div>
<button
class="h-[38px] px-3 rounded-xl text-[11px] font-bold bg-emerald-50 hover:bg-emerald-100 text-emerald-700 transition-colors inline-flex items-center gap-1.5 border border-emerald-100"
:disabled="syncing || !(status?.bound?.hasToken || status?.bound?.hasCookie) || items.length === 0"
@click="syncSingleDate(String(date))"
>
<Icon name="play" :size="13" /> 单独同步本日
</button>
</div>
<div class="space-y-2 max-h-[260px] overflow-y-auto custom-scrollbar pr-1">
<div
v-for="(s, i) in items"
:key="s.scheduleId ?? i"
class="flex items-center gap-3 p-2.5 rounded-xl bg-zinc-50 border border-zinc-200"
>
<span class="w-6 text-center text-[11px] font-bold text-indigo-600">{{ i + 1 }}</span>
<img
v-if="s.coverUrl || s.cover"
:src="s.coverUrl || s.cover"
class="w-9 h-9 rounded-lg object-cover bg-zinc-200 border border-zinc-200 shrink-0"
@error="($event.target as any).style.display = 'none'"
/>
<div
v-else
class="w-9 h-9 rounded-lg bg-indigo-50 text-indigo-600 flex items-center justify-center border border-zinc-200 shrink-0"
>
<Icon name="music" :size="14" />
</div>
<div class="min-w-0 flex-1">
<p class="text-[13px] font-semibold text-zinc-900 truncate">
{{ s.title }}
</p>
<p class="text-[11px] text-zinc-500 truncate">
{{ s.singer || s.artist || '未知歌手' }}
</p>
</div>
<span
v-if="s.kgHash"
class="text-[10px] font-bold text-emerald-700 bg-emerald-50 rounded-md px-1.5 py-0.5 border border-emerald-100"
title="已绑定酷狗 hash,可精确添加"
>
KG
</span>
</div>
</div>
</div>
</div>
<div
v-else-if="!loadingSchedules && selectedDates.length > 0"
class="mt-6 rounded-2xl border border-dashed border-zinc-300 py-14 flex flex-col items-center justify-center text-zinc-500"
>
<Icon name="calendar-x" :size="36" class="mb-2 text-zinc-400" />
<p class="text-sm">所选日期还没有已发布排期歌曲</p>
</div>
</div>
<!-- 同步历史 -->
<div class="bg-white/90 border border-zinc-200 rounded-2xl p-6 shadow-sm">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<div class="w-9 h-9 rounded-xl bg-emerald-50 flex items-center justify-center text-emerald-600">
<Icon name="history" :size="18" />
</div>
<div>
<h3 class="text-base font-bold text-zinc-900">最近同步记录</h3>
<p class="text-[11px] text-zinc-500">最多显示 100 条,缺失歌曲可在“详情”里手动补录 kgHash 后重试</p>
</div>
</div>
</div>
<div v-if="historyLoading" class="space-y-2">
<LoadingState :height="96" />
</div>
<div v-else-if="!syncHistory.length" class="text-center py-10 text-zinc-500 text-sm">
暂无同步记录
</div>
<div v-else class="space-y-2 max-h-[420px] overflow-y-auto custom-scrollbar pr-1">
<div
v-for="h in syncHistory"
:key="h.id"
class="flex flex-col sm:flex-row sm:items-center gap-3 p-3 rounded-xl border border-zinc-200 bg-zinc-50"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span
:class="[
'px-2 py-0.5 rounded-md text-[10px] font-bold border',
statusBadge(h.status).cls,
]"
>
{{ statusBadge(h.status).text }}
</span>
<span class="text-[13px] font-bold text-zinc-900 truncate">
{{ h.playlistName }}
</span>
<a
v-if="h.playlistUrl"
:href="h.playlistUrl"
target="_blank"
rel="noopener noreferrer"
class="text-[11px] text-indigo-600 hover:underline truncate"
>
在酷狗打开 →
</a>
</div>
<div class="mt-1 text-[11px] text-zinc-500 flex flex-wrap gap-x-3 gap-y-1">
<span>排期:{{ formatDateOnly(h.playDate) }}</span>
<span>成功 {{ h.addedSongs }}/{{ h.totalSongs }}</span>
<span v-if="h.missingSongs?.length" class="text-amber-700">
缺 {{ h.missingSongs.length }} 首
</span>
<span>触发:{{ formatDateTime(h.createdAt) }}</span>
</div>
<p v-if="h.error" class="mt-1 text-[11px] text-red-600 whitespace-pre-wrap break-words">
{{ h.error }}
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ========== 模式二:手动生成照片 ========== -->
<div v-else class="flex flex-col gap-8">
<div class="w-full flex flex-col gap-6">
<div class="bg-zinc-50/40 border border-zinc-200 rounded-xl p-6 space-y-6 shadow-sm">
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-900 shrink-0">
<Icon name="layout" :size="18" class="text-blue-500" /> 选择日期
</h3>
<div class="flex flex-col gap-5 items-end">
<div class="space-y-2 flex-1 w-full">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">选择要生成图片的日期(可多选)</label>
<AdminMultiDatePicker
v-model="cardSelectedDates"
@change="fetchCardSchedules"
/>
</div>
<button
:disabled="cardLoading || cardSelectedDates.length === 0"
class="w-full py-3 h-[54px] bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-md shadow-blue-900/10 transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shrink-0"
@click="fetchCardSchedules"
>
<Icon name="calendar" :size="16" />
{{ cardLoading ? '加载排期中...' : '读取所选日期排期' }}
</button>
</div>
</div>
</div>
<div class="w-full flex flex-col gap-4">
<template v-if="Object.keys(cardSchedulesByDate).length > 0">
<div
v-for="(schedules, date) in cardSchedulesByDate"
:key="String(date)"
class="bg-zinc-50/40 border border-zinc-200 rounded-xl p-6 shadow-sm mb-4"
>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-zinc-900">{{ date }} 排期预览 ({{ schedules.length }} 首)</h3>
<button
:disabled="schedules.length === 0"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold rounded-xl shadow-md shadow-emerald-900/10 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
@click="openGenerator(String(date), schedules)"
>
<Icon name="image" :size="14" />
生成该日图片
</button>
</div>
<div v-if="schedules.length > 0" class="space-y-3">
<div
v-for="(schedule, index) in schedules"
:key="schedule?.id || String(index)"
class="flex items-center gap-3 p-3 bg-white/70 border border-zinc-200 rounded-lg"
>
<span class="text-blue-500 font-bold w-6">{{ index + 1 }}.</span>
<div class="flex-1 overflow-hidden">
<p class="text-sm text-zinc-700 font-medium truncate">{{ schedule?.song?.title || schedule?.title }}</p>
<p class="text-xs text-zinc-500 truncate">{{ schedule?.song?.artist || schedule?.artist }}</p>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-zinc-500">
该日期没有排期歌曲
</div>
</div>
</template>
<div
v-else-if="!cardLoading && cardSelectedDates.length > 0"
class="flex flex-col items-center justify-center py-20 bg-zinc-50/20 border border-zinc-200 rounded-xl border-dashed"
>
<Icon name="calendar" :size="44" class="text-zinc-700 mb-4" />
<p class="text-zinc-500">所选日期均没有排期歌曲</p>
</div>
</div>
<AdminPlaylistCardGenerator
:show="showCardModal"
:songs="currentGeneratorSongs"
:default-title="`${currentGeneratorDate} 校园广播歌单`"
@close="showCardModal = false"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import Icon from '~/components/UI/Icon.vue'
import LoadingState from '~/components/UI/Common/LoadingState.vue'
import AdminMultiDatePicker from './AdminMultiDatePicker.vue'
import AdminPlaylistCardGenerator from './PlaylistCardGenerator.vue'
import { useAuth } from '~/composables/useAuth'
import { useToast } from '~/composables/useToast'
const auth = useAuth()
const toast = useToast()
const WEEKDAY = ['周日', '周一', '周二', '周三', '周四', '周五', '周六']
const today = new Date()
const todayStr = `${today.getFullYear()}-${pad2(today.getMonth() + 1)}-${pad2(today.getDate())}`
const mode = ref<'auto' | 'manual-card'>('auto')
const selectedDates = ref<string[]>([todayStr])
const status = ref<any>(null)
const schedulesByDate = ref<Record<string, any[]>>({})
const syncHistory = ref<any[]>([])
const loadingSchedules = ref(false)
const historyLoading = ref(false)
const syncing = ref(false)
const qrLoading = ref(false)
const qrOpen = ref(false)
const qrImg = ref('')
const qrContext = ref<any>(null)
const qrHint = ref('')
const qrState = ref<number>(0)
const qrPollTimer = ref<any>(null)
const baseUrlInput = ref('')
const baseUrlSaving = ref(false)
const baseUrlSavedTick = ref(0)
const cardSelectedDates = ref<string[]>([todayStr])
const cardSchedulesByDate = ref<Record<string, any[]>>({})
const cardLoading = ref(false)
const showCardModal = ref(false)
const currentGeneratorDate = ref('')
const currentGeneratorSongs = ref<any[]>([])
const totalLoadedSongs = computed(
() => Object.values(schedulesByDate.value).reduce((n, arr) => n + arr.length, 0),
)
const lastSyncText = computed(() => {
if (!syncHistory.value.length) return '暂无同步'
const first = syncHistory.value[0]
return formatDateTime(first.createdAt)
})
const qrStateColor = computed(() => {
const s = qrState.value
if (s === 1) return 'text-emerald-600'
if (s === 2) return 'text-amber-600'
if (s === -1) return 'text-red-600'
return 'text-zinc-500'
})
function pad2(n: number) {
return n < 10 ? '0' + n : '' + n
}
function formatDateTime(s: any) {
if (!s) return '-'
const d = new Date(s)
if (Number.isNaN(d.getTime())) return String(s)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`
}
function formatDateOnly(s: any) {
if (!s) return '-'
const d = new Date(s)
if (Number.isNaN(d.getTime())) return String(s)
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} (${WEEKDAY[d.getDay()]})`
}
function statusBadge(s: string) {
switch (s) {
case 'success':
return { cls: 'bg-emerald-50 text-emerald-700 border-emerald-200', text: '同步成功' }
case 'partial':
return { cls: 'bg-amber-50 text-amber-700 border-amber-200', text: '部分歌曲缺失' }
case 'partial_add_failed':
return { cls: 'bg-amber-50 text-amber-700 border-amber-200', text: '添加歌曲失败' }
case 'failed_create':
return { cls: 'bg-red-50 text-red-700 border-red-200', text: '歌单创建失败' }
case 'skipped_empty':
return { cls: 'bg-zinc-100 text-zinc-500 border-zinc-200', text: '无排期跳过' }
case 'error':
return { cls: 'bg-red-50 text-red-700 border-red-200', text: '同步出错' }
default:
return { cls: 'bg-zinc-100 text-zinc-500 border-zinc-200', text: '等待中' }
}
}
async function reloadStatus() {
try {
const r = await $fetch('/api/admin/kugou/status', { ...auth.getAuthConfig() })
status.value = r
if (r?.service?.baseUrl && !baseUrlInput.value) {
baseUrlInput.value = r.service.baseUrl
}
} catch (e: any) {
toast.show?.(e?.message || '状态刷新失败', 'error')
}
}
async function saveBaseUrl() {
const value = (baseUrlInput.value || '').trim()
if (value && !/^https?:\/\//i.test(value)) {
toast.show?.('请填写合法的 http(s) URL,或留空走环境变量 KUGOU_API_BASE', 'error')
return
}
baseUrlSaving.value = true
baseUrlSavedTick.value = 0
try {
await $fetch('/api/admin/kugou/settings', {
method: 'POST',
...auth.getAuthConfig(),
body: { kugouApiBaseUrl: value || null },
})
toast.show?.('地址已保存', 'success')
baseUrlSavedTick.value = Date.now()
await reloadStatus()
} catch (e: any) {
toast.show?.(e?.message || '保存失败', 'error')
} finally {
baseUrlSaving.value = false
}
}
async function startQrLogin() {
qrLoading.value = true
qrOpen.value = true
qrImg.value = ''
qrHint.value = ''
qrState.value = 0
qrContext.value = null
try {
const r = await $fetch('/api/admin/kugou/qr-create', {
...auth.getAuthConfig(),
query: { platform: 'web' },
})
const d = r?.data
qrContext.value = d
const base64 = d?.img || d?.qrimg || d?.base64 || ''
if (base64) qrImg.value = base64.startsWith('data:image') ? base64 : `data:image/png;base64,${base64}`
else if (d?.qr_url) qrImg.value = d.qr_url
startQrPoll()
} catch (e: any) {
qrHint.value = e?.message || '二维码获取失败'
qrState.value = -1
} finally {
qrLoading.value = false
}
}
function startQrPoll() {
if (qrPollTimer.value) clearInterval(qrPollTimer.value)
let count = 0
qrPollTimer.value = setInterval(async () => {
count++
if (count > 120) {
clearInterval(qrPollTimer.value)
qrPollTimer.value = null
qrHint.value = '二维码已过期,请重试'
qrState.value = -1
return
}
try {
const ctx = qrContext.value || {}
const r: any = await $fetch('/api/admin/kugou/qr-check', {
method: 'POST',
...auth.getAuthConfig(),
body: {
qr_id: ctx.qr_id,
login_id: ctx.login_id,
key: ctx.key || ctx.login_id || ctx.qr_id,
cookie: ctx.cookie || '',
},
})
qrState.value = Number(r?.state ?? 0)
qrHint.value = r?.hint || ''
if (r?.bound) {
clearInterval(qrPollTimer.value)
qrPollTimer.value = null
qrOpen.value = false
toast.show?.('酷狗账号绑定成功', 'success')
await reloadStatus()
await loadSyncHistory()
} else if (qrState.value === -1) {
clearInterval(qrPollTimer.value)
qrPollTimer.value = null
}
} catch (e: any) {
qrHint.value = e?.message || '轮询失败'
}
}, 2500)
}
function closeQr() {
if (qrPollTimer.value) clearInterval(qrPollTimer.value)
qrPollTimer.value = null
qrOpen.value = false
qrImg.value = ''
qrContext.value = null
}
async function handleUnbind() {
if (!window.confirm('确定解除酷狗管理员账号绑定吗?')) return
try {
await $fetch('/api/admin/kugou/unbind', { method: 'POST', ...auth.getAuthConfig() })
toast.show?.('已解除酷狗账号绑定', 'success')
await reloadStatus()
} catch (e: any) {
toast.show?.(e?.message || '解除绑定失败', 'error')
}
}
async function loadSchedules() {
if (!selectedDates.value.length) {
schedulesByDate.value = {}
return
}
loadingSchedules.value = true
const out: Record<string, any[]> = {}
try {
const sorted = [...selectedDates.value].sort()
for (const d of sorted) {
try {
const r: any = await $fetch('/api/admin/schedule/full', {
...auth.getAuthConfig(),
query: { date: d, includeDrafts: 'false' },
})
const list: any[] = r?.success ? r?.data?.schedules ?? [] : []
const normalized = list
.filter((s) => !s.isDraft)
.map((s) => {
const song = s.song || s
return {
scheduleId: s.id,
sequence: s.sequence,
title: song?.title,
singer: song?.singer || song?.artist,
artist: song?.artist || song?.singer,
kgHash: song?.kgHash || song?.kg_hash,
musicId: song?.musicId,
coverUrl: song?.coverUrl || song?.cover_url,
cover: song?.cover,
}
})
if (normalized.length) out[d] = normalized
} catch {}
}
schedulesByDate.value = out
if (!Object.keys(out).length) toast.show?.('所选日期没有已发布排期歌曲', 'info')
} catch (e: any) {
toast.show?.(e?.message || '读取排期失败', 'error')
} finally {
loadingSchedules.value = false
}
}
async function syncToKugou(dates?: string[]) {
const useDates = dates && dates.length ? dates : selectedDates.value
if (!useDates.length) {
toast.show?.('请先选择排期日期', 'warning')
return
}
syncing.value = true
try {
const r: any = await $fetch('/api/admin/kugou/sync-schedule-playlists', {
method: 'POST',
...auth.getAuthConfig(),
body: { dates: useDates },
})
const results: any[] = r?.results || []
const ok = results.filter((x) => x.status === 'success').length
const partial = results.filter((x) => x.status === 'partial').length
const fail = results.length - ok - partial
toast.show?.(
`同步完成:成功 ${ok} 个,部分 ${partial} 个,失败 ${fail} 个`,
fail ? 'error' : partial ? 'warning' : 'success',
)
await loadSyncHistory()
} catch (e: any) {
toast.show?.(e?.message || '同步失败', 'error')
} finally {
syncing.value = false
}
}
async function syncSingleDate(date: string) {
await syncToKugou([date])
}
async function loadSyncHistory() {
historyLoading.value = true
try {
const r: any = await $fetch('/api/admin/kugou/sync-history', { ...auth.getAuthConfig() })
syncHistory.value = r?.rows || []
} catch (e: any) {
toast.show?.(e?.message || '读取同步历史失败', 'error')
} finally {
historyLoading.value = false
}
}
async function fetchCardSchedulesForDate(date: string) {
try {
const response: any = await $fetch('/api/admin/schedule/full', {
...auth.getAuthConfig(),
query: { date, includeDrafts: 'true' },
})
if (response?.success && response?.data?.schedules) {
return response.data.schedules
}
} catch (e: any) {
console.error(`Failed to fetch schedules for ${date}:`, e)
}
return []
}
async function fetchCardSchedules() {
if (cardSelectedDates.value.length === 0) {
cardSchedulesByDate.value = {}
return
}
cardLoading.value = true
const newSchedules: Record<string, any[]> = {}
try {
const sortedDates = [...cardSelectedDates.value].sort()
for (const d of sortedDates) {
const items = await fetchCardSchedulesForDate(d)
if (items.length > 0) newSchedules[d] = items
}
cardSchedulesByDate.value = newSchedules
if (Object.keys(newSchedules).length === 0) {
toast.show?.('所选日期没有排期歌曲', 'info')
}
} catch (e: any) {
toast.show?.(e?.message || '获取排期失败', 'error')
} finally {
cardLoading.value = false
}
}
function openGenerator(date: string, songs: any[]) {
currentGeneratorDate.value = date
currentGeneratorSongs.value = songs
showCardModal.value = true
}
watch(
() => selectedDates.value.slice().sort().join(','),
() => loadSchedules(),
)
watch(
() => cardSelectedDates.value.slice().sort().join(','),
() => fetchCardSchedules(),
)
onMounted(async () => {
await reloadStatus()
await Promise.all([loadSchedules(), loadSyncHistory(), fetchCardSchedules()])
})
</script>
+28 -28
View File
@@ -2,7 +2,7 @@
<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>
<h2 class="text-2xl font-black text-zinc-900 tracking-tight">向用户发送通知</h2>
<p class="text-xs text-zinc-500 mt-1">即时推送系统公告、活动信息或重要提醒至指定群体</p>
</div>
</div>
@@ -10,7 +10,7 @@
<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="bg-zinc-50/30 border border-zinc-200 rounded-[2rem] p-8 shadow-xl">
<div class="space-y-6">
<!-- 标题 -->
<div class="space-y-2">
@@ -21,7 +21,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-2xl px-5 py-3.5 text-sm focus:outline-none focus:border-blue-200 transition-all text-zinc-700 placeholder:text-zinc-500"
>
</div>
@@ -33,12 +33,12 @@
<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"
class="w-full bg-white border border-zinc-200 rounded-2xl px-5 py-4 text-sm focus:outline-none focus:border-blue-200 transition-all text-zinc-700 placeholder:text-zinc-500 min-h-[160px] resize-none"
/>
</div>
<!-- 范围选择 -->
<div class="space-y-3 pt-4 border-t border-zinc-800/50">
<div class="space-y-3 pt-4 border-t border-zinc-200">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
>通知范围</label
>
@@ -50,7 +50,7 @@
'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'
: 'bg-white border-zinc-200 text-zinc-500 hover:border-zinc-200 hover:text-zinc-600'
]"
@click="form.scope = opt.id"
>
@@ -120,7 +120,7 @@
</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="p-6 bg-white/70 border border-zinc-200 border-dashed rounded-2xl">
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
<CustomSelect
v-model="multiClassForm.grade"
@@ -139,7 +139,7 @@
/>
<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"
class="px-4 bg-zinc-100 hover:bg-zinc-200 disabled:opacity-50 disabled:cursor-not-allowed text-zinc-400 font-bold rounded-xl text-xs transition-all"
@click="addClassToSelection"
>
添加
@@ -151,7 +151,7 @@
<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"
class="flex items-center gap-2 px-3 py-1.5 bg-white border border-zinc-200 rounded-lg group"
>
<span class="text-[10px] font-bold text-zinc-400"
>{{ cls.grade }} {{ cls.class }}</span
@@ -188,7 +188,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-2xl pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-blue-200 transition-all text-zinc-700"
@input="onUserSearchInput"
>
</div>
@@ -196,9 +196,9 @@
<!-- 搜索结果 -->
<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"
class="bg-white border border-zinc-200 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">
<div class="px-4 py-2 border-b border-zinc-200/60 bg-zinc-50/40">
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
>搜索结果 ({{ userSearchResults.length }})</span
>
@@ -206,10 +206,10 @@
<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"
class="flex items-center justify-between p-4 border-b border-zinc-200/30 last:border-0 hover:bg-zinc-100/30 transition-all"
>
<div>
<h5 class="text-sm font-bold text-zinc-200">
<h5 class="text-sm font-bold text-zinc-700">
{{ user.name || user.username }}
</h5>
<div class="flex items-center gap-2 mt-0.5">
@@ -228,7 +228,7 @@
</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"
class="px-3 py-1.5 bg-zinc-100 hover:bg-blue-600 hover:text-zinc-900 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) ? '已选择' : '选择' }}
@@ -253,10 +253,10 @@
<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"
class="flex items-center gap-2 px-3 py-1.5 bg-white border border-zinc-200 rounded-lg group"
>
<div class="flex flex-col">
<span class="text-[10px] font-bold text-zinc-300 leading-none">{{
<span class="text-[10px] font-bold text-zinc-600 leading-none">{{
user.name || user.username
}}</span>
<span class="text-[8px] text-zinc-600">@{{ user.username }}</span>
@@ -302,10 +302,10 @@
<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',
'flex items-center gap-2 px-10 py-4 bg-white border border-zinc-200 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'
: 'text-zinc-400 hover:text-zinc-900 hover:border-zinc-300 hover:bg-white'
]"
@click="sendNotification"
>
@@ -320,17 +320,17 @@
<!-- 右侧:预览区 -->
<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"
class="bg-zinc-50/30 border border-zinc-200 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"
class="text-sm font-black text-zinc-900 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"
class="w-full max-w-[320px] bg-white border border-zinc-200 rounded-3xl overflow-hidden shadow-2xl relative"
>
<!-- 顶部装饰 -->
<div class="h-1.5 bg-blue-600 w-full" />
@@ -339,12 +339,12 @@
<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"
class="w-8 h-8 rounded-xl bg-blue-50 text-blue-500 flex items-center justify-center border border-blue-200"
>
<Bell :size="14" />
</div>
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>VoiceHub 系统</span
>宝一外校园点歌系统</span
>
</div>
<span class="text-[9px] text-zinc-700 font-bold uppercase tracking-wider"
@@ -356,7 +356,7 @@
<h4
:class="[
'text-sm font-black transition-colors',
form.title ? 'text-zinc-100' : 'text-zinc-800 italic'
form.title ? 'text-zinc-900' : 'text-zinc-800 italic'
]"
>
{{ form.title || '通知标题' }}
@@ -371,7 +371,7 @@
</p>
</div>
<div class="pt-4 border-t border-zinc-800/50 flex items-center justify-between">
<div class="pt-4 border-t border-zinc-200 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">
@@ -379,7 +379,7 @@
</span>
</div>
<button
class="p-1.5 text-blue-500 hover:bg-blue-600/10 rounded-lg transition-all"
class="p-1.5 text-blue-500 hover:bg-blue-50 rounded-lg transition-all"
>
<MessageSquare :size="14" />
</button>
@@ -742,7 +742,7 @@ onUnmounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+19 -19
View File
@@ -5,19 +5,19 @@
<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"
class="bg-zinc-50/40 border border-zinc-200 rounded-2xl p-6 relative overflow-hidden group hover:border-zinc-200 transition-all shadow-lg shadow-zinc-900/5"
>
<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'
? 'bg-blue-500/10 text-blue-500 border-blue-200'
: 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'
: 'bg-zinc-100 text-zinc-500 border-zinc-200'
]"
>
<component :is="stat.icon" :size="24" />
@@ -26,7 +26,7 @@
v-if="stat.trend"
:class="[
'flex items-center gap-1 text-[11px] font-bold',
stat.trendDown ? 'text-red-400' : 'text-emerald-400'
stat.trendDown ? 'text-red-400' : 'text-emerald-700'
]"
>
<TrendingDown v-if="stat.trendDown" :size="12" />
@@ -36,7 +36,7 @@
</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>
<h4 class="text-3xl font-bold tracking-tight text-zinc-900">{{ stat.value }}</h4>
</div>
</div>
</div>
@@ -44,14 +44,14 @@
<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"
class="lg:col-span-5 bg-zinc-50/40 border border-zinc-200 rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-zinc-900/5"
>
<div class="px-6 py-5 border-b border-zinc-800 flex items-center justify-between">
<div class="px-6 py-5 border-b border-zinc-200 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="p-2 text-zinc-500 hover:text-zinc-600 transition-colors"
:class="{ 'animate-spin': loadingActivities }"
@click="refreshActivities"
>
@@ -79,7 +79,7 @@
<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"
class="flex items-start gap-4 p-4 rounded-2xl hover:bg-zinc-100/40 transition-all cursor-pointer group"
>
<div
:class="[
@@ -91,7 +91,7 @@
</div>
<div class="flex-1 min-w-0">
<h5
class="font-bold text-sm text-zinc-200 group-hover:text-blue-400 transition-colors"
class="font-bold text-sm text-zinc-700 group-hover:text-blue-700 transition-colors"
>
{{ activity.title }}
</h5>
@@ -110,9 +110,9 @@
<!-- 系统状态 -->
<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"
class="lg:col-span-4 bg-zinc-50/40 border border-zinc-200 rounded-xl overflow-hidden flex flex-col shadow-lg shadow-zinc-900/5"
>
<div class="px-6 py-5 border-b border-zinc-800 flex items-center justify-between">
<div class="px-6 py-5 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-lg font-bold flex items-center gap-2">
<ShieldCheck :size="18" class="text-emerald-500" /> 系统状态
</h3>
@@ -144,16 +144,16 @@
/>
<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>
<span class="text-xs font-bold text-zinc-700">{{ 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"
class="lg:col-span-3 bg-zinc-50/40 border border-zinc-200 rounded-3xl overflow-hidden flex flex-col shadow-lg shadow-zinc-900/5"
>
<div class="px-6 py-5 border-b border-zinc-800">
<div class="px-6 py-5 border-b border-zinc-200">
<h3 class="text-lg font-bold flex items-center gap-2">
<Zap :size="18" class="text-yellow-500" /> 快速操作
</h3>
@@ -166,7 +166,7 @@
'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'
: 'bg-white/80 border-zinc-200 text-zinc-400 hover:border-zinc-300 hover:text-zinc-700'
]"
@click="navigateTo(action.id)"
>
@@ -302,9 +302,9 @@ 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' }
schedule: { icon: Calendar, bg: 'bg-blue-500/10 text-blue-500 border-blue-200' }
}
return styles[type] || { icon: Activity, bg: 'bg-zinc-500/10 text-zinc-500 border-zinc-500/20' }
return styles[type] || { icon: Activity, bg: 'bg-zinc-100 text-zinc-500 border-zinc-200' }
}
const loadStats = async () => {
@@ -366,7 +366,7 @@ onMounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+138 -36
View File
@@ -3,14 +3,14 @@
<!-- 页面标题 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">播出时段管理</h2>
<h2 class="text-2xl font-black text-zinc-900 tracking-tight">播出时段管理</h2>
<p class="text-xs text-zinc-500 mt-1">
定义校园广播的常规播放窗口,用户仅可在这些时间段内进行点歌或查看排期
</p>
</div>
<div class="flex items-center gap-3">
<div class="flex flex-wrap 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"
class="bg-zinc-50/40 border border-zinc-200 rounded-xl px-4 py-2 flex items-center gap-3"
>
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
>启用播出时段选择</span
@@ -18,7 +18,7 @@
<button
:class="[
'relative w-10 h-5 rounded-full transition-colors',
enablePlayTimeSelection ? 'bg-blue-600' : 'bg-zinc-800'
enablePlayTimeSelection ? 'bg-blue-600' : 'bg-zinc-100'
]"
@click="toggleGlobalEnabled"
>
@@ -30,6 +30,21 @@
/>
</button>
</div>
<div v-if="playTimes.length > 1" class="flex items-center gap-2 bg-zinc-50/40 border border-zinc-200 rounded-xl px-3 py-2">
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest">批量</span>
<button
class="px-3 py-1.5 text-[10px] font-black bg-emerald-600/20 text-emerald-700 border border-emerald-200 rounded-lg hover:bg-emerald-600/30 transition-all"
@click="batchToggleAll(true)"
>
全部启用
</button>
<button
class="px-3 py-1.5 text-[10px] font-black bg-zinc-100 text-zinc-400 border border-zinc-200 rounded-lg hover:bg-zinc-200 transition-all"
@click="batchToggleAll(false)"
>
全部禁用
</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"
@@ -43,7 +58,7 @@
<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-xs font-bold text-zinc-600">关于时段限制</p>
<p class="text-[11px] text-zinc-500 leading-relaxed">
启用全局时段选择后,系统将仅在定义的时段内开放点歌功能。如果未设置开始或结束时间,系统将视为该边界不设限。
</p>
@@ -53,7 +68,7 @@
<!-- 加载状态 -->
<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"
class="flex flex-col items-center justify-center py-20 space-y-4 bg-zinc-50/20 border border-zinc-200 rounded-xl"
>
<div
class="w-10 h-10 border-4 border-blue-600/20 border-t-blue-600 rounded-full animate-spin"
@@ -68,7 +83,7 @@
>
<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-xs font-bold text-zinc-600">获取数据失败</p>
<p class="text-[11px] text-zinc-500 leading-relaxed">{{ error }}</p>
</div>
</div>
@@ -76,13 +91,13 @@
<!-- 空状态 -->
<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"
class="flex flex-col items-center justify-center py-20 space-y-6 bg-zinc-50/20 border border-zinc-200 rounded-xl"
>
<div class="p-6 rounded-xl bg-zinc-950 border border-zinc-800 text-zinc-700">
<div class="p-6 rounded-xl bg-white border border-zinc-200 text-zinc-700">
<Clock :size="48" />
</div>
<div class="text-center space-y-2">
<h3 class="text-lg font-bold text-zinc-100">暂无播出时段</h3>
<h3 class="text-lg font-bold text-zinc-900">暂无播出时段</h3>
<p class="text-xs text-zinc-500">点击“添加播出时段”按钮创建第一个播出时段</p>
</div>
</div>
@@ -94,15 +109,15 @@
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'
'group relative bg-zinc-50/30 border rounded-xl p-8 transition-all hover:shadow-2xl hover:shadow-black/40',
playTime.enabled ? 'border-zinc-200/80' : 'border-zinc-200 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'
'p-3 rounded-xl bg-white border border-zinc-200 transition-all',
playTime.enabled ? 'text-blue-500 border-blue-200' : 'text-zinc-700'
]"
>
<Clock :size="20" />
@@ -113,7 +128,7 @@
'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'
: 'bg-zinc-100/50 text-zinc-600 border-zinc-200'
]"
>
{{ playTime.enabled ? '已启用' : '已禁用' }}
@@ -127,7 +142,7 @@
<div class="space-y-4">
<div>
<h4
class="text-lg font-black text-zinc-100 group-hover:text-blue-400 transition-colors"
class="text-lg font-black text-zinc-900 group-hover:text-blue-700 transition-colors"
>
{{ playTime.name }}
</h4>
@@ -135,7 +150,7 @@
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" />
<div class="w-4 h-[2px] bg-zinc-100" />
<span class="text-xl">{{ playTime.endTime || '不限' }}</span>
</div>
</div>
@@ -145,16 +160,23 @@
</p>
</div>
<div class="mt-8 pt-6 border-t border-zinc-800/50 flex items-center justify-between">
<div class="mt-8 pt-6 border-t border-zinc-200 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"
class="p-2.5 bg-white border border-zinc-200 rounded-lg text-zinc-500 hover:text-emerald-700 hover:border-emerald-200 transition-all"
title="复制此时段"
@click="duplicatePlayTime(playTime)"
>
<Copy :size="14" />
</button>
<button
class="p-2.5 bg-white border border-zinc-200 rounded-lg text-zinc-500 hover:text-blue-700 hover:border-blue-200 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"
class="p-2.5 bg-white border border-zinc-200 rounded-lg text-zinc-500 hover:text-red-400 hover:border-red-200 transition-all"
@click="confirmDelete(playTime)"
>
<Trash2 :size="14" />
@@ -164,7 +186,7 @@
: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-zinc-100 text-zinc-400 hover:text-zinc-700'
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
]"
@click="togglePlayTimeStatus(playTime)"
@@ -185,15 +207,15 @@
>
<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"
class="relative w-full max-w-lg bg-white border border-zinc-200 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">
<h3 class="text-xl font-black text-zinc-900 tracking-tight">
{{ editingPlayTime ? '编辑播出时段' : '添加播出时段' }}
</h3>
<button
class="p-2 text-zinc-500 hover:text-zinc-200 transition-colors"
class="p-2 text-zinc-500 hover:text-zinc-700 transition-colors"
@click="cancelForm"
>
<X :size="20" />
@@ -201,6 +223,23 @@
</div>
<div class="space-y-6">
<!-- 快速预设 -->
<div v-if="!editingPlayTime" class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">快速模板</label>
<div class="flex flex-wrap gap-2">
<button
v-for="p in playTimePresets"
:key="p.name"
type="button"
class="px-3 py-2 bg-white border border-zinc-200 rounded-lg text-[11px] font-bold text-zinc-400 hover:text-zinc-700 hover:border-zinc-200 hover:bg-white transition-all"
@click="applyPreset(p)"
>
<span class="block">{{ p.name }}</span>
<span class="block text-[9px] text-zinc-600 font-medium">{{ p.startTime }} - {{ p.endTime }}</span>
</button>
</div>
</div>
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>时段名称</label
@@ -209,7 +248,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-lg px-5 py-3.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
@@ -222,7 +261,7 @@
<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"
class="w-full bg-white border border-zinc-200 rounded-lg px-5 py-3.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 appearance-none"
>
<Clock
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
@@ -239,7 +278,7 @@
<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"
class="w-full bg-white border border-zinc-200 rounded-lg px-5 py-3.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 appearance-none"
>
<Clock
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
@@ -259,7 +298,7 @@
<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"
class="w-full bg-white border border-zinc-200 rounded-lg px-5 py-4 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 min-h-[100px] resize-none"
/>
</div>
@@ -267,10 +306,10 @@
<input
v-model="formData.enabled"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-blue-600"
class="w-4 h-4 rounded border-zinc-200 bg-white accent-blue-600"
>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
class="text-xs font-bold text-zinc-600 group-hover:text-blue-700 transition-colors"
>启用此播出时段</span
>
</label>
@@ -284,9 +323,9 @@
</div>
</div>
<div class="px-8 py-6 bg-zinc-950/50 border-t border-zinc-800 flex justify-end gap-3">
<div class="px-8 py-6 bg-white/70 border-t border-zinc-200 flex justify-end gap-3">
<button
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-600"
@click="cancelForm"
>
取消
@@ -311,7 +350,7 @@
@click="showDeleteConfirm = false"
/>
<div
class="relative w-full max-w-sm bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
class="relative w-full max-w-sm bg-white border border-zinc-200 rounded-xl shadow-2xl overflow-hidden"
>
<div class="p-8">
<div class="flex flex-col items-center py-4 space-y-6">
@@ -321,7 +360,7 @@
<AlertCircle :size="32" />
</div>
<div class="text-center space-y-2 px-4">
<h4 class="text-lg font-bold text-zinc-100">
<h4 class="text-lg font-bold text-zinc-900">
确定要删除播出时段 "{{ playTimeToDelete?.name }}" 吗?
</h4>
<p class="text-xs text-zinc-500 leading-relaxed">
@@ -330,7 +369,7 @@
</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"
class="flex-1 px-4 py-3 bg-white border border-zinc-200 text-zinc-500 text-xs font-black rounded-lg transition-all hover:bg-zinc-100"
@click="showDeleteConfirm = false"
>
取消
@@ -366,9 +405,19 @@ import {
Info,
AlertCircle,
X,
AlignLeft
AlignLeft,
Copy
} from '@lucide/vue'
const playTimePresets = [
{ name: '早间广播', startTime: '07:00', endTime: '07:40', description: '早自习前广播时段' },
{ name: '课间操时段', startTime: '09:30', endTime: '10:00', description: '大课间操期间' },
{ name: '午间广播', startTime: '12:00', endTime: '12:40', description: '午休后午间广播' },
{ name: '傍晚广播', startTime: '17:30', endTime: '18:15', description: '放学后时段' },
{ name: '晚间新闻', startTime: '21:00', endTime: '21:30', description: '晚自习结束前' },
{ name: '全天开放', startTime: '', endTime: '', description: '无时间边界限制' }
]
const { getAuthConfig, isAdmin } = useAuth()
const { showToast: showNotification } = useToast()
@@ -636,6 +685,59 @@ const savePlayTime = async () => {
}
}
// 应用快速模板
const applyPreset = (preset) => {
Object.assign(formData, {
name: preset.name,
startTime: preset.startTime,
endTime: preset.endTime,
description: preset.description,
enabled: true
})
}
// 复制时段
const duplicatePlayTime = (playTime: PlayTime) => {
showAddForm.value = true
editingPlayTime.value = null
Object.assign(formData, {
id: 0,
name: `${playTime.name} (副本)`,
startTime: playTime.startTime || '',
endTime: playTime.endTime || '',
description: playTime.description || '',
enabled: playTime.enabled
})
}
// 批量切换启用状态
const batchToggleAll = async (enable: boolean) => {
if (!isAdmin.value) return
const ids = playTimes.value
.filter((pt) => pt.enabled !== enable)
.map((pt) => pt.id)
if (ids.length === 0) {
showNotification(`所有时段已是${enable ? '启用' : '禁用'}状态`, 'info')
return
}
try {
const authConfig = getAuthConfig()
await Promise.all(
ids.map((id) =>
$fetch(`/api/admin/play-times/${id}`, {
method: 'PATCH',
body: { enabled: enable },
...authConfig
})
)
)
await fetchPlayTimes()
showNotification(`已${enable ? '启用' : '禁用'} ${ids.length} 个时段`, 'success')
} catch (err: any) {
showNotification(err.message || '批量操作失败', 'error')
}
}
// 取消表单
const cancelForm = () => {
showAddForm.value = false
+115 -30
View File
@@ -8,13 +8,13 @@
<Transition name="scale">
<div
v-if="show"
class="bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden flex flex-col"
class="bg-white border border-zinc-200 rounded-2xl shadow-2xl w-full max-w-sm overflow-hidden flex flex-col"
@click.stop
>
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">生成歌单卡片</h3>
<div class="flex items-center justify-between p-4 border-b border-zinc-200">
<h3 class="text-sm font-black text-zinc-900 uppercase tracking-widest">生成歌单卡片</h3>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
class="text-zinc-500 hover:text-zinc-600 transition-colors"
@click="$emit('close')"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path d="M18 6L6 18M6 6l12 12"/></svg>
@@ -29,22 +29,22 @@
v-model="cardTitle"
type="text"
placeholder="例如: 2026-03-21 校园午间广播"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50 transition-colors"
class="w-full bg-white border border-zinc-200 rounded-lg px-3 py-2 text-sm text-zinc-600 focus:outline-none focus:border-blue-500/50 transition-colors"
@input="drawCanvas"
>
</div>
<!-- Canvas 预览 -->
<div class="relative w-full aspect-[2/3] max-w-[280px] bg-zinc-950 rounded-xl overflow-hidden border border-zinc-800 shadow-inner flex items-center justify-center">
<div class="relative w-full aspect-[2/3] max-w-[280px] bg-white rounded-xl overflow-hidden border border-zinc-200 shadow-inner flex items-center justify-center">
<canvas ref="canvasRef" width="400" :height="canvasHeight" class="w-full h-full object-contain bg-white"></canvas>
</div>
<p class="text-[10px] text-zinc-500 text-center">宽度 400 像素,高度自适应,适合在手机端查看和分享</p>
</div>
<div class="p-4 border-t border-zinc-800 bg-zinc-900/50 flex gap-3">
<div class="p-4 border-t border-zinc-200 bg-zinc-50/50 flex gap-3">
<button
class="flex-1 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
class="flex-1 py-2.5 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
@click="$emit('close')"
>
取消
@@ -86,13 +86,87 @@ const aiPrompt = ref('anime style school campus sunset beautiful sky highly deta
const isGeneratingAi = ref(false)
const bgImageObj = ref(null)
const maxDisplaySongs = 35
const maxDisplaySongs = 200
const songLeftX = 44
const songRightX = 360
const songAreaWidth = songRightX - songLeftX
const isBrowser = typeof document !== 'undefined'
let _sharedCtx = null
const getSharedCtx = () => {
if (!isBrowser) return null
if (_sharedCtx) return _sharedCtx
try {
const c = document.createElement('canvas')
_sharedCtx = c.getContext('2d')
} catch { _sharedCtx = null }
return _sharedCtx
}
const fitText = (ctx, text, fontSize, maxWidth, lineHeight) => {
const hasCtx = !!ctx
const fontFamily = `"Inter", "system-ui", "PingFang SC", "Microsoft YaHei", sans-serif`
const avgCharWidth = fontSize * 0.55
const cjkCharWidth = fontSize * 1.05
const splitLines = String(text || '').split(/\r?\n/)
const lines = []
for (const rawLine of splitLines) {
if (hasCtx) {
ctx.font = `${fontSize}px ${fontFamily}`
const chars = Array.from(rawLine)
let current = ''
for (const ch of chars) {
const tryStr = current + ch
const w = ctx.measureText(tryStr).width
if (w > maxWidth && current) {
lines.push(current)
current = ch
} else {
current = tryStr
}
}
if (current) lines.push(current)
} else {
const chars = Array.from(rawLine)
let current = ''
let curW = 0
for (const ch of chars) {
const code = ch.codePointAt(0) || 0
const isCJK = code > 0x2e80
const w = isCJK ? cjkCharWidth : avgCharWidth
if (curW + w > maxWidth && current) {
lines.push(current)
current = ch
curW = w
} else {
current += ch
curW += w
}
}
if (current) lines.push(current)
}
}
return { lines, lineHeight }
}
const canvasHeight = computed(() => {
const count = Math.min(props.songs.length, maxDisplaySongs)
if (count <= 10) return 600
// 每多一首增加 40px 高度
return 600 + (count - 10) * 40
if (!count) return 600
const ctx = getSharedCtx()
let totalH = 0
const maxWidthTitle = songAreaWidth
const maxWidthArtist = Math.max(100, songAreaWidth - 10)
for (let i = 0; i < count; i++) {
const s = props.songs[i]
const song = s?.song || s || {}
const title = song.title || '未知歌曲'
const artist = song.artist || '未知歌手'
const t1 = fitText(ctx, title, 16, maxWidthTitle, 22)
const t2 = fitText(ctx, `- ${artist}`, 13, maxWidthArtist, 18)
totalH += Math.max(28, (t1.lines.length * t1.lineHeight) + (t2.lines.length * t2.lineHeight) + 10)
}
return 240 + totalH
})
@@ -136,7 +210,7 @@ const drawCanvas = () => {
// 绘制副标题(日期)
ctx.font = '14px "Inter", "system-ui", sans-serif'
ctx.fillStyle = 'rgba(255, 255, 255, 0.8)'
ctx.fillStyle = 'rgba(0, 0, 0, 0.75)'
ctx.fillText(new Date().toLocaleDateString('zh-CN'), 200, 80)
// 绘制列表背景
@@ -155,33 +229,44 @@ const drawCanvas = () => {
// 绘制歌曲列表
ctx.textAlign = 'left'
ctx.textBaseline = 'top'
let y = 160
const displaySongs = props.songs.slice(0, maxDisplaySongs) // 最多显示 35 首
const displaySongs = props.songs.slice(0, maxDisplaySongs)
displaySongs.forEach((schedule, index) => {
const song = schedule.song || schedule
// 序号
ctx.fillStyle = '#2563eb'
ctx.font = 'bold 16px "Inter", "system-ui", sans-serif'
ctx.font = 'bold 16px "Inter", "system-ui", "PingFang SC", "Microsoft YaHei", sans-serif'
ctx.textBaseline = 'top'
ctx.fillText(`${index + 1}.`, 40, y)
// 歌名
ctx.fillStyle = '#0F172A' // Neutral Dark
ctx.font = 'bold 16px "Inter", "system-ui", sans-serif'
// 限制歌名长度
let title = song.title || '未知歌曲'
if (title.length > 12) title = title.substring(0, 12) + '...'
ctx.fillText(title, 70, y)
const maxWidthTitle = songAreaWidth
const maxWidthArtist = Math.max(120, songAreaWidth - 10)
const title = song.title || '未知歌曲'
const artist = song.artist || '未知歌手'
// 歌手
const t1 = fitText(ctx, title, 16, maxWidthTitle, 22)
ctx.fillStyle = '#0F172A'
ctx.font = `bold 16px "Inter", "system-ui", "PingFang SC", "Microsoft YaHei", sans-serif`
let ty = y
for (const line of t1.lines) {
ctx.fillText(line, songLeftX, ty)
ty += t1.lineHeight
}
const t2 = fitText(ctx, `- ${artist}`, 13, maxWidthArtist, 18)
ctx.fillStyle = '#64748B'
ctx.font = '14px "Inter", "system-ui", sans-serif'
let artist = song.artist || '未知歌手'
if (artist.length > 10) artist = artist.substring(0, 10) + '...'
ctx.fillText(`- ${artist}`, 240, y)
ctx.font = `13px "Inter", "system-ui", "PingFang SC", "Microsoft YaHei", sans-serif`
let ay = ty + 4
for (const line of t2.lines) {
ctx.fillText(line, songLeftX, ay)
ay += t2.lineHeight
}
y += 40
const songBlockHeight = Math.max(28, (ty - y) + (ay - ty) + 4)
y += songBlockHeight + 8
})
if (props.songs.length > maxDisplaySongs) {
@@ -193,13 +278,13 @@ const drawCanvas = () => {
// 绘制底部水印
if (bgImageObj.value) {
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)'
} else {
ctx.fillStyle = '#CBD5E1'
}
ctx.font = '12px "Inter", "system-ui", sans-serif'
ctx.textAlign = 'center'
ctx.fillText('Generated by VoiceHub', 200, height - 20)
ctx.fillText('Generated by 宝一外校园点歌系统', 200, height - 20)
}
const downloadCard = () => {
@@ -0,0 +1,162 @@
<template>
<div class="flex flex-col space-y-8 pb-12 lg:pb-20 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="flex flex-col space-y-2">
<h2 class="text-2xl font-bold tracking-tight text-zinc-900">导入酷狗</h2>
<p class="text-sm text-zinc-500">选择排期日期,生成对应的广播排期卡片,用于扫描导入酷狗音乐</p>
</div>
<div class="flex flex-col gap-8">
<!-- 设置面板 -->
<div class="w-full flex flex-col gap-6">
<div class="bg-zinc-50/40 border border-zinc-200 rounded-xl p-6 space-y-6 shadow-lg">
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-900 shrink-0">
<Layout class="w-4 h-4 text-blue-500" /> 选择日期
</h3>
<div class="flex flex-col gap-5 items-end">
<!-- 日期选择 -->
<div class="space-y-2 flex-1 w-full">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">选择要生成图片的日期(可多选)</label>
<AdminMultiDatePicker
v-model="selectedDates"
@change="fetchSchedules"
/>
</div>
<button
:disabled="loading || selectedDates.length === 0"
class="w-full py-3 h-[54px] bg-blue-600 hover:bg-blue-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-blue-900/20 transition-colors uppercase tracking-wider disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2 shrink-0"
@click="fetchSchedules"
>
<CalendarDays class="w-4 h-4" />
{{ loading ? '加载排期中...' : '读取所选日期排期' }}
</button>
</div>
</div>
</div>
<!-- 预览区 -->
<div class="w-full flex flex-col gap-4">
<template v-if="Object.keys(schedulesByDate).length > 0">
<div v-for="(schedules, date) in schedulesByDate" :key="date" class="bg-zinc-50/40 border border-zinc-200 rounded-xl p-6 shadow-lg mb-4">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-bold text-zinc-900">{{ date }} 排期预览 ({{ schedules.length }} 首)</h3>
<button
:disabled="schedules.length === 0"
class="px-4 py-2 bg-emerald-600 hover:bg-emerald-500 text-white text-xs font-bold rounded-xl shadow-lg shadow-emerald-900/20 transition-colors flex items-center gap-2 disabled:opacity-50 disabled:cursor-not-allowed"
@click="openGenerator(date, schedules)"
>
<ImageIcon class="w-4 h-4" />
生成该日图片
</button>
</div>
<div v-if="schedules.length > 0" class="space-y-3">
<div v-for="(schedule, index) in schedules" :key="schedule.id || index" class="flex items-center gap-3 p-3 bg-white/70 border border-zinc-200 rounded-lg">
<span class="text-blue-500 font-bold w-6">{{ index + 1 }}.</span>
<div class="flex-1 overflow-hidden">
<p class="text-sm text-zinc-700 font-medium truncate">{{ schedule.song?.title || schedule.title }}</p>
<p class="text-xs text-zinc-500 truncate">{{ schedule.song?.artist || schedule.artist }}</p>
</div>
</div>
</div>
<div v-else class="text-center py-8 text-zinc-500">
该日期没有排期歌曲
</div>
</div>
</template>
<div v-else-if="!loading && selectedDates.length > 0" class="flex flex-col items-center justify-center py-20 bg-zinc-50/20 border border-zinc-200 rounded-xl border-dashed">
<CalendarDays class="w-12 h-12 text-zinc-700 mb-4" />
<p class="text-zinc-500">所选日期均没有排期歌曲</p>
</div>
</div>
</div>
<AdminPlaylistCardGenerator
:show="showModal"
:songs="currentGeneratorSongs"
:default-title="`${currentGeneratorDate} 校园广播歌单`"
@close="showModal = false"
/>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { Layout, Image as ImageIcon, CalendarDays } from '@lucide/vue'
import AdminMultiDatePicker from './AdminMultiDatePicker.vue'
import AdminPlaylistCardGenerator from './PlaylistCardGenerator.vue'
import { useAuth } from '~/composables/useAuth'
const auth = useAuth()
const todayStr = new Date().toISOString().split('T')[0]
const selectedDates = ref([todayStr])
const schedulesByDate = ref({})
const loading = ref(false)
const showModal = ref(false)
const currentGeneratorDate = ref('')
const currentGeneratorSongs = ref([])
const fetchSchedulesForDate = async (date) => {
try {
const response = await $fetch('/api/admin/schedule/full', {
...auth.getAuthConfig(),
query: {
date: date,
includeDrafts: 'true'
}
})
if (response.success && response.data && response.data.schedules) {
return response.data.schedules
}
} catch (error) {
console.error(`Failed to fetch schedules for ${date}:`, error)
}
return []
}
const fetchSchedules = async () => {
if (selectedDates.value.length === 0) {
schedulesByDate.value = {}
return
}
loading.value = true
const newSchedules = {}
try {
// 按日期顺序获取
const sortedDates = [...selectedDates.value].sort()
for (const date of sortedDates) {
const schedules = await fetchSchedulesForDate(date)
if (schedules.length > 0) {
newSchedules[date] = schedules
}
}
schedulesByDate.value = newSchedules
if (Object.keys(newSchedules).length === 0 && window.$showNotification) {
window.$showNotification('所选日期没有排期歌曲', 'info')
}
} catch (error) {
console.error('Failed to fetch schedules:', error)
if (window.$showNotification) {
window.$showNotification('获取排期失败', 'error')
}
} finally {
loading.value = false
}
}
const openGenerator = (date, songs) => {
currentGeneratorDate.value = date
currentGeneratorSongs.value = songs
showModal.value = true
}
onMounted(() => {
fetchSchedules()
})
</script>
Binary file not shown.
+197 -63
View File
@@ -3,14 +3,14 @@
<!-- 顶部标题和全局开关 -->
<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>
<h2 class="text-2xl font-black text-zinc-900 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"
class="flex flex-wrap items-center gap-4 bg-zinc-50/40 border border-zinc-200 p-2 rounded-3xl"
>
<div class="flex items-center gap-3 px-3 border-r border-zinc-800/50 mr-1">
<div class="flex items-center gap-3 px-3 border-r border-zinc-200 mr-1">
<span class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
>当前状态</span
>
@@ -31,7 +31,7 @@
<button
:class="[
'relative w-10 h-5 rounded-full transition-colors',
enableRequest ? 'bg-blue-600' : 'bg-zinc-800'
enableRequest ? 'bg-blue-600' : 'bg-zinc-100'
]"
@click="toggleGlobalRequest"
>
@@ -44,14 +44,14 @@
</button>
</div>
<div class="flex items-center gap-3 px-2 border-l border-zinc-800/50">
<div class="flex items-center gap-3 px-2 border-l border-zinc-200">
<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'
enableRequestTimeLimitation ? 'bg-purple-600' : 'bg-zinc-100'
]"
@click="toggleTimeLimitation"
>
@@ -70,17 +70,17 @@
<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"
class="group relative h-full min-h-[220px] bg-zinc-50/20 border-2 border-dashed border-zinc-200 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"
class="w-14 h-14 rounded-full bg-white border border-zinc-200 flex items-center justify-center text-zinc-500 group-hover:text-blue-500 group-hover:bg-blue-50 group-hover:border-blue-200 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"
class="text-sm font-black text-zinc-400 group-hover:text-blue-700 transition-colors uppercase tracking-widest"
>
添加投稿时段
</h4>
@@ -101,16 +101,16 @@
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'
'group bg-zinc-50/40 border rounded-3xl p-8 space-y-6 transition-all hover:shadow-2xl hover:shadow-black/40',
slot.enabled && !slot.past ? 'border-zinc-200' : 'border-zinc-200 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',
'p-3.5 rounded-2xl bg-white border border-zinc-200 text-zinc-400 transition-colors',
slot.enabled && !slot.past
? 'text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/10'
? 'text-blue-500 border-blue-200 shadow-lg shadow-blue-900/10'
: ''
]"
>
@@ -124,7 +124,7 @@
? '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'
: 'bg-zinc-100/50 text-zinc-600 border-zinc-200'
]"
>
{{ slot.past ? '已过期' : slot.enabled ? '已启用' : '已禁用' }}
@@ -135,7 +135,7 @@
<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"
class="text-lg font-black text-zinc-900 flex items-baseline gap-2 group-hover:text-blue-700 transition-colors"
>
{{ slot.name }}
<span class="text-xs font-bold text-zinc-600 tracking-tight"
@@ -150,7 +150,7 @@
}}</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" />
<div class="w-3 h-[2px] bg-zinc-100 ml-1.5" />
<span class="text-[10px] uppercase tracking-tighter truncate">{{
slot.endTime || '不限'
}}</span>
@@ -179,7 +179,7 @@
>
</div>
<div
class="h-1.5 w-full bg-zinc-950 rounded-full overflow-hidden border border-zinc-800/50"
class="h-1.5 w-full bg-white rounded-full overflow-hidden border border-zinc-200"
>
<div
class="h-full transition-all duration-500"
@@ -196,17 +196,17 @@
</div>
</div>
<div class="pt-6 border-t border-zinc-800/50 flex items-center justify-between">
<div class="pt-6 border-t border-zinc-200 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"
class="p-2.5 bg-white border border-zinc-200 rounded-xl text-zinc-500 hover:text-blue-700 hover:border-blue-200 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"
class="p-2.5 bg-white border border-zinc-200 rounded-xl text-zinc-500 hover:text-red-400 hover:border-red-200 transition-all"
@click="confirmDelete(slot)"
>
<Trash2 :size="14" />
@@ -217,7 +217,7 @@
: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-zinc-100 text-zinc-400 hover:text-zinc-700'
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
]"
@click="toggleRequestTimeStatus(slot)"
@@ -232,12 +232,12 @@
<!-- 统计概览 -->
<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"
class="bg-zinc-50/20 border border-zinc-200 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',
'w-12 h-12 rounded-2xl bg-white border border-zinc-200 flex items-center justify-center text-zinc-500 transition-colors',
`group-hover:text-${stat.color}-500`
]"
>
@@ -247,7 +247,7 @@
<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>
<h5 class="text-xl font-black text-zinc-700">{{ stat.value }}</h5>
</div>
</div>
</div>
@@ -263,17 +263,17 @@
>
<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"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-white/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"
class="w-full max-w-2xl bg-white border border-zinc-200 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">
<div class="px-8 py-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-900">
{{ editingRequestTime ? '编辑投稿开放时段' : '添加投稿开放时段' }}
</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="cancelForm">
<button class="text-zinc-500 hover:text-zinc-600 transition-colors" @click="cancelForm">
<X :size="20" />
</button>
</div>
@@ -287,30 +287,68 @@
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"
class="w-full bg-white border border-zinc-200 rounded-2xl px-5 py-3.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
<div class="space-y-3">
<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-[10px] font-bold text-zinc-500">点击可一键套用常见时段</span>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="p in requestTimeQuickPresets"
:key="p.label"
type="button"
class="px-3 py-2 bg-white border border-zinc-200 rounded-xl text-[11px] font-bold text-zinc-400 hover:text-zinc-700 hover:border-zinc-200 hover:bg-white transition-all whitespace-nowrap"
@click="applyRequestTimePreset(p)"
>
{{ p.label }}
</button>
</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
>
<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"
>开始时间</label
>
<div class="grid grid-cols-2 gap-2">
<input
v-model="formData.startDate"
type="date"
class="w-full bg-white border border-zinc-200 rounded-2xl px-4 py-3.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200"
>
<input
v-model="formData.startTimeOfDay"
type="time"
class="w-full bg-white border border-zinc-200 rounded-2xl px-4 py-3.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
<p class="text-[9px] text-zinc-600 px-1">
实际值: {{ formData.startTime || '尚未设置' }}
</p>
</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"
>结束时间</label
>
<div class="grid grid-cols-2 gap-2">
<input
v-model="formData.endDate"
type="date"
class="w-full bg-white border border-zinc-200 rounded-2xl px-4 py-3.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200"
>
<input
v-model="formData.endTimeOfDay"
type="time"
class="w-full bg-white border border-zinc-200 rounded-2xl px-4 py-3.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
<p class="text-[9px] text-zinc-600 px-1">
实际值: {{ formData.endTime || '尚未设置' }}
</p>
</div>
</div>
@@ -323,7 +361,7 @@
<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"
class="w-full bg-white border border-zinc-200 rounded-2xl px-5 py-3.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200"
>
<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]"
@@ -339,11 +377,11 @@
<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"
class="w-4.5 h-4.5 rounded-lg border-zinc-200 bg-white accent-blue-600"
>
<div>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
class="text-xs font-bold text-zinc-600 group-hover:text-blue-700 transition-colors"
>启用此投稿时段</span
>
<p class="text-[9px] text-zinc-600 font-medium">
@@ -361,7 +399,7 @@
<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"
class="w-full bg-white border border-zinc-200 rounded-2xl px-5 py-4 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 min-h-[100px] resize-none"
/>
</div>
@@ -374,9 +412,9 @@
</div>
</div>
<div class="px-8 py-6 bg-zinc-900/50 border-t border-zinc-800/50 flex gap-3 justify-end">
<div class="px-8 py-6 bg-zinc-50/50 border-t border-zinc-200 flex gap-3 justify-end">
<button
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-600"
@click="cancelForm"
>
取消
@@ -404,11 +442,11 @@
>
<div
v-if="showDeleteConfirm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-white/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"
class="w-full max-w-md bg-white border border-zinc-200 rounded-3xl shadow-2xl overflow-hidden p-8"
>
<div class="flex flex-col items-center space-y-6">
<div
@@ -417,17 +455,17 @@
<Trash2 :size="28" />
</div>
<div class="text-center space-y-2 px-4">
<h4 class="text-lg font-bold text-zinc-100">
<h4 class="text-lg font-bold text-zinc-900">
确定要彻底移除 "{{ RequestTimeToDelete?.name }}" 吗?
</h4>
<p class="text-xs text-zinc-500 leading-relaxed">
删除时段后,原本属于此周期的投稿关联记录可能失效。建议先尝试
<span class="text-zinc-300 font-bold">禁用</span> 该时段。
<span class="text-zinc-600 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"
class="flex-1 px-4 py-3 bg-white hover:bg-zinc-100 text-zinc-500 text-xs font-black rounded-2xl transition-all"
@click="showDeleteConfirm = false"
>
保留时段
@@ -447,9 +485,9 @@
<!-- 说明部分 -->
<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">
<div class="flex-1 bg-zinc-50/30 border border-zinc-200 rounded-[2rem] p-6 space-y-4">
<h4
class="text-xs font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
class="text-xs font-black text-zinc-900 uppercase tracking-widest flex items-center gap-2"
>
<CheckCircle2 :size="14" class="text-emerald-500" /> 逻辑优先级说明
</h4>
@@ -457,16 +495,16 @@
<li class="flex gap-2">
<span class="text-zinc-700 font-black">1.</span>
<span
><span class="text-zinc-300">全局开关</span>
><span class="text-zinc-600">全局开关</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
>开启 <span class="text-zinc-600">时段限制</span> 后,系统会检查当前时间是否处于任一
<span class="text-zinc-600">已启用</span> 且未达到
<span class="text-zinc-600">计划数</span> 的时段内。</span
>
</li>
<li class="flex gap-2">
@@ -476,9 +514,9 @@
</ul>
</div>
<div class="flex-1 bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-4">
<div class="flex-1 bg-zinc-50/30 border border-zinc-200 rounded-[2rem] p-6 space-y-4">
<h4
class="text-xs font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
class="text-xs font-black text-zinc-900 uppercase tracking-widest flex items-center gap-2"
>
<XCircle :size="14" class="text-rose-500" /> 注意事项
</h4>
@@ -502,7 +540,7 @@
</template>
<script lang="ts" setup>
import { onMounted, onUnmounted, reactive, ref, computed } from 'vue'
import { onMounted, onUnmounted, reactive, ref, computed, watch } from 'vue'
import { useAuth } from '~/composables/useAuth'
import type { RequestTime } from '~~/types'
import {
@@ -521,6 +559,57 @@ import {
Filter
} from '@lucide/vue'
const addMinutes = (d: Date, mins: number) => {
const r = new Date(d); r.setMinutes(r.getMinutes() + mins); return r
}
const addHours = (d: Date, h: number) => addMinutes(d, h * 60)
const addDays = (d: Date, n: number) => {
const r = new Date(d); r.setDate(r.getDate() + n); return r
}
const nextWeekday = (base: Date, target: number) => {
const r = new Date(base)
const diff = (target - r.getDay() + 7) % 7 || 7
r.setDate(r.getDate() + diff)
return r
}
const pad = (n: number) => n.toString().padStart(2, '0')
const fmt = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`
const splitDT = (dt: string): [string, string] => {
if (!dt || !dt.includes('T')) return ['', '']
const [d, t] = dt.split('T')
return [d, t.slice(0, 5)]
}
const requestTimeQuickPresets = [
{ label: '立即开放 2 小时', apply: () => {
const n = new Date()
return { startTime: addMinutes(n, 5), endTime: addHours(n, 2) }
}},
{ label: '今晚 18:00-22:00', apply: () => {
const t = new Date(); t.setHours(18, 0, 0, 0)
return { startTime: t, endTime: addHours(t, 4) }
}},
{ label: '明天 12:00-14:00', apply: () => {
const t = addDays(new Date(), 1); t.setHours(12, 0, 0, 0)
return { startTime: t, endTime: addHours(t, 2) }
}},
{ label: '下周一至周五', apply: () => {
const s = nextWeekday(new Date(), 1); s.setHours(0, 5, 0, 0)
const e = addDays(s, 4); e.setHours(23, 59, 0, 0)
return { startTime: s, endTime: e }
}},
{ label: '下周整周 7 天', apply: () => {
const s = nextWeekday(new Date(), 1); s.setHours(0, 5, 0, 0)
const e = addDays(s, 6); e.setHours(23, 59, 0, 0)
return { startTime: s, endTime: e }
}},
{ label: '持续开放 30 天', apply: () => {
const s = addMinutes(new Date(), 5)
const e = addDays(s, 30); e.setHours(23, 59, 0, 0)
return { startTime: s, endTime: e }
}}
]
const { getAuthConfig, isAdmin } = useAuth()
const requestTimes = ref<RequestTime[]>([])
@@ -544,11 +633,51 @@ const formData = reactive({
name: '',
startTime: '',
endTime: '',
startDate: '',
startTimeOfDay: '',
endDate: '',
endTimeOfDay: '',
description: '',
enabled: true,
expected: 0
})
// 日期+时间分离字段 <-> 合并的 datetime-local 字段双向同步
const syncStartFromParts = () => {
if (formData.startDate && formData.startTimeOfDay) {
formData.startTime = `${formData.startDate}T${formData.startTimeOfDay}`
} else if (!formData.startDate && !formData.startTimeOfDay) {
formData.startTime = ''
}
}
const syncEndFromParts = () => {
if (formData.endDate && formData.endTimeOfDay) {
formData.endTime = `${formData.endDate}T${formData.endTimeOfDay}`
} else if (!formData.endDate && !formData.endTimeOfDay) {
formData.endTime = ''
}
}
watch(() => formData.startDate, syncStartFromParts)
watch(() => formData.startTimeOfDay, syncStartFromParts)
watch(() => formData.endDate, syncEndFromParts)
watch(() => formData.endTimeOfDay, syncEndFromParts)
// 将 startTime/endTime 拆分到辅助字段(编辑时调用)
const splitFormDateTime = () => {
const [sd, st] = splitDT(formData.startTime)
formData.startDate = sd; formData.startTimeOfDay = st
const [ed, et] = splitDT(formData.endTime)
formData.endDate = ed; formData.endTimeOfDay = et
}
// 应用快捷时间预设
const applyRequestTimePreset = (p: any) => {
const { startTime, endTime } = p.apply()
formData.startTime = fmt(startTime)
formData.endTime = fmt(endTime)
splitFormDateTime()
}
// 统计数据
const stats = computed(() => [
{
@@ -711,6 +840,7 @@ const editRequestTime = (RequestTime: RequestTime) => {
enabled: RequestTime.enabled,
expected: RequestTime.expected || 0
})
splitFormDateTime()
}
const toggleRequestTimeStatus = async (RequestTime: RequestTime) => {
@@ -827,6 +957,10 @@ const cancelForm = () => {
name: '',
startTime: '',
endTime: '',
startDate: '',
startTimeOfDay: '',
endDate: '',
endTimeOfDay: '',
description: '',
enabled: true,
expected: 0
@@ -842,7 +976,7 @@ const cancelForm = () => {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
+11 -11
View File
@@ -2,7 +2,7 @@
<div class="roles-manager">
<div class="admin-section-header">
<div>
<h2 class="text-xl font-black text-zinc-100 flex items-center gap-2">
<h2 class="text-xl font-black text-zinc-900 flex items-center gap-2">
<Shield class="w-6 h-6 text-indigo-400" />
权限组管理
</h2>
@@ -28,7 +28,7 @@
<div v-else-if="error" class="py-12 text-center text-red-400">
{{ error }}
<button class="mt-4 px-4 py-2 bg-zinc-800 rounded-lg hover:bg-zinc-700" @click="fetchRoles">
<button class="mt-4 px-4 py-2 bg-zinc-100 rounded-lg hover:bg-zinc-200" @click="fetchRoles">
重试
</button>
</div>
@@ -37,15 +37,15 @@
<div
v-for="(roleConfig, roleKey) in roles"
:key="roleKey"
class="role-card p-6 bg-zinc-900/50 border border-zinc-800 rounded-2xl"
class="role-card p-6 bg-zinc-50/50 border border-zinc-200 rounded-2xl"
>
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="w-10 h-10 rounded-xl bg-zinc-800 flex items-center justify-center text-zinc-400">
<div class="w-10 h-10 rounded-xl bg-zinc-100 flex items-center justify-center text-zinc-400">
<Shield class="w-5 h-5" />
</div>
<div>
<h3 class="font-bold text-zinc-200">{{ roleConfig.displayName }}</h3>
<h3 class="font-bold text-zinc-700">{{ roleConfig.displayName }}</h3>
<p class="text-xs text-zinc-500 font-mono mt-0.5">{{ roleKey }}</p>
</div>
</div>
@@ -56,7 +56,7 @@
class="hidden"
:disabled="roleKey === 'SUPER_ADMIN'"
/>
<div class="w-10 h-6 bg-zinc-800 rounded-full relative transition-colors" :class="{ 'bg-indigo-500': roleConfig.canAccessAdmin }">
<div class="w-10 h-6 bg-zinc-100 rounded-full relative transition-colors" :class="{ 'bg-indigo-500': roleConfig.canAccessAdmin }">
<div class="absolute w-4 h-4 bg-white rounded-full top-1 transition-transform" :class="roleConfig.canAccessAdmin ? 'left-5' : 'left-1'" />
</div>
<span class="text-xs font-bold text-zinc-400">允许进后台</span>
@@ -67,20 +67,20 @@
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-2">
<h4 class="text-xs font-bold text-zinc-500 uppercase tracking-wider">可访问页面</h4>
<span class="text-[10px] font-black px-2 py-0.5 rounded-md bg-zinc-800/60 text-zinc-400 border border-zinc-800">
<span class="text-[10px] font-black px-2 py-0.5 rounded-md bg-zinc-100/60 text-zinc-400 border border-zinc-200">
已启用 {{ roleConfig.pages.length }} 项
</span>
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 text-[11px] font-black rounded-lg bg-zinc-800/60 border border-zinc-800 text-zinc-300 hover:bg-zinc-800 transition-colors"
class="px-3 py-1.5 text-[11px] font-black rounded-lg bg-zinc-100/60 border border-zinc-200 text-zinc-600 hover:bg-zinc-100 transition-colors"
:disabled="roleKey === 'SUPER_ADMIN'"
@click="selectAll(roleKey)"
>
全选
</button>
<button
class="px-3 py-1.5 text-[11px] font-black rounded-lg bg-zinc-800/60 border border-zinc-800 text-zinc-300 hover:bg-zinc-800 transition-colors"
class="px-3 py-1.5 text-[11px] font-black rounded-lg bg-zinc-100/60 border border-zinc-200 text-zinc-600 hover:bg-zinc-100 transition-colors"
:disabled="roleKey === 'SUPER_ADMIN'"
@click="clearAll(roleKey)"
>
@@ -109,7 +109,7 @@
v-for="page in section.pages"
:key="page.id"
class="flex items-center gap-2 p-2 rounded-xl border cursor-pointer transition-all"
:class="roleConfig.pages.includes(page.id) ? 'bg-indigo-500/10 border-indigo-500/30 text-indigo-200 shadow-[0_0_0_1px_rgba(99,102,241,0.15)]' : 'bg-zinc-800/30 border-zinc-800 text-zinc-500 hover:bg-zinc-800/70'"
:class="roleConfig.pages.includes(page.id) ? 'bg-indigo-500/10 border-indigo-500/30 text-indigo-200 shadow-[0_0_0_1px_rgba(99,102,241,0.15)]' : 'bg-zinc-100/30 border-zinc-200 text-zinc-500 hover:bg-zinc-100/70'"
>
<input
v-model="roleConfig.pages"
@@ -125,7 +125,7 @@
</div>
</div>
</div>
<div v-else class="mt-6 py-8 text-center border border-dashed border-zinc-800 rounded-xl">
<div v-else class="mt-6 py-8 text-center border border-dashed border-zinc-200 rounded-xl">
<p class="text-sm text-zinc-600">该角色不允许进入后台</p>
</div>
</div>
+5 -5
View File
@@ -1,18 +1,18 @@
<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)]"
class="backdrop-blur-md p-6 rounded-xl border border-zinc-200 bg-slate-800/70 shadow-2xl max-w-[400px] mx-auto text-zinc-900 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">
<h3 class="mb-6 pb-2 border-b border-zinc-200 text-zinc-900 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>
<label class="block mb-2 font-medium text-zinc-900" 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)]"
class="w-full p-3 border border-zinc-200 rounded-lg text-base bg-white/90 text-zinc-900 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"
>
@@ -51,7 +51,7 @@
<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"
class="flex-1 p-3 border border-zinc-200 rounded-lg text-base cursor-pointer transition-all duration-200 bg-white/10 text-zinc-900 hover:bg-white/15"
type="button"
@click="$emit('cancel')"
>
+37 -17
View File
@@ -7,16 +7,16 @@
</div>
<!-- 歌曲封面 -->
<div v-if="settings.showCover" class="cover-section">
<div v-if="settings.showCover" class="cover-section aspect-square">
<img
v-if="schedule.song.cover"
:alt="schedule.song.title"
:src="convertToHttps(schedule.song.cover)"
class="song-cover"
class="song-cover object-cover"
referrerpolicy="no-referrer"
@error="handleImageError"
>
<div class="cover-placeholder" :class="{ 'show': !schedule.song.cover }">
<div class="cover-placeholder object-contain" :class="{ 'show': !schedule.song.cover }">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<circle cx="12" cy="12" r="3" />
<path d="M12 1v6m0 6v6" />
@@ -112,13 +112,14 @@ const handleImageError = (event) => {
justify-content: center;
font-weight: bold;
font-size: 14px;
color: #333;
color: #333333 !important;
flex-shrink: 0;
}
.cover-section {
width: 40px;
height: 40px;
aspect-ratio: 1 / 1;
flex-shrink: 0;
}
@@ -127,17 +128,20 @@ const handleImageError = (event) => {
height: 100%;
object-fit: cover;
border-radius: 4px;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
}
.cover-placeholder {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
object-fit: contain;
background: #f5f5f5;
border-radius: 4px;
display: none;
align-items: center;
justify-content: center;
color: #999;
color: var(--text-tertiary, #999999);
}
.cover-placeholder.show {
@@ -157,7 +161,7 @@ const handleImageError = (event) => {
.song-title {
font-weight: bold;
font-size: 16px;
color: #333;
color: #333333 !important;
margin-bottom: 2px;
white-space: nowrap;
overflow: hidden;
@@ -182,7 +186,7 @@ const handleImageError = (event) => {
.song-artist {
font-size: 14px;
color: #666;
color: #666666 !important;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
@@ -193,7 +197,7 @@ const handleImageError = (event) => {
display: flex;
align-items: center;
font-size: 12px;
color: #666;
color: #666666 !important;
white-space: nowrap;
}
@@ -203,14 +207,14 @@ const handleImageError = (event) => {
}
.value {
color: #333;
color: #333333 !important;
}
/* 打印样式 */
@media print {
.schedule-item-print {
color: #000 !important;
background: #fff !important;
color: #000000 !important;
background: #ffffff !important;
width: 100% !important;
max-width: none !important;
box-sizing: border-box !important;
@@ -226,25 +230,41 @@ const handleImageError = (event) => {
.sequence-number {
background: #f0f0f0 !important;
color: #000 !important;
color: #000000 !important;
width: 30px !important;
height: 30px !important;
flex-shrink: 0 !important;
}
.cover-section {
width: 40px !important;
height: 40px !important;
width: 48px !important;
height: 48px !important;
aspect-ratio: 1 / 1 !important;
flex-shrink: 0 !important;
}
.song-cover {
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
border-radius: 0 !important;
box-shadow: none !important;
}
.cover-placeholder {
width: 100% !important;
height: 100% !important;
object-fit: contain !important;
border-radius: 0 !important;
}
.song-info {
flex: 1 !important;
min-width: 0 !important;
}
.song-title {
color: #000 !important;
color: #000000 !important;
font-size: 16px !important;
white-space: nowrap !important;
overflow: hidden !important;
@@ -254,11 +274,11 @@ const handleImageError = (event) => {
.song-artist,
.label,
.value {
color: #333 !important;
color: #333333 !important;
}
.time-range {
color: #666 !important;
color: var(--text-quaternary, #666666) !important;
}
.requester-info,
+407 -117
View File
@@ -7,27 +7,107 @@
@update:modelValue="handleDateSelect"
/>
<!-- 播出时段与自动排期配置 -->
<!-- 播出时段标签组 + 自动排期策略 -->
<div
class="grid grid-cols-1 md:grid-cols-2 gap-3 bg-zinc-900/30 border border-zinc-800 rounded-xl p-3 md:p-4"
class="space-y-3 bg-zinc-50/30 border border-zinc-200 rounded-xl p-3 md:p-4"
>
<div v-if="playTimeEnabled">
<CustomSelect
:model-value="selectedPlayTime"
label="播出时段"
:options="playTimeOptions"
class-name="w-full"
@update:model-value="handlePlayTimeSelect"
/>
<!-- 播出时段:标签按钮组(直接点击切换) -->
<div v-if="playTimeEnabled && playTimes.length > 0" class="space-y-2">
<div class="flex items-center justify-between px-1">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">播出时段</span>
<span class="text-[10px] font-bold text-zinc-500">
{{ selectedPlayTime ? '已按时段过滤' : '显示全天排期' }}
</span>
</div>
<div class="flex flex-wrap gap-2">
<button
:class="[
'px-4 py-2 rounded-xl text-xs font-black border transition-all whitespace-nowrap',
selectedPlayTime === ''
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
: 'bg-white border-zinc-200 text-zinc-400 hover:text-zinc-700 hover:border-zinc-200'
]"
@click="handlePlayTimeSelect('')"
>
<span class="flex items-center gap-1.5">
<Layers :size="12" />
全部时段 (全天)
</span>
</button>
<button
v-for="pt in playTimes"
:key="pt.id"
:disabled="!pt.enabled"
:class="[
'px-4 py-2 rounded-xl text-xs font-black border transition-all whitespace-nowrap',
selectedPlayTime === String(pt.id)
? pt.enabled
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-200 border-zinc-300 text-zinc-600'
: pt.enabled
? 'bg-white border-zinc-200 text-zinc-400 hover:text-zinc-700 hover:border-zinc-200'
: 'bg-white/80 border-zinc-200 text-zinc-700 cursor-not-allowed'
]"
@click="pt.enabled && handlePlayTimeSelect(String(pt.id))"
>
<span class="flex items-center gap-1.5">
<Clock :size="12" />
<span>{{ pt.name }}</span>
<span
v-if="pt.startTime || pt.endTime"
class="opacity-70 font-bold"
>
{{ pt.startTime || '--' }}-{{ pt.endTime || '--' }}
</span>
<span
v-if="!pt.enabled"
class="ml-1 px-1.5 py-0.5 text-[9px] bg-zinc-100 rounded text-zinc-500"
>已禁用</span>
</span>
</button>
</div>
</div>
<div :class="playTimeEnabled ? '' : 'md:col-span-2'">
<CustomSelect
:model-value="autoScheduleStrategy"
label="自动排期策略"
:options="scheduleStrategyOptions"
class-name="w-full"
@update:model-value="handleStrategyChange"
/>
<!-- 自动排期策略 -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
<div :class="playTimeEnabled ? '' : 'md:col-span-2'">
<CustomSelect
:model-value="autoScheduleStrategy"
label="自动排期策略"
:options="scheduleStrategyOptions"
class-name="w-full"
@update:model-value="handleStrategyChange"
/>
</div>
<!-- 快速统计信息 -->
<div
v-if="playTimeEnabled && playTimes.length > 0"
class="hidden md:flex items-center gap-3 bg-white/80 border border-zinc-200 rounded-xl px-4 py-2"
>
<div class="flex-1 flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-blue-600/20 text-blue-700 flex items-center justify-center">
<ListMusic :size="14" />
</div>
<div class="flex flex-col leading-tight">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">当前列表</span>
<span class="text-sm font-black text-zinc-700">
{{ localScheduledSongs.length }} 首 / {{ playTimeScheduledCount }} 首
</span>
</div>
</div>
<div class="w-px h-8 bg-zinc-100" />
<div class="flex-1 flex items-center gap-2">
<div class="w-8 h-8 rounded-lg bg-purple-600/20 text-purple-400 flex items-center justify-center">
<Inbox :size="14" />
</div>
<div class="flex flex-col leading-tight">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">待排歌曲</span>
<span class="text-sm font-black text-zinc-700">
{{ filteredUnscheduledSongs.length }} 首
</span>
</div>
</div>
</div>
</div>
</div>
</div>
@@ -39,7 +119,7 @@
<div v-else>
<div
class="lg:hidden sticky -top-4 -mx-4 -mt-4 z-20 flex gap-2 p-2 bg-zinc-950/90 backdrop-blur-md border-b border-zinc-800 shadow-xl mb-4 pt-4 min-h-[64px]"
class="lg:hidden sticky -top-4 -mx-4 -mt-4 z-20 flex gap-2 p-2 bg-white/90 backdrop-blur-md border-b border-zinc-200 shadow-xl mb-4 pt-4 min-h-[64px]"
>
<button
:class="[
@@ -51,7 +131,7 @@
<ListMusic class="w-4 h-4" />
<span class="flex items-center gap-1.5"
>待排歌曲
<span class="px-1.5 py-0.5 bg-zinc-800 text-[10px] rounded text-zinc-400">{{
<span class="px-1.5 py-0.5 bg-zinc-100 text-[10px] rounded text-zinc-400">{{
filteredUnscheduledSongs.length
}}</span></span
>
@@ -66,7 +146,7 @@
<PlaySquare class="w-4 h-4" />
<span class="flex items-center gap-1.5"
>播放列表
<span class="px-1.5 py-0.5 bg-zinc-800 text-[10px] rounded text-zinc-400">{{
<span class="px-1.5 py-0.5 bg-zinc-100 text-[10px] rounded text-zinc-400">{{
localScheduledSongs.length
}}</span></span
>
@@ -86,11 +166,11 @@
@drop.stop.prevent="handleReturnToDraggable"
>
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-2 md:gap-3 px-1 min-h-[52px]">
<h3 class="hidden lg:block text-lg font-black tracking-tight text-zinc-100 uppercase">
<h3 class="hidden lg:block text-lg font-black tracking-tight text-zinc-900 uppercase">
待排歌曲
</h3>
<div
class="flex w-full lg:w-auto gap-2 p-1.5 bg-zinc-900/50 rounded-xl border border-zinc-800"
class="flex w-full lg:w-auto gap-2 p-1.5 bg-zinc-50/50 rounded-xl border border-zinc-200"
>
<button
v-for="tab in [
@@ -102,7 +182,7 @@
:class="[
'flex-1 lg:flex-none px-3 md:px-4 py-2 rounded-lg text-[10px] md:text-xs font-black uppercase tracking-widest transition-all',
activeTab === tab.id
? 'bg-zinc-800 text-blue-400 shadow-md border border-blue-500/20'
? 'bg-zinc-100 text-blue-700 shadow-md border border-blue-200'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="activeTab = tab.id"
@@ -113,9 +193,9 @@
</div>
<!-- 筛选区 - 移动端折叠 -->
<div class="bg-zinc-900/40 border border-zinc-800 rounded-2xl shadow-xl">
<div class="bg-zinc-50/40 border border-zinc-200 rounded-2xl shadow-xl">
<div
class="p-3 md:p-4 flex items-center justify-between lg:hidden border-b border-zinc-800/50 rounded-t-2xl"
class="p-3 md:p-4 flex items-center justify-between lg:hidden border-b border-zinc-200 rounded-t-2xl"
@click="mobileFiltersOpen = !mobileFiltersOpen"
>
<div class="flex items-center gap-2 text-zinc-400">
@@ -142,7 +222,7 @@
v-model="searchQuery"
type="text"
placeholder="搜索歌曲、艺术家..."
class="w-full pl-9 pr-4 py-2 bg-zinc-950 border border-zinc-800 rounded-xl text-xs focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
class="w-full pl-9 pr-4 py-2 bg-white border border-zinc-200 rounded-xl text-xs focus:outline-none focus:border-blue-200 transition-all text-zinc-700"
>
<button
v-if="searchQuery"
@@ -174,14 +254,14 @@
'draggable-songs flex-1 border-2 border-dashed rounded-[2rem] p-2 md:p-3 min-h-[400px] transition-colors duration-200',
isDraggableOver
? 'border-blue-500 bg-blue-500/5'
: 'border-zinc-800/80 bg-zinc-900/20'
: 'border-zinc-200/80 bg-zinc-50/20'
]"
>
<div class="space-y-2">
<div
v-for="song in filteredUnscheduledSongs"
:key="song.id"
class="draggable-song relative group bg-zinc-900 border border-zinc-800/50 rounded-xl p-3 hover:border-zinc-700 transition-all select-none"
class="draggable-song relative group bg-white border border-zinc-200 rounded-xl p-3 hover:border-zinc-200 transition-all select-none"
draggable="true"
@dragend="dragEnd"
@dragstart="dragStart($event, song)"
@@ -193,7 +273,7 @@
<div class="flex items-center gap-3">
<!-- 封面图片 -->
<div
class="relative w-12 h-12 rounded-lg overflow-hidden bg-zinc-800 flex-shrink-0 border border-zinc-700/50 cursor-pointer hover:opacity-80 transition-opacity"
class="relative w-12 h-12 rounded-lg overflow-hidden bg-zinc-100 flex-shrink-0 border border-zinc-200 cursor-pointer hover:opacity-80 transition-opacity"
@click.stop="playSong(song)"
>
<img
@@ -214,10 +294,10 @@
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<h4 class="font-bold text-zinc-100 text-sm truncate">
<h4 class="font-bold text-zinc-900 text-sm truncate">
<span
v-if="false"
class="text-zinc-100 flex items-center gap-1 w-full text-left"
class="text-zinc-900 flex items-center gap-1 w-full text-left"
>
<span class="truncate">{{ song.title }}</span>
</span>
@@ -225,7 +305,7 @@
</h4>
<button
v-if="song.hasSubmissionNote && song.submissionNote"
class="inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-all flex-shrink-0"
class="inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-200 bg-blue-500/10 text-blue-700 hover:bg-blue-500/20 transition-all flex-shrink-0"
title="查看备注留言"
@click.stop="openSubmissionRemark(song)"
>
@@ -262,7 +342,7 @@
<!-- 普通模式:投票数 -->
<div
v-if="activeTab !== 'replay'"
class="flex items-center gap-1 text-[10px] font-bold text-zinc-500 bg-zinc-950/50 px-2 py-1 rounded-md border border-zinc-800/50"
class="flex items-center gap-1 text-[10px] font-bold text-zinc-500 bg-white/70 px-2 py-1 rounded-md border border-zinc-200"
>
<Heart class="w-3 h-3 text-red-500/50" />
{{ song.voteCount || 0 }}
@@ -271,7 +351,7 @@
<!-- 重播模式:查看按钮 -->
<button
v-if="activeTab === 'replay'"
class="px-3 py-1.5 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-400 border border-blue-500/20 text-[10px] font-bold transition-colors"
class="px-3 py-1.5 rounded-lg bg-blue-500/10 hover:bg-blue-500/20 text-blue-700 border border-blue-200 text-[10px] font-bold transition-colors"
@click.stop="openReplayModal(song)"
>
查看
@@ -289,7 +369,7 @@
<!-- 移动端添加按钮 -->
<button
class="lg:hidden p-2 rounded-full bg-blue-600/20 text-blue-400 hover:bg-blue-600/30 active:scale-95 transition-all flex-shrink-0"
class="lg:hidden p-2 rounded-full bg-blue-600/20 text-blue-700 hover:bg-blue-600/30 active:scale-95 transition-all flex-shrink-0"
@click.stop="addSongToSchedule(song)"
>
<Plus class="w-5 h-5" />
@@ -297,7 +377,7 @@
<!-- 菜单按钮 -->
<div
class="p-1.5 rounded-lg bg-zinc-950 border border-zinc-800 text-zinc-600 cursor-grab active:cursor-grabbing hover:text-zinc-400 transition-colors"
class="p-1.5 rounded-lg bg-white border border-zinc-200 text-zinc-600 cursor-grab active:cursor-grabbing hover:text-zinc-400 transition-colors"
>
<MoreVertical class="w-4 h-4" />
</div>
@@ -342,81 +422,81 @@
<div
class="hidden lg:flex flex-col xl:flex-row xl:items-center justify-between gap-3 md:gap-4 px-1 min-h-[56px]"
>
<h3 class="text-lg font-black tracking-tight text-zinc-100 uppercase">播放顺序</h3>
<h3 class="text-lg font-black tracking-tight text-zinc-900 uppercase">播放顺序</h3>
<div
class="flex flex-wrap items-center gap-1.5 md:gap-2 p-1.5 bg-zinc-900/50 border border-zinc-800/50 rounded-2xl"
class="flex flex-wrap items-center gap-1.5 md:gap-2 p-1.5 bg-zinc-50/50 border border-zinc-200 rounded-2xl"
>
<div class="flex gap-1 md:gap-1.5">
<button
:disabled="
!hasChanges && localScheduledSongs.length === 0 && !hasUnpublishedDrafts
"
class="p-2 bg-zinc-950 border border-zinc-800 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-zinc-700 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
@click="saveDraft"
>
<Save class="w-3.5 h-3.5" />
<span
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-800 text-[9px] text-zinc-300 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-700"
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
>保存草稿</span
>
</button>
<button
:disabled="localScheduledSongs.length === 0"
class="p-2 bg-zinc-950 border border-zinc-800 hover:bg-zinc-800 text-zinc-500 hover:text-zinc-200 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-zinc-700 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
@click="openDownloadDialog"
>
<Download class="w-3.5 h-3.5" />
<span
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-800 text-[9px] text-zinc-300 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-700"
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
>下载歌曲</span
>
</button>
<button
:disabled="localScheduledSongs.length === 0"
class="p-2 bg-zinc-950 border border-zinc-800 hover:bg-zinc-800 text-zinc-500 hover:text-blue-400 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-blue-700 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
@click="showCardGenerator = true"
>
<ImageIcon class="w-3.5 h-3.5" />
<span
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-800 text-[9px] text-zinc-300 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-700"
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
>生成图片</span
>
</button>
<button
:disabled="localScheduledSongs.length === 0"
class="p-2 bg-zinc-950 border border-zinc-800 hover:bg-zinc-800 text-zinc-500 hover:text-emerald-500 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-emerald-500 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
@click="markAllAsPlayed"
>
<CheckCircle2 class="w-3.5 h-3.5" />
<span
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-800 text-[9px] text-zinc-300 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-700"
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
>全部已播放</span
>
</button>
<button
:disabled="localScheduledSongs.length === 0"
class="p-2 bg-zinc-950 border border-zinc-800 hover:bg-zinc-800 text-zinc-500 hover:text-cyan-400 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-cyan-400 rounded-xl transition-all group relative disabled:opacity-50 disabled:cursor-not-allowed"
title="构建播放队列"
@click="confirmBuildPlayQueue"
>
<Music2 class="w-3.5 h-3.5" />
<span
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-800 text-[9px] text-zinc-300 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-700"
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
>构建播放队列</span
>
</button>
<button
class="p-2 bg-zinc-950 border border-zinc-800 hover:bg-zinc-800 text-zinc-500 hover:text-purple-400 rounded-xl transition-all group relative"
class="p-2 bg-white border border-zinc-200 hover:bg-zinc-100 text-zinc-500 hover:text-purple-400 rounded-xl transition-all group relative"
@click="openMoveDateDialog"
>
<ArrowRight class="w-3.5 h-3.5" />
<span
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-800 text-[9px] text-zinc-300 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-700"
class="absolute -top-10 left-1/2 -translate-x-1/2 px-2 py-1 bg-zinc-100 text-[9px] text-zinc-600 rounded opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none whitespace-nowrap border border-zinc-200"
>迁移日期</span
>
</button>
</div>
<div class="h-6 w-[1px] bg-zinc-800 mx-1" />
<div class="h-6 w-[1px] bg-zinc-100 mx-1" />
<button
class="flex items-center gap-1.5 px-3 md:px-4 py-2 bg-yellow-500/10 hover:bg-yellow-500/20 text-yellow-500 text-[10px] md:text-xs font-black rounded-xl border border-yellow-500/20 transition-all uppercase tracking-widest active:scale-95 disabled:opacity-50"
title="一键将当前待排歌曲自动排入"
@@ -445,7 +525,7 @@
ref="sequenceList"
:class="[
'sequence-list flex-1 border-2 border-dashed rounded-[2rem] p-2 md:p-3 min-h-[400px] transition-colors duration-200',
isSequenceOver ? 'border-blue-500 bg-blue-500/5' : 'border-zinc-800/80 bg-zinc-900/20'
isSequenceOver ? 'border-blue-500 bg-blue-500/5' : 'border-zinc-200/80 bg-zinc-50/20'
]"
@dragleave="handleSequenceDragLeave"
@dragover.prevent="handleDragOver"
@@ -465,9 +545,9 @@
v-for="(schedule, index) in localScheduledSongs"
:key="schedule.id"
:class="[
'scheduled-song relative group bg-zinc-900 border border-zinc-800/50 rounded-xl p-3 hover:border-zinc-700 transition-all select-none',
'scheduled-song relative group bg-white border border-zinc-200 rounded-xl p-3 hover:border-zinc-200 transition-all select-none',
dragOverIndex === index ? 'border-t-2 border-t-blue-500' : '',
schedule.isDraft ? 'border-amber-500/30 bg-amber-500/5' : ''
schedule.isDraft ? 'border-amber-200 bg-amber-500/5' : ''
]"
:data-schedule-id="schedule.id"
draggable="true"
@@ -483,17 +563,17 @@
>
<div class="flex items-center gap-3">
<div
class="flex flex-col items-center justify-center w-10 h-10 rounded-lg bg-zinc-950/50 border border-zinc-800 text-zinc-500 font-black text-xs flex-shrink-0"
class="flex flex-col items-center justify-center w-10 h-10 rounded-lg bg-white/70 border border-zinc-200 text-zinc-500 font-black text-xs flex-shrink-0"
>
<span class="text-[8px] text-zinc-600 uppercase leading-none mb-0.5">POS</span>
<span class="text-sm text-zinc-300 leading-none">{{
<span class="text-sm text-zinc-600 leading-none">{{
index + 1 < 10 ? '0' + (index + 1) : index + 1
}}</span>
</div>
<!-- 封面图片 -->
<div
class="relative w-10 h-10 rounded-lg overflow-hidden bg-zinc-800 flex-shrink-0 border border-zinc-700/50 cursor-pointer hover:opacity-80 transition-opacity"
class="relative w-10 h-10 rounded-lg overflow-hidden bg-zinc-100 flex-shrink-0 border border-zinc-200 cursor-pointer hover:opacity-80 transition-opacity"
@click.stop="playSong(schedule.song)"
>
<img
@@ -514,12 +594,12 @@
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
<div class="flex items-center gap-2">
<h4 class="font-bold text-zinc-200 text-sm truncate">
<h4 class="font-bold text-zinc-700 text-sm truncate">
{{ schedule.song.title }}
</h4>
<button
v-if="schedule.song.hasSubmissionNote && schedule.song.submissionNote"
class="inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-all flex-shrink-0"
class="inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-200 bg-blue-500/10 text-blue-700 hover:bg-blue-500/20 transition-all flex-shrink-0"
title="查看备注留言"
@click.stop="openSubmissionRemark(schedule.song)"
>
@@ -528,7 +608,7 @@
<!-- 重播标识 -->
<span
v-if="schedule.song.replayRequestCount > 0"
class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-blue-500/10 text-blue-500 border border-blue-500/20 uppercase tracking-wider flex items-center gap-1"
class="px-1.5 py-0.5 rounded text-[9px] font-bold bg-blue-500/10 text-blue-500 border border-blue-200 uppercase tracking-wider flex items-center gap-1"
title="重播歌曲"
>
<Icon name="repeat" :size="10" />
@@ -602,7 +682,7 @@
</button>
<div
class="p-1.5 rounded-lg bg-zinc-950 border border-zinc-800 text-zinc-600 cursor-grab active:cursor-grabbing hover:text-zinc-400 transition-colors"
class="p-1.5 rounded-lg bg-white border border-zinc-200 text-zinc-600 cursor-grab active:cursor-grabbing hover:text-zinc-400 transition-colors"
>
<MoreVertical class="w-4 h-4" />
</div>
@@ -616,42 +696,42 @@
<!-- 移动端底部操作栏 -->
<div
class="lg:hidden fixed bottom-0 left-0 right-0 z-40 p-3 bg-zinc-950/90 backdrop-blur-xl border-t border-zinc-800 flex items-center gap-3 pb-6"
class="lg:hidden fixed bottom-0 left-0 right-0 z-40 p-3 bg-white/90 backdrop-blur-xl border-t border-zinc-200 flex items-center gap-3 pb-6"
>
<div class="w-[240px] overflow-x-auto scrollbar-hide">
<div class="flex items-center gap-2 w-max">
<button
class="w-11 h-11 shrink-0 bg-zinc-900 border border-zinc-800 text-zinc-400 rounded-xl flex items-center justify-center active:scale-95 transition-all"
class="w-11 h-11 shrink-0 bg-white border border-zinc-200 text-zinc-400 rounded-xl flex items-center justify-center active:scale-95 transition-all"
@click="openDownloadDialog"
>
<Download class="w-5 h-5" />
</button>
<button
class="w-11 h-11 shrink-0 bg-zinc-900 border border-zinc-800 text-zinc-400 rounded-xl flex items-center justify-center active:scale-95 transition-all"
class="w-11 h-11 shrink-0 bg-white border border-zinc-200 text-zinc-400 rounded-xl flex items-center justify-center active:scale-95 transition-all"
@click="showCardGenerator = true"
>
<ImageIcon class="w-5 h-5" />
</button>
<button
class="w-11 h-11 shrink-0 bg-zinc-900 border border-zinc-800 text-zinc-400 rounded-xl flex items-center justify-center active:scale-95 transition-all"
class="w-11 h-11 shrink-0 bg-white border border-zinc-200 text-zinc-400 rounded-xl flex items-center justify-center active:scale-95 transition-all"
@click="saveDraft"
>
<Save class="w-5 h-5" />
</button>
<button
class="w-11 h-11 shrink-0 bg-zinc-900 border border-zinc-800 text-emerald-500 rounded-xl flex items-center justify-center active:scale-95 transition-all"
class="w-11 h-11 shrink-0 bg-white border border-zinc-200 text-emerald-500 rounded-xl flex items-center justify-center active:scale-95 transition-all"
@click="markAllAsPlayed"
>
<CheckCircle2 class="w-5 h-5" />
</button>
<button
class="w-11 h-11 shrink-0 bg-zinc-900 border border-zinc-800 text-purple-400 rounded-xl flex items-center justify-center active:scale-95 transition-all"
class="w-11 h-11 shrink-0 bg-white border border-zinc-200 text-purple-400 rounded-xl flex items-center justify-center active:scale-95 transition-all"
@click="openMoveDateDialog"
>
<ArrowRight class="w-5 h-5" />
</button>
<button
class="w-11 h-11 shrink-0 bg-zinc-900 border border-zinc-800 text-blue-500 rounded-xl flex items-center justify-center active:scale-95 transition-all"
class="w-11 h-11 shrink-0 bg-white border border-zinc-200 text-blue-500 rounded-xl flex items-center justify-center active:scale-95 transition-all"
title="仅发布排期"
@click="publishSchedule"
>
@@ -661,6 +741,15 @@
</div>
<!-- 主要操作 -->
<!-- 清空当天排期:破坏性动作,独立出来(发布按钮不再兼任删除) -->
<button
:disabled="localScheduledSongs.length === 0"
class="shrink-0 w-11 h-11 bg-white border border-red-200 text-red-500 rounded-xl flex items-center justify-center active:scale-95 transition-all disabled:opacity-40 disabled:cursor-not-allowed"
title="删除当天全部排期"
@click="clearTodaySchedule"
>
<Trash2 class="w-5 h-5" />
</button>
<button
:disabled="!hasChanges"
class="flex-1 py-3 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black uppercase tracking-widest rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50 disabled:cursor-not-allowed flex items-center justify-center gap-2"
@@ -705,13 +794,13 @@
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
>
<div
class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden"
class="bg-white border border-zinc-200 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden"
@click.stop
>
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">迁移排期日期</h3>
<div class="flex items-center justify-between p-4 border-b border-zinc-200">
<h3 class="text-sm font-black text-zinc-900 uppercase tracking-widest">迁移排期日期</h3>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
class="text-zinc-500 hover:text-zinc-600 transition-colors"
@click="showMoveDateDialog = false"
>
<CloseIcon class="w-5 h-5" />
@@ -721,12 +810,12 @@
<div class="text-xs text-zinc-500">当前日期:{{ selectedDate }}</div>
<input
v-model="moveTargetDate"
class="w-full bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-3 text-zinc-200 focus:outline-none focus:border-purple-500 transition-colors"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-3 text-zinc-700 focus:outline-none focus:border-purple-500 transition-colors"
type="date"
>
<div class="flex gap-3">
<button
class="flex-1 py-3 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
class="flex-1 py-3 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 text-xs font-bold rounded-xl transition-colors uppercase tracking-wider"
@click="showMoveDateDialog = false"
>
取消
@@ -749,11 +838,11 @@
@click="closeReplayModal"
>
<div
class="bg-zinc-900 border border-zinc-800 rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
class="bg-white border border-zinc-200 rounded-2xl shadow-2xl w-full max-w-md overflow-hidden"
@click.stop
>
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">
<div class="flex items-center justify-between p-4 border-b border-zinc-200">
<h3 class="text-sm font-black text-zinc-900 uppercase tracking-widest">
{{ replayModalTitle }} - 重播申请详情
</h3>
<div class="flex items-center gap-3">
@@ -767,7 +856,7 @@
拒绝申请
</button>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
class="text-zinc-500 hover:text-zinc-600 transition-colors"
@click="closeReplayModal"
>
<CloseIcon class="w-5 h-5" />
@@ -783,12 +872,12 @@
>
<div class="flex items-center gap-3">
<div
class="w-8 h-8 rounded-lg bg-zinc-900 border border-zinc-800 flex items-center justify-center text-zinc-500 group-hover:text-blue-400 transition-colors"
class="w-8 h-8 rounded-lg bg-white border border-zinc-200 flex items-center justify-center text-zinc-500 group-hover:text-blue-700 transition-colors"
>
<User class="w-3.5 h-3.5" />
</div>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{ req.name }}</span>
<span class="text-sm font-bold text-zinc-700">{{ req.name }}</span>
<span v-if="req.grade" class="text-[10px] text-zinc-500"
>{{ req.grade }}{{ req.class ? ` ${req.class}` : '' }}</span
>
@@ -819,7 +908,7 @@
/>
</template>
<script setup>
<script setup lang="ts">
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch, inject } from 'vue'
import {
Search,
@@ -851,7 +940,10 @@ import {
MessageSquare,
Image,
CalendarDays,
Image as ImageIcon
Image as ImageIcon,
Layers,
Inbox,
Trash2
} from '@lucide/vue'
import AdminDatePicker from './AdminDatePicker.vue'
import PlaylistCardGenerator from './PlaylistCardGenerator.vue'
@@ -892,7 +984,9 @@ const defaultAutoConfig: AutoConfig = {
// 响应式数据
const showCardGenerator = ref(false)
const selectedDate = ref(new Date().toISOString().split('T')[0])
const _pad = (n) => n.toString().padStart(2, '0')
const _today = new Date(); _today.setHours(0, 0, 0, 0)
const selectedDate = ref(`${_today.getFullYear()}-${_pad(_today.getMonth() + 1)}-${_pad(_today.getDate())}`)
const loading = ref(false)
const songSortOption = ref<AutoConfig['songSortOption']>('votes-desc')
const hasChanges = ref(false)
@@ -999,6 +1093,9 @@ const localScheduledSongs = ref([])
const replayRequests = ref([])
const scheduledSongIds = ref(new Set())
// 跨日期本地暂存池:保存未保存但分配到其他日期的排期,避免切换日期时丢失
const crossDateLocalPool = ref([])
// 计算是否有未发布的草稿
const hasUnpublishedDrafts = computed(() => {
return localScheduledSongs.value.some((schedule) => schedule.isDraft)
@@ -1033,6 +1130,20 @@ const playTimeOptions = computed(() => {
return options
})
// 当前时段(全天)内所有已排歌曲数(用于对比显示)
const playTimeScheduledCount = computed(() => {
if (!selectedPlayTime.value) return localScheduledSongs.length
try {
const ptId = parseInt(selectedPlayTime.value)
return (playTimes.value || []).reduce((sum, pt) => {
if (pt.id === ptId) return sum
return sum
}, 0)
} catch (e) {
return localScheduledSongs.length
}
})
// 排序选项
const sortOptions = [
{ label: '最新投稿', value: 'time-desc' },
@@ -1595,45 +1706,95 @@ const onSemesterChange = async () => {
// 更新本地排期数据(包括草稿)
const updateLocalScheduledSongs = () => {
// 第一步:把当前 localScheduledSongs 中的本地暂存(isLocalOnly)回收到跨日期池
for (const s of localScheduledSongs.value) {
if (s.isLocalOnly) {
const idx = crossDateLocalPool.value.findIndex(p => p.id === s.id)
if (idx === -1) crossDateLocalPool.value.push(s)
else crossDateLocalPool.value[idx] = s
}
}
// 清理池子里已经在公开排期或草稿中的(避免重复)
crossDateLocalPool.value = crossDateLocalPool.value.filter(p => {
return ![...publicSchedules.value, ...drafts.value].some(s => s.id === p.id || (s.song?.id && p.song?.id && s.song.id === p.song.id))
})
console.log('更新本地排期数据 - 当前日期:', selectedDate.value)
console.log('公开排期数量:', publicSchedules.value.length)
console.log('草稿数量:', drafts.value.length)
console.log('跨日期本地暂存池数量:', crossDateLocalPool.value.length)
// 获取已发布的排期
const _fmtLocal = (d) => {
const x = new Date(d)
return `${x.getFullYear()}-${_pad(x.getMonth() + 1)}-${_pad(x.getDate())}`
}
// 获取已发布的排期
const todaySchedules = publicSchedules.value.filter((s) => {
if (!s.playDate) return false
const scheduleDateStr = new Date(s.playDate).toISOString().split('T')[0]
const scheduleDateStr = _fmtLocal(s.playDate)
return scheduleDateStr === selectedDate.value
})
// 获取草稿排期
const todayDrafts = drafts.value.filter((draft) => {
if (!draft.playDate) return false
const draftDateStr = new Date(draft.playDate).toISOString().split('T')[0]
const draftDateStr = _fmtLocal(draft.playDate)
return draftDateStr === selectedDate.value
})
// 获取本地暂存池中属于当前日期的
const todayLocalOnly = crossDateLocalPool.value.filter((p) => {
return p.playDate === selectedDate.value
})
// 从池中移除今天的,因为接下来要放入 localScheduledSongs
crossDateLocalPool.value = crossDateLocalPool.value.filter(p => p.playDate !== selectedDate.value)
console.log('当天已发布排期:', todaySchedules.length)
console.log('当天草稿排期:', todayDrafts.length)
console.log('当天本地暂存排期:', todayLocalOnly.length)
// 合并已发布和草稿排期
let allSchedules = [...todaySchedules, ...todayDrafts]
// 合并已发布、草稿、本地暂存
let allSchedules = [...todaySchedules, ...todayDrafts, ...todayLocalOnly]
// 如果选择了特定播出时段,进行过滤
if (selectedPlayTime.value) {
allSchedules = allSchedules.filter((s) => s.playTimeId === parseInt(selectedPlayTime.value))
const ptId = parseInt(selectedPlayTime.value)
allSchedules = allSchedules.filter((s) => {
if (!selectedPlayTime.value) return true
if (s.playTimeId === ptId) return true
// 如果未选择时段(为空)则也不显示,除非时段本身就是空
if (s.playTimeId == null && ptId == null) return true
return s.playTimeId === ptId
})
}
// 去重(根据 song.id):保留优先级:已发布 > 草稿 > 本地暂存
const seenIds = new Set()
allSchedules = allSchedules.filter(s => {
const key = s.song?.id || s.songId || s.id
if (!key) return true
if (seenIds.has(key)) return false
seenIds.add(key)
return true
})
// 按 sequence 字段排序
allSchedules.sort((a, b) => (a.sequence || 0) - (b.sequence || 0))
allSchedules.sort((a, b) => {
const sa = a.sequence || 0
const sb = b.sequence || 0
if (sa !== sb) return sa - sb
return (a.id || 0) - (b.id || 0)
})
localScheduledSongs.value = allSchedules.map((s) => ({ ...s }))
console.log('最终显示排期数量:', localScheduledSongs.value.length)
// 更新已排期歌曲ID集合(包括草稿)
// 更新已排期歌曲ID集合(包括草稿、跨日期暂存、公开排期)
const allScheduledSongs = [...publicSchedules.value, ...drafts.value, ...crossDateLocalPool.value, ...localScheduledSongs.value]
scheduledSongIds.value = new Set(
[...publicSchedules.value, ...drafts.value]
allScheduledSongs
.filter((s) => s.song && s.song.id)
.map((s) => s.song.id)
)
@@ -1952,15 +2113,18 @@ const saveSequence = async () => {
// 一键自动排入期望播出的歌曲
const autoScheduleExpected = () => {
let count = 0
const expectedSongs = [...filteredUnscheduledSongs.value]
// 1. 收集所有待排期歌曲(不做严格的日期过滤,根据期望日期自动分配)
const pool = [...allUnscheduledSongs.value]
if (expectedSongs.length === 0) {
if (pool.length === 0) {
showToast('当前没有待排期的歌曲', 'info')
return
}
// 2. 排序(和之前保持一致:按策略)
if (autoScheduleStrategy.value === 'contributor-count') {
expectedSongs.sort((a, b) => {
pool.sort((a, b) => {
const diffA = getSongContributorCount(b) - getSongContributorCount(a)
if (diffA !== 0) return diffA
const seqDiff = (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999)
@@ -1968,21 +2132,64 @@ const autoScheduleExpected = () => {
return (b.voteCount || 0) - (a.voteCount || 0)
})
} else {
expectedSongs.sort((a, b) => (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999))
pool.sort((a, b) => (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999))
}
const baseId = Date.now()
expectedSongs.forEach((song, idx) => {
const filteredPtId = selectedPlayTime.value ? parseInt(selectedPlayTime.value) : null
// 3. 初始化每个日期 × 时段的序列计数器
// 结构: dateStr -> Map(playTimeIdStr -> seq)
const dateSeqCounters = new Map()
const initDateCounters = (dateStr) => {
if (!dateSeqCounters.has(dateStr)) {
const ptMap = new Map()
for (const s of localScheduledSongs.value) {
if (s.playDate !== dateStr) continue
const k = String(s.playTimeId || '')
ptMap.set(k, (ptMap.get(k) || 0) + 1)
}
dateSeqCounters.set(dateStr, ptMap)
}
return dateSeqCounters.get(dateStr)
}
// 4. 处理歌曲,根据期望日期分配 playDate
pool.forEach((song, idx) => {
// 4.1 确定播出日期:优先期望日期,否则用当前 selectedDate
let targetPlayDate = (song.expectedPlayDate && String(song.expectedPlayDate).trim()) || selectedDate.value
// 4.2 确定播出时段:优先期望时段,其次是当前选中的时段视图
let targetPlayTimeId = null
if (song.preferredPlayTimeId != null) {
const matched = (playTimes.value || []).find(pt => String(pt.id) === String(song.preferredPlayTimeId))
if (matched) targetPlayTimeId = matched.id
}
if (targetPlayTimeId == null && filteredPtId != null) {
targetPlayTimeId = filteredPtId
}
// 4.3 如果处于特定时段视图,且该歌曲的时段不匹配(且非空),则跳过
if (filteredPtId != null && targetPlayTimeId != null && String(targetPlayTimeId) !== String(filteredPtId)) {
return
}
// 4.4 检查是否已经存在于已排期列表中
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song?.id === song.id)
if (existingIndex !== -1) return
const playTimeId = selectedPlayTime.value ? parseInt(selectedPlayTime.value) : null
// 4.5 获取该日期下的计数器
const ptSeq = initDateCounters(targetPlayDate)
const ptKey = String(targetPlayTimeId || '')
const nextSeq = (ptSeq.get(ptKey) || 0) + 1
ptSeq.set(ptKey, nextSeq)
const newSchedule = {
id: baseId + idx,
song: song,
playDate: selectedDate.value,
sequence: localScheduledSongs.value.length + 1,
playTimeId,
playDate: targetPlayDate,
sequence: nextSeq,
playTimeId: targetPlayTimeId,
isNew: true,
isLocalOnly: true
}
@@ -1991,14 +2198,42 @@ const autoScheduleExpected = () => {
count++
})
localScheduledSongs.value.forEach((item, idx) => {
item.sequence = idx + 1
})
// 5. 按 playDate + playTime 顺序重排 sequence
const ptOrder = (id) => {
if (id == null || id === '') return 1e9
const pt = (playTimes.value || []).find(p => p.id === id)
if (!pt || !pt.startTime) return 1e8
const [h, m] = pt.startTime.split(':').map(Number)
return h * 60 + (m || 0)
}
// 5.1 按日期分组后再排序
const dateGroups = new Map()
for (const s of localScheduledSongs.value) {
const key = String(s.playDate || '')
if (!dateGroups.has(key)) dateGroups.set(key, [])
dateGroups.get(key).push(s)
}
const allSorted = []
const sortedDates = [...dateGroups.keys()].sort()
for (const d of sortedDates) {
const group = dateGroups.get(d)
group.sort((a, b) => {
const po = ptOrder(a.playTimeId) - ptOrder(b.playTimeId)
if (po !== 0) return po
return (a.sequence || 0) - (b.sequence || 0)
})
group.forEach((item, idx) => { item.sequence = idx + 1 })
allSorted.push(...group)
}
localScheduledSongs.value = allSorted
hasChanges.value = true
const strategyLabel =
autoScheduleStrategy.value === 'contributor-count' ? '(按投稿人数优先)' : ''
showToast(`已自动排入 ${count} 首待排歌曲${strategyLabel},请注意调整顺序后保存`, 'success')
const byPref = pool.filter(s => s.preferredPlayTimeId || s.expectedPlayDate).length
const prefInfo = byPref ? `,其中 ${byPref} 首按期望日期/时段排入` : ''
showToast(`已自动排入 ${count} 首待排歌曲${strategyLabel}${prefInfo},请注意调整顺序后保存`, 'success')
}
// 构建播放队列
@@ -2114,7 +2349,7 @@ const confirmMoveDate = async () => {
const sourceDate = selectedDate.value
const sourceSchedules = [...publicSchedules.value, ...drafts.value].filter((schedule) => {
if (!schedule.playDate) return false
return new Date(schedule.playDate).toISOString().split('T')[0] === sourceDate
return _fmtLocal(schedule.playDate) === sourceDate
})
if (sourceSchedules.length === 0) {
@@ -2279,21 +2514,26 @@ watch(
)
// 发布排期(需要确认)
//
// ⚠️ 这里**只管发布,绝不删除**。
//
// 以前为了「清空某天排期」把删除偷偷挂在发布按钮上:列表为空时点发布 → 弹「删除排期」→
// 调 bulk-publish 传空数组 → 服务端先删光该日期(含草稿)再插入 0 条。
// 于是只要**界面上的列表是空的**(数据还没加载回来、时段筛选没匹配上、切换日期后没刷新完…),
// 点「发布」就等于清空当天 —— 按钮叫发布,干的是删除。删除已经拆到
// clearTodaySchedule(),发布路径不再有任何破坏性分支。
const publishSchedule = async () => {
try {
// 如果列表为空,提示删除排期
if (localScheduledSongs.value.length === 0) {
confirmDialogTitle.value = '删除排期'
confirmDialogMessage.value = '确定要删除当天的所有排期吗?此操作不可恢复。'
confirmDialogType.value = 'danger'
confirmDialogConfirmText.value = '确认删除'
} else {
confirmDialogTitle.value = '发布排期'
confirmDialogMessage.value = '确定要发布当前排期吗?发布后将立即公示并发送通知。'
confirmDialogType.value = 'warning'
confirmDialogConfirmText.value = '发布排期'
showToast('当前列表没有歌曲,没有需要发布的内容', 'info')
return
}
confirmDialogTitle.value = '发布排期'
confirmDialogMessage.value = '确定要发布当前排期吗?发布后将立即公示并发送通知。'
confirmDialogType.value = 'warning'
confirmDialogConfirmText.value = '发布排期'
confirmAction.value = async () => {
await publishScheduleConfirmed()
}
@@ -2303,6 +2543,56 @@ const publishSchedule = async () => {
}
}
/**
* 清空当天的排期(含草稿)—— 独立的显式操作,跟「发布」彻底分开。
* 服务端 bulk-publish 收到空 songs 数组时会先删该日期全部排期再插入 0 条(即清空),
* 所以「删除」这个破坏性动作必须由调用方明确要求,见 bulk-publish.post.ts 里的 confirmClear。
*/
const clearTodaySchedule = () => {
if (localScheduledSongs.value.length === 0) {
showToast('当天本来就没有排期', 'info')
return
}
confirmDialogTitle.value = '删除当天排期'
confirmDialogMessage.value = `确定要删除 ${selectedDate.value} 的全部排期吗?已发布的排期和草稿都会一起删除,此操作不可恢复。`
confirmDialogType.value = 'danger'
confirmDialogConfirmText.value = '确认删除'
confirmAction.value = async () => {
loading.value = true
try {
await $fetch('/api/admin/schedule/bulk-publish', {
method: 'POST',
body: {
playDate: selectedDate.value,
playTimeId: selectedPlayTime.value ? parseInt(selectedPlayTime.value) : null,
songs: [],
confirmClear: true // 明确表示「我就是要清空」,缺了它服务端会拒(防止误清)
},
...auth.getAuthConfig()
})
hasChanges.value = false
await loadData()
updateLocalScheduledSongs()
if (window.$showNotification) {
window.$showNotification('当天排期已删除', 'success')
}
} catch (error) {
console.error('删除当天排期失败:', error)
if (window.$showNotification) {
window.$showNotification('删除失败: ' + (error.data?.message || error.message), 'error')
}
} finally {
loading.value = false
}
}
showConfirmDialog.value = true
}
// 确认发布排期
const publishScheduleConfirmed = async () => {
loading.value = true
+42 -42
View File
@@ -3,14 +3,14 @@
class="flex flex-col space-y-8 pb-12 lg:pb-20 animate-in fade-in slide-in-from-bottom-4 duration-500"
>
<div class="flex flex-col space-y-2">
<h2 class="text-2xl font-bold tracking-tight text-zinc-100">打印排期</h2>
<h2 class="text-2xl font-bold tracking-tight text-zinc-900">打印排期</h2>
<p class="text-sm text-zinc-500">自定义打印设置,预览并导出排期表</p>
</div>
<!-- 权限检查 -->
<div
v-if="!canPrintSchedule"
class="flex flex-col items-center justify-center py-20 bg-zinc-900/50 rounded-xl border border-zinc-800"
class="flex flex-col items-center justify-center py-20 bg-zinc-50/50 rounded-xl border border-zinc-200"
>
<div
class="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center text-red-500 mb-4"
@@ -21,7 +21,7 @@
<path d="m9 9 6 6" />
</svg>
</div>
<h3 class="text-lg font-bold text-zinc-100">权限不足</h3>
<h3 class="text-lg font-bold text-zinc-900">权限不足</h3>
<p class="text-zinc-500 mt-2">您没有打印排期的权限,请联系管理员获取相应权限。</p>
</div>
@@ -29,9 +29,9 @@
<!-- 设置面板 -->
<div class="lg:col-span-4 flex flex-col gap-6 h-auto">
<div
class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 space-y-6 shadow-lg flex flex-col h-auto"
class="bg-zinc-50/40 border border-zinc-200 rounded-xl p-6 space-y-6 shadow-lg flex flex-col h-auto"
>
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-100 shrink-0">
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-900 shrink-0">
<Layout class="w-4 h-4 text-blue-500" /> 打印设置
</h3>
@@ -59,8 +59,8 @@
:class="[
'py-2.5 rounded-lg text-sm font-bold transition-all',
settings.orientation === 'portrait'
? 'border border-blue-500/30 bg-blue-600/10 text-blue-400 shadow-sm'
: 'border border-zinc-800 bg-zinc-950 text-zinc-500 hover:text-zinc-300'
? 'border border-blue-200 bg-blue-50 text-blue-700 shadow-sm'
: 'border border-zinc-200 bg-white text-zinc-500 hover:text-zinc-600'
]"
@click="settings.orientation = 'portrait'"
>
@@ -70,8 +70,8 @@
:class="[
'py-2.5 rounded-lg text-sm font-bold transition-all',
settings.orientation === 'landscape'
? 'border border-blue-500/30 bg-blue-600/10 text-blue-400 shadow-sm'
: 'border border-zinc-800 bg-zinc-950 text-zinc-500 hover:text-zinc-300'
? 'border border-blue-200 bg-blue-50 text-blue-700 shadow-sm'
: 'border border-zinc-200 bg-white text-zinc-500 hover:text-zinc-600'
]"
@click="settings.orientation = 'landscape'"
>
@@ -89,13 +89,13 @@
<input
v-model="settings.startDate"
type="date"
class="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50 transition-colors"
class="bg-white border border-zinc-200 rounded-lg px-3 py-2 text-sm text-zinc-600 focus:outline-none focus:border-blue-500/50 transition-colors"
max="9999-12-31"
>
<input
v-model="settings.endDate"
type="date"
class="bg-zinc-950 border border-zinc-800 rounded-lg px-3 py-2 text-sm text-zinc-300 focus:outline-none focus:border-blue-500/50 transition-colors"
class="bg-white border border-zinc-200 rounded-lg px-3 py-2 text-sm text-zinc-600 focus:outline-none focus:border-blue-500/50 transition-colors"
max="9999-12-31"
>
</div>
@@ -103,7 +103,7 @@
<button
v-for="range in dateRanges"
:key="range.value"
class="px-2 py-1 bg-zinc-800/50 hover:bg-zinc-800 text-[10px] text-zinc-400 rounded-md transition-colors border border-zinc-800"
class="px-2 py-1 bg-zinc-100/50 hover:bg-zinc-100 text-[10px] text-zinc-400 rounded-md transition-colors border border-zinc-200"
@click="setDateRange(range.value)"
>
{{ range.label }}
@@ -127,7 +127,7 @@
'w-5 h-5 rounded flex items-center justify-center border transition-all',
settings[option.key]
? 'bg-blue-600 border-blue-600'
: 'bg-zinc-950 border-zinc-800 group-hover:border-zinc-700'
: 'bg-white border-zinc-200 group-hover:border-zinc-200'
]"
>
<CheckCircle2 v-if="settings[option.key]" class="w-3 h-3 text-white" />
@@ -137,7 +137,7 @@
:class="[
'text-sm font-medium transition-colors',
settings[option.key]
? 'text-zinc-200'
? 'text-zinc-700'
: 'text-zinc-500 group-hover:text-zinc-400'
]"
>{{ option.label }}</span
@@ -153,7 +153,7 @@
'w-5 h-5 rounded flex items-center justify-center border transition-all',
settings.showSchoolLogo
? 'bg-blue-600 border-blue-600'
: 'bg-zinc-950 border-zinc-800 group-hover:border-zinc-700'
: 'bg-white border-zinc-200 group-hover:border-zinc-200'
]"
>
<CheckCircle2 v-if="settings.showSchoolLogo" class="w-3 h-3 text-white" />
@@ -163,7 +163,7 @@
:class="[
'text-sm font-medium transition-colors',
settings.showSchoolLogo
? 'text-zinc-200'
? 'text-zinc-700'
: 'text-zinc-500 group-hover:text-zinc-400'
]"
>学校Logo</span
@@ -180,15 +180,15 @@
<textarea
v-model="settings.remark"
placeholder="请输入备注信息(可选)"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm focus:outline-none text-zinc-300 min-h-[80px] resize-none focus:border-blue-500/50 transition-colors"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm focus:outline-none text-zinc-600 min-h-[80px] resize-none focus:border-blue-500/50 transition-colors"
/>
</div>
</div>
<!-- 操作按钮 -->
<div class="space-y-2 pt-4 border-t border-zinc-800 shrink-0">
<div class="space-y-2 pt-4 border-t border-zinc-200 shrink-0">
<button
class="w-full flex items-center justify-center gap-2 py-3 bg-zinc-800/80 hover:bg-zinc-700 text-zinc-300 text-sm font-bold rounded-lg border border-zinc-700 transition-all"
class="w-full flex items-center justify-center gap-2 py-3 bg-zinc-100/80 hover:bg-zinc-200 text-zinc-600 text-sm font-bold rounded-lg border border-zinc-200 transition-all"
@click="refreshPreview"
>
<RefreshCw class="w-4 h-4" /> 刷新预览
@@ -223,10 +223,10 @@
<!-- 预览区域 -->
<div class="lg:col-span-8 flex flex-col gap-4 h-[700px] lg:h-0 lg:min-h-full mb-8 lg:mb-0">
<div
class="bg-zinc-900/40 border border-zinc-800 rounded-xl overflow-hidden flex flex-col h-full shadow-lg"
class="bg-zinc-50/40 border border-zinc-200 rounded-xl overflow-hidden flex flex-col h-full shadow-lg"
>
<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 text-zinc-100">
<div class="px-6 py-5 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-900">
<AlignLeft class="w-4.5 h-4.5 text-zinc-500" /> 预览区域
</h3>
<div class="flex items-center gap-2 text-xs font-bold">
@@ -245,7 +245,7 @@
</div>
<div
class="flex-1 bg-zinc-950/50 p-6 md:p-12 overflow-auto custom-scrollbar flex items-start"
class="flex-1 bg-white/70 p-6 md:p-12 overflow-auto custom-scrollbar flex items-start"
>
<!-- 纸张预览 -->
<div
@@ -260,7 +260,7 @@
<!-- 页面头部 -->
<div class="page-header">
<div class="logo-section">
<img :src="logoUrl" alt="VoiceHub Logo" class="logo" >
<img :src="logoUrl" alt="宝一外校园点歌系统 Logo" class="logo" >
<!-- 竖线分割 -->
<div class="logo-divider" />
<!-- 学校logo -->
@@ -356,7 +356,7 @@
>备注:{{ settings.remark }}</span
>
</div>
<span class="footer-right">Generated by VoiceHub | 广播管理系统</span>
<span class="footer-right">Generated by 宝一外校园点歌系统 | 广播管理系统</span>
</div>
</div>
</div>
@@ -1383,15 +1383,15 @@ watch(
/* 预览区域和打印样式的核心CSS - 保持原生CSS以确保精确控制 */
.preview-content {
background: #ffffff;
background: #ffffff !important;
padding: 0;
position: relative;
}
/* 打印页面样式 */
.print-page {
background: #ffffff;
color: #000000;
background: #ffffff !important;
color: #000000 !important;
width: 100%;
margin: 0 auto;
padding: 30px;
@@ -1426,7 +1426,7 @@ watch(
.logo-divider {
width: 2px;
height: 60px;
background: linear-gradient(to bottom, #ddd, #999, #ddd);
background: linear-gradient(to bottom, var(--border-secondary, #dddddd), #999, #ddd);
border-radius: 1px;
margin: 0 4px;
}
@@ -1445,19 +1445,19 @@ watch(
font-size: 24px;
font-weight: normal;
margin: 0 0 4px 0;
color: #000;
color: #000000 !important;
}
.title-section h2 {
font-size: 18px;
font-weight: 500;
margin: 0;
color: #666;
color: #666666 !important;
}
.date-info {
font-size: 14px;
color: #666;
color: #666666 !important;
text-align: right;
display: flex;
align-items: flex-start;
@@ -1477,14 +1477,14 @@ watch(
.no-data-message {
text-align: center;
padding: 60px 20px;
color: #666;
color: var(--text-quaternary, #666666);
}
.no-data-icon {
width: 64px;
height: 64px;
margin: 0 auto 20px;
color: #ccc;
color: var(--text-secondary, #cccccc);
}
.no-data-icon svg {
@@ -1496,7 +1496,7 @@ watch(
font-size: 18px;
font-weight: 500;
margin: 0 0 12px 0;
color: #333;
color: var(--text-quaternary, #333333);
}
.no-data-message p {
@@ -1512,15 +1512,15 @@ watch(
font-weight: bold;
margin: 24px 0 12px 0;
padding: 8px 12px;
border-bottom: 2px solid #ddd;
border-bottom: 2px solid var(--border-secondary, #ddd);
background: #f8f9fa;
color: #333;
color: var(--text-quaternary, #333333);
border-radius: 4px 4px 0 0;
}
.playtime-group .group-title {
background: #e3f2fd;
border-bottom-color: #2196f3;
border-bottom-color: var(--border-secondary, #2196f3);
color: #1565c0;
}
@@ -1533,7 +1533,7 @@ watch(
.group-count {
font-size: 14px;
font-weight: normal;
color: #666;
color: var(--text-quaternary, #666666);
margin-left: 8px;
}
@@ -1547,7 +1547,7 @@ watch(
margin: 16px 0 8px 0;
padding: 6px 10px;
background: #f0f8ff;
border-left: 3px solid #2196f3;
border-left: 3px solid var(--border-secondary, #2196f3);
color: #1565c0;
border-radius: 0 4px 4px 0;
}
@@ -1555,7 +1555,7 @@ watch(
.playtime-count {
font-size: 12px;
font-weight: normal;
color: #666;
color: var(--text-quaternary, #666666);
margin-left: 6px;
}
@@ -1570,7 +1570,7 @@ watch(
padding-top: 16px;
border-top: 1px solid #e5e5e5;
font-size: 12px;
color: #666;
color: var(--text-quaternary, #666666);
}
.footer-left {
+18 -18
View File
@@ -3,7 +3,7 @@
<!-- 顶部标题栏 -->
<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>
<h2 class="text-2xl font-black text-zinc-900 tracking-tight">学期管理</h2>
<p class="text-xs text-zinc-500 mt-1">
定义系统的教学周期,点歌、排期及统计数据将基于当前活跃学期进行归档
</p>
@@ -134,7 +134,7 @@
<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"
class="bg-zinc-50/30 border border-zinc-200 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"
@@ -158,7 +158,7 @@
</button>
</div>
<div class="p-6 bg-zinc-900/20 border border-zinc-800 rounded-3xl flex items-start gap-4">
<div class="p-6 bg-zinc-50/20 border border-zinc-200 rounded-3xl flex items-start gap-4">
<svg
xmlns="http://www.w3.org/2000/svg"
width="18"
@@ -214,7 +214,7 @@
<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]"
class="text-center py-20 bg-zinc-50/10 border border-zinc-200 border-dashed rounded-[2rem]"
>
<p class="text-xs font-bold text-zinc-600">加载中...</p>
</div>
@@ -225,8 +225,8 @@
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'
? 'bg-blue-600/5 border-blue-200'
: 'bg-zinc-50/40 border-zinc-200 hover:border-zinc-200'
"
>
<div class="flex items-center gap-5">
@@ -235,7 +235,7 @@
:class="
sem.isActive
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
: 'bg-zinc-800 text-zinc-600 border border-zinc-700'
: 'bg-zinc-100 text-zinc-600 border border-zinc-200'
"
>
<svg
@@ -258,7 +258,7 @@
<h5
class="font-bold transition-colors"
:class="
sem.isActive ? 'text-blue-400' : 'text-zinc-200 group-hover:text-blue-400'
sem.isActive ? 'text-blue-700' : 'text-zinc-700 group-hover:text-blue-700'
"
>
{{ sem.name }}
@@ -293,7 +293,7 @@
<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"
class="flex items-center gap-2 px-4 py-2 bg-zinc-100 hover:bg-zinc-200 text-zinc-400 hover:text-zinc-900 text-[10px] font-black rounded-xl border border-zinc-200 transition-all uppercase tracking-widest disabled:opacity-50 disabled:cursor-not-allowed"
@click="setActive(sem.id)"
>
<svg
@@ -342,7 +342,7 @@
<div
v-if="semesters.length === 0 && !loading"
class="text-center py-20 bg-zinc-900/10 border border-zinc-800 border-dashed rounded-[2rem]"
class="text-center py-20 bg-zinc-50/10 border border-zinc-200 border-dashed rounded-[2rem]"
>
<p class="text-xs font-bold text-zinc-600">暂无学期数据</p>
</div>
@@ -356,14 +356,14 @@
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"
class="w-full max-w-md bg-white rounded-3xl border border-zinc-200 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"
class="p-2 text-zinc-500 hover:text-zinc-900 transition-colors"
@click="closeAddModal"
>
<svg
@@ -392,7 +392,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-2xl px-5 py-4 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 font-bold transition-all"
>
</div>
@@ -400,11 +400,11 @@
<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"
class="w-4.5 h-4.5 rounded-lg border-zinc-200 bg-white accent-blue-600 transition-all"
>
<div>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-blue-400 transition-colors"
class="text-xs font-bold text-zinc-600 group-hover:text-blue-700 transition-colors"
>设为当前活跃学期</span
>
<p class="text-[10px] text-zinc-600 font-medium">
@@ -413,7 +413,7 @@
</div>
</label>
<div class="p-4 bg-zinc-950 border border-zinc-800 rounded-2xl flex items-start gap-3">
<div class="p-4 bg-white border border-zinc-200 rounded-2xl flex items-start gap-3">
<svg
xmlns="http://www.w3.org/2000/svg"
width="14"
@@ -437,9 +437,9 @@
</div>
</div>
<div class="p-6 bg-zinc-800/50 border-t border-zinc-800 flex gap-3 justify-end">
<div class="p-6 bg-zinc-100/50 border-t border-zinc-200 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"
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-600 transition-colors"
@click="closeAddModal"
>
取消
+15 -15
View File
@@ -2,7 +2,7 @@
<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',
'fixed inset-y-0 left-0 z-50 w-64 bg-[#ffffff] border-r border-zinc-200 shadow-sm transform transition-transform duration-300 ease-in-out lg:translate-x-0',
isOpen ? 'translate-x-0' : '-translate-x-full'
]"
>
@@ -14,7 +14,7 @@
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
<img
:src="logo"
alt="VoiceHub Logo"
alt="宝一外校园点歌系统 Logo"
class="w-8 h-8 object-contain"
width="32"
height="32"
@@ -24,7 +24,7 @@
</div>
<!-- 品牌文字 -->
<div class="flex flex-col justify-center">
<h1 class="font-bold text-lg text-zinc-100 leading-none tracking-tight">VoiceHub</h1>
<h1 class="font-bold text-lg text-zinc-900 leading-none tracking-tight">宝一外校园点歌系统</h1>
<p
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
>
@@ -49,8 +49,8 @@
: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',
? 'bg-blue-50 text-blue-700 border-blue-200'
: 'text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 border-transparent',
getTourClass(item.id)
]"
@click="onNavigate(item.id)"
@@ -61,8 +61,8 @@
:size="18"
:class="
activeTab === item.id
? 'text-blue-400'
: 'text-zinc-500 group-hover:text-zinc-300'
? 'text-blue-700'
: 'text-zinc-500 group-hover:text-zinc-700'
"
/>
<span class="truncate">{{ item.label }}</span>
@@ -78,26 +78,26 @@
</nav>
<!-- 用户信息及退出登录 -->
<div class="mt-4 pt-4 border-t border-zinc-800">
<div class="mt-4 pt-4 border-t border-zinc-200">
<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"
class="flex items-center gap-3 p-3 rounded-lg bg-zinc-50 border border-zinc-200 hover:bg-zinc-100 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"
class="w-10 h-10 rounded-lg object-cover border border-zinc-200 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"
class="w-10 h-10 rounded-lg bg-zinc-100 flex items-center justify-center text-zinc-600 font-bold border border-zinc-200 shrink-0"
>
{{ (currentUser?.name || '管').charAt(0) }}
</div>
<!-- 用户详细信息 -->
<div class="flex-1 min-w-0">
<p class="text-xs font-black truncate text-zinc-100">
<p class="text-xs font-black truncate text-zinc-900">
{{ currentUser?.name || '管理员' }}
</p>
<p
@@ -108,7 +108,7 @@
</div>
<!-- 退出按钮 -->
<button
class="p-2 text-zinc-600 hover:text-red-400 hover:bg-red-400/10 rounded-lg transition-all"
class="p-2 text-zinc-500 hover:text-red-600 hover:bg-red-50 rounded-lg transition-all"
title="退出登录"
@click="$emit('logout')"
>
@@ -190,7 +190,7 @@ const menuGroups = [
{ icon: Music2, label: '歌曲管理', id: 'songs' },
{ icon: CalendarDays, label: '排期管理', id: 'schedule' },
{ icon: Printer, label: '打印排期', id: 'print' },
{ icon: ImageIcon, label: '图片歌单', id: 'playlist-card' }
{ icon: ImageIcon, label: '导入酷狗', id: 'playlist-card' }
]
},
{
@@ -279,7 +279,7 @@ const getRoleDisplayName = (role) => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+34 -34
View File
@@ -3,7 +3,7 @@
<!-- 顶部标题栏 -->
<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>
<h2 class="text-2xl font-black text-zinc-900 tracking-tight">站点配置</h2>
<p class="text-xs text-zinc-500 mt-1 font-medium">
管理站点全局属性、视觉识别、点歌逻辑及系统安全策略
</p>
@@ -11,7 +11,7 @@
<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"
class="flex items-center gap-2 px-5 py-2 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50 disabled:cursor-not-allowed"
@click="resetForm"
>
<RotateCcw :size="14" /> 重置
@@ -35,7 +35,7 @@
<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"
class="w-8 h-8 border-4 border-zinc-200 border-t-blue-500 rounded-full animate-spin mb-4"
/>
<p class="text-zinc-500 text-sm">加载配置中...</p>
</div>
@@ -44,7 +44,7 @@
<!-- 基础信息 -->
<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"
class="text-sm font-black text-zinc-900 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-200 pb-4"
>
<Globe :size="16" class="text-blue-500" /> 基础信息
</h3>
@@ -100,7 +100,7 @@
<!-- 视觉识别 -->
<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"
class="text-sm font-black text-zinc-900 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-200 pb-4"
>
<ImageIcon :size="16" class="text-purple-500" /> 视觉识别
</h3>
@@ -137,10 +137,10 @@
<!-- 投稿须知 -->
<section
class="lg:col-span-2 bg-zinc-900/40 border border-zinc-800 rounded-2xl p-6 space-y-6"
class="lg:col-span-2 bg-zinc-50/40 border border-zinc-200 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"
class="text-sm font-black text-zinc-900 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-200 pb-4"
>
<FileText :size="16" class="text-emerald-500" /> 投稿须知
</h3>
@@ -155,75 +155,75 @@
<!-- 投稿逻辑设置 -->
<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"
class="text-sm font-black text-zinc-900 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-200 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"
class="flex items-center justify-between p-3 bg-white/70 border border-zinc-200 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用联合投稿</p>
<p class="text-xs font-bold text-zinc-700">启用联合投稿</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"
class="w-5 h-5 rounded border-zinc-200 bg-white 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"
class="flex items-center justify-between p-3 bg-white/70 border border-zinc-200 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用投稿备注留言</p>
<p class="text-xs font-bold text-zinc-700">启用投稿备注留言</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"
class="w-5 h-5 rounded border-zinc-200 bg-white 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"
class="flex items-center justify-between p-3 bg-white/70 border border-zinc-200 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用重播申请</p>
<p class="text-xs font-bold text-zinc-700">启用重播申请</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"
class="w-5 h-5 rounded border-zinc-200 bg-white 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"
class="flex items-center justify-between p-3 bg-white/70 border border-zinc-200 rounded-xl"
>
<div>
<p class="text-xs font-bold text-zinc-200">启用投稿限额</p>
<p class="text-xs font-bold text-zinc-700">启用投稿限额</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"
class="w-5 h-5 rounded border-zinc-200 bg-white 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">
<div class="grid grid-cols-3 gap-2 p-1 bg-white border border-zinc-200 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'
? 'bg-zinc-100 text-blue-700 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('daily')"
@@ -234,7 +234,7 @@
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'weekly'
? 'bg-zinc-800 text-blue-400 shadow-sm'
? 'bg-zinc-100 text-blue-700 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('weekly')"
@@ -245,7 +245,7 @@
:class="[
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
currentLimitType === 'monthly'
? 'bg-zinc-800 text-blue-400 shadow-sm'
? 'bg-zinc-100 text-blue-700 shadow-sm'
: 'text-zinc-600 hover:text-zinc-400'
]"
@click="handleLimitTypeChange('monthly')"
@@ -279,23 +279,23 @@
<!-- 安全与隐私设置 -->
<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"
class="text-sm font-black text-zinc-900 uppercase tracking-widest flex items-center gap-2 border-b border-zinc-200 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="p-4 bg-white/70 border border-zinc-200 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"
class="w-4 h-4 rounded border-zinc-200 bg-white 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-xs font-bold text-zinc-700">显示黑名单具体关键词</p>
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
开启后,在投稿命中黑名单时将明确提示冲突关键词;关闭则仅提示“包含关键词”。
</p>
@@ -303,18 +303,18 @@
</div>
</div>
<div class="p-4 bg-zinc-950/50 border border-zinc-800 rounded-xl space-y-4">
<div class="p-4 bg-white/70 border border-zinc-200 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"
class="w-4 h-4 rounded border-zinc-200 bg-white 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-xs font-bold text-zinc-700">隐藏学生详细信息</p>
<p class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
开启后,非管理员用户在前端点歌列表、排期预览中将无法查看投稿学生的完整学号与真实姓名。
</p>
@@ -359,9 +359,9 @@ 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'
'w-full bg-white border border-zinc-200 rounded-xl px-4 py-2.5 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all placeholder:text-zinc-500'
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 cardClass = 'bg-zinc-50/40 border border-zinc-200 rounded-2xl p-6 shadow-xl space-y-6'
const defaultSubmissionGuidelines = `1. 投稿时无需加入书名号
2. 除DJ外,其他类型歌曲均接收(包括小语种)
@@ -467,7 +467,7 @@ const saveConfig = async () => {
saving.value = true
const configToSave = {
...formData.value,
siteTitle: (formData.value.siteTitle || '').trim() || '校园广播站点歌系统',
siteTitle: (formData.value.siteTitle || '').trim() || '宝一外校园点歌系统',
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/images/site-logo.png',
siteVersion: (formData.value.siteVersion || '').trim() || null,
submissionGuidelines:
+15 -15
View File
@@ -3,14 +3,14 @@
<!-- 头部区域 -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">邮件服务配置</h2>
<h2 class="text-2xl font-black text-zinc-900 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"
class="flex items-center gap-2 px-6 py-2 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-600 text-xs font-bold rounded-xl transition-all active:scale-95 disabled:opacity-50"
:disabled="reloading"
@click="reloadSmtpConfig"
>
@@ -31,16 +31,16 @@
<!-- 左侧栏: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">
<section class="bg-zinc-50/30 border border-zinc-200 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"
class="text-sm font-black text-zinc-900 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'"
:class="config.smtpEnabled ? 'bg-blue-600' : 'bg-zinc-100'"
@click="config.smtpEnabled = !config.smtpEnabled"
>
<div
@@ -59,7 +59,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-2.5 text-xs text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
<div class="grid grid-cols-2 gap-3">
@@ -71,7 +71,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-2.5 text-xs text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
<div class="space-y-1.5">
@@ -94,7 +94,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-2.5 text-xs text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
<div class="space-y-1.5">
@@ -105,7 +105,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-2.5 text-xs text-zinc-700 focus:outline-none focus:border-blue-200"
>
<p class="text-[9px] text-zinc-500 px-1 italic">
通常与发件人账号一致,部分服务商强制要求一致。
@@ -119,7 +119,7 @@
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"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-2.5 text-xs text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
<div class="space-y-1.5">
@@ -130,16 +130,16 @@
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"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-2.5 text-xs text-zinc-700 focus:outline-none focus:border-blue-200"
>
</div>
</div>
</section>
<!-- SMTP 测试模块 -->
<section class="bg-zinc-900/30 border border-zinc-800 rounded-[2rem] p-6 space-y-6">
<section class="bg-zinc-50/30 border border-zinc-200 rounded-[2rem] p-6 space-y-6">
<h3
class="text-sm font-black text-zinc-100 uppercase tracking-widest flex items-center gap-2"
class="text-sm font-black text-zinc-900 uppercase tracking-widest flex items-center gap-2"
>
<Send :size="16" class="text-emerald-500" /> 服务测试
</h3>
@@ -154,10 +154,10 @@
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"
class="flex-1 bg-white border border-zinc-200 rounded-xl px-4 py-2.5 text-xs text-zinc-700 focus:outline-none focus:border-blue-200"
>
<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"
class="flex items-center gap-2 px-4 py-2 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-400 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
:disabled="testing || !testEmail || !config.smtpEnabled"
@click="sendTestEmail"
>
+21 -21
View File
@@ -3,7 +3,7 @@
<div class="space-y-6">
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 px-1">
<div class="space-y-1">
<h2 class="text-3xl font-black text-zinc-100 tracking-tight">歌曲审核</h2>
<h2 class="text-3xl font-black text-zinc-900 tracking-tight">歌曲审核</h2>
<div class="flex items-center gap-6 mt-4">
<div class="flex items-center gap-2">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">待审核</span>
@@ -15,7 +15,7 @@
<div class="flex items-center gap-3">
<button
:disabled="loading"
class="flex items-center gap-2 px-5 py-2.5 bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 text-xs font-bold rounded-lg border border-blue-500/20 transition-all disabled:opacity-50"
class="flex items-center gap-2 px-5 py-2.5 bg-blue-50 hover:bg-blue-600/20 text-blue-700 text-xs font-bold rounded-lg border border-blue-200 transition-all disabled:opacity-50"
@click="refreshSongs(true)"
>
<RotateCcw :size="16" :class="{ 'animate-spin': loading }" /> 刷新
@@ -25,15 +25,15 @@
</div>
<!-- 歌曲表格 -->
<div class="bg-zinc-900/10 border border-zinc-800/40 rounded-xl overflow-hidden shadow-2xl relative">
<div v-if="loading" class="absolute inset-0 bg-zinc-950/40 backdrop-blur-sm z-10 flex items-center justify-center">
<div class="bg-zinc-50/10 border border-zinc-200 rounded-xl overflow-hidden shadow-2xl relative">
<div v-if="loading" class="absolute inset-0 bg-white/80 backdrop-blur-sm z-10 flex items-center justify-center">
<div class="flex flex-col items-center gap-3">
<div class="w-8 h-8 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin" />
<div class="w-8 h-8 border-2 border-blue-200 border-t-blue-500 rounded-full animate-spin" />
<span class="text-xs font-bold text-zinc-400 tracking-widest uppercase">正在加载...</span>
</div>
</div>
<div class="hidden lg:grid grid-cols-12 gap-4 px-8 py-5 border-b border-zinc-800/60 bg-zinc-900/40">
<div class="hidden lg:grid grid-cols-12 gap-4 px-8 py-5 border-b border-zinc-200/60 bg-zinc-50/40">
<div class="col-span-4 text-[10px] font-black text-zinc-600 uppercase tracking-widest">歌曲信息</div>
<div class="col-span-2 text-[10px] font-black text-zinc-600 uppercase tracking-widest">投稿人</div>
<div class="col-span-3 text-[10px] font-black text-zinc-600 uppercase tracking-widest">期望排期</div>
@@ -49,17 +49,17 @@
<div
v-for="song in pendingAuditSongs"
:key="song.id"
class="grid grid-cols-1 lg:grid-cols-12 gap-4 px-6 lg:px-8 py-5 transition-all items-center hover:bg-zinc-800/20 group relative"
class="grid grid-cols-1 lg:grid-cols-12 gap-4 px-6 lg:px-8 py-5 transition-all items-center hover:bg-zinc-100/20 group relative"
>
<div class="col-span-12 lg:col-span-4 flex items-center gap-4">
<div class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 border transition-all overflow-hidden bg-zinc-800/40 border-zinc-700/30 cursor-pointer hover:opacity-80" @click.stop="playSong(song)">
<div class="w-12 h-12 rounded-xl flex items-center justify-center shrink-0 border transition-all overflow-hidden bg-zinc-100/40 border-zinc-200/30 cursor-pointer hover:opacity-80" @click.stop="playSong(song)">
<img v-if="song.cover" :src="convertToHttps(song.cover)" class="w-full h-full object-cover" referrerpolicy="no-referrer">
<Music v-else :size="20" class="text-zinc-600" />
</div>
<div class="min-w-0">
<h4 class="font-bold transition-colors flex items-center text-zinc-100 group-hover:text-blue-400">
<h4 class="font-bold transition-colors flex items-center text-zinc-900 group-hover:text-blue-700">
<span class="truncate">{{ song.title }}</span>
<button v-if="song.hasSubmissionNote && song.submissionNote" class="ml-2 inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-all" title="查看备注留言" @click.stop="openSubmissionRemark(song)">
<button v-if="song.hasSubmissionNote && song.submissionNote" class="ml-2 inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-200 bg-blue-500/10 text-blue-700 hover:bg-blue-500/20 transition-all" title="查看备注留言" @click.stop="openSubmissionRemark(song)">
<MessageSquare :size="12" />
</button>
</h4>
@@ -80,10 +80,10 @@
</div>
<div class="col-span-12 lg:col-span-3 flex items-center justify-end gap-2">
<button class="px-4 py-2 bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-500 hover:text-emerald-400 text-xs font-bold rounded-lg border border-emerald-500/20 transition-all" @click="auditSong(song.id, 'approved')">
<button class="px-4 py-2 bg-emerald-600/10 hover:bg-emerald-600/20 text-emerald-500 hover:text-emerald-700 text-xs font-bold rounded-lg border border-emerald-500/20 transition-all" @click="auditSong(song.id, 'approved')">
通过
</button>
<button class="px-4 py-2 bg-red-600/10 hover:bg-red-600/20 text-red-500 hover:text-red-400 text-xs font-bold rounded-lg border border-red-500/20 transition-all" @click="rejectSong(song.id)">
<button class="px-4 py-2 bg-red-600/10 hover:bg-red-600/20 text-red-600 hover:text-red-400 text-xs font-bold rounded-lg border border-red-500/20 transition-all" @click="rejectSong(song.id)">
未过审 (释放排期)
</button>
</div>
@@ -93,20 +93,20 @@
<!-- 驳回歌曲对话框 -->
<Transition enter-active-class="transition duration-300 ease-out" enter-from-class="opacity-0" enter-to-class="opacity-100" leave-active-class="transition duration-200 ease-in" leave-from-class="opacity-100" leave-to-class="opacity-0">
<div v-if="showRejectDialog" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm">
<div class="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden" @click.stop>
<div class="px-8 py-6 border-b border-zinc-800/50 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-100">未过审处理</h3>
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showRejectDialog = false"><X :size="20" /></button>
<div v-if="showRejectDialog" class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-white/80 backdrop-blur-sm">
<div class="w-full max-w-lg bg-white border border-zinc-200 rounded-xl shadow-2xl overflow-hidden" @click.stop>
<div class="px-8 py-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-900">未过审处理</h3>
<button class="text-zinc-500 hover:text-zinc-600 transition-colors" @click="showRejectDialog = false"><X :size="20" /></button>
</div>
<div class="p-8 space-y-6">
<div class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg">
<div class="p-5 bg-white border border-zinc-200 rounded-lg">
<p class="text-xs text-zinc-400 mb-2">此操作会将歌曲标记为未过审,并<span class="text-red-400 font-bold">立即释放其占用的排期序号</span>,供其他同学选择。歌曲不会被删除。</p>
<textarea v-model="rejectReason" placeholder="请输入未过审原因 (可选),将通知投稿人..." class="w-full bg-zinc-900 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-red-500/30 min-h-[100px] resize-none transition-all" />
<textarea v-model="rejectReason" placeholder="请输入未过审原因 (可选),将通知投稿人..." class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-red-200 min-h-[100px] resize-none transition-all" />
</div>
</div>
<div class="px-8 py-6 bg-zinc-900/50 border-t border-zinc-800/50 flex gap-3 justify-end">
<button class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300" @click="showRejectDialog = false">取消</button>
<div class="px-8 py-6 bg-zinc-50/50 border-t border-zinc-200 flex gap-3 justify-end">
<button class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-600" @click="showRejectDialog = false">取消</button>
<button :disabled="auditLoading" class="px-8 py-2.5 bg-red-600 hover:bg-red-500 text-white text-xs font-black rounded-lg transition-all disabled:opacity-50" @click="confirmReject">
{{ auditLoading ? '处理中...' : '确认释放排期' }}
</button>
+47 -47
View File
@@ -8,14 +8,14 @@
<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]"
class="bg-white border border-zinc-200 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>
<div class="flex items-center justify-between p-4 border-b border-zinc-200 shrink-0">
<h3 class="text-sm font-black text-zinc-900 uppercase tracking-widest">下载歌曲</h3>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
class="text-zinc-500 hover:text-zinc-600 transition-colors"
@click="closeDialog"
>
<CloseIcon class="w-5 h-5" />
@@ -36,15 +36,15 @@
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'
? 'bg-blue-50 border-blue-500 shadow-sm'
: 'bg-white border-zinc-200 hover:border-zinc-200'
]"
@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'"
:class="selectedQuality === option.value ? 'text-blue-700' : 'text-zinc-700'"
>{{ option.label }}</span
>
<div
@@ -69,16 +69,16 @@
>
</div>
<div class="bg-zinc-950 border border-zinc-800 rounded-2xl p-4 space-y-4">
<div class="bg-white border border-zinc-200 rounded-2xl p-4 space-y-4">
<!-- 合并开关 -->
<div class="flex items-center justify-between">
<div class="flex flex-col">
<span class="text-xs font-bold text-zinc-200">合并为一个文件</span>
<span class="text-xs font-bold text-zinc-700">合并为一个文件</span>
<span class="text-[10px] text-zinc-500">将选中歌曲按顺序合并为单个音频</span>
</div>
<button
class="w-10 h-6 rounded-full transition-colors relative"
:class="mergeSongs ? 'bg-blue-600' : 'bg-zinc-700'"
:class="mergeSongs ? 'bg-blue-600' : 'bg-zinc-200'"
@click="mergeSongs = !mergeSongs"
>
<div
@@ -90,20 +90,20 @@
<!-- 合并设置区域 -->
<Transition name="expand">
<div v-if="mergeSongs" class="space-y-4 pt-4 border-t border-zinc-800/50">
<div v-if="mergeSongs" class="space-y-4 pt-4 border-t border-zinc-200">
<!-- 导出格式选择 -->
<div class="space-y-2">
<div class="flex items-center gap-2">
<Music class="w-3 h-3 text-zinc-500" />
<span class="text-xs font-bold text-zinc-200">导出格式</span>
<span class="text-xs font-bold text-zinc-700">导出格式</span>
</div>
<div class="flex gap-2">
<button
class="flex-1 py-1.5 px-3 rounded-lg border text-[10px] font-bold transition-all"
:class="
exportFormat === 'mp3'
? 'bg-blue-600/10 border-blue-500 text-blue-400'
: 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:border-zinc-700'
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'bg-white border-zinc-200 text-zinc-400 hover:border-zinc-200'
"
@click="exportFormat = 'mp3'"
>
@@ -113,8 +113,8 @@
class="flex-1 py-1.5 px-3 rounded-lg border text-[10px] font-bold transition-all"
:class="
exportFormat === 'wav'
? 'bg-blue-600/10 border-blue-500 text-blue-400'
: 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:border-zinc-700'
? 'bg-blue-50 border-blue-500 text-blue-700'
: 'bg-white border-zinc-200 text-zinc-400 hover:border-zinc-200'
"
@click="exportFormat = 'wav'"
>
@@ -128,10 +128,10 @@
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
<Edit3 class="w-3 h-3 text-zinc-500" />
<span class="text-xs font-bold text-zinc-200">自定义文件名</span>
<span class="text-xs font-bold text-zinc-700">自定义文件名</span>
</div>
<button
class="text-[10px] text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1"
class="text-[10px] text-blue-700 hover:text-blue-300 transition-colors flex items-center gap-1"
:class="{ 'opacity-50 cursor-not-allowed': !customFilename }"
title="保存为默认预设"
@click="saveFilenamePreset"
@@ -145,19 +145,19 @@
v-model="customFilename"
type="text"
placeholder="例如: 第XX期 - {songs}"
class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-3 py-2 text-xs text-zinc-200 focus:outline-none focus:border-blue-500/50 transition-colors pr-8"
class="w-full bg-white border border-zinc-200 rounded-xl px-3 py-2 text-xs text-zinc-700 focus:outline-none focus:border-blue-500/50 transition-colors pr-8"
>
<!-- 快速插入占位符按钮 -->
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
<button
class="px-1.5 py-0.5 rounded bg-zinc-800 text-[9px] text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700 transition-colors"
class="px-1.5 py-0.5 rounded bg-zinc-100 text-[9px] text-zinc-400 hover:text-zinc-700 hover:bg-zinc-200 transition-colors"
title="插入所有歌名"
@click="insertPlaceholder('{songs}')"
>
{songs}
</button>
<button
class="px-1.5 py-0.5 rounded bg-zinc-800 text-[9px] text-zinc-400 hover:text-zinc-200 hover:bg-zinc-700 transition-colors"
class="px-1.5 py-0.5 rounded bg-zinc-100 text-[9px] text-zinc-400 hover:text-zinc-700 hover:bg-zinc-200 transition-colors"
title="插入当前日期"
@click="insertPlaceholder('{date}')"
>
@@ -168,13 +168,13 @@
<p class="text-[9px] text-zinc-600">
可用占位符:
<code
class="bg-zinc-800 px-1 rounded text-zinc-400 cursor-pointer hover:text-blue-400"
class="bg-zinc-100 px-1 rounded text-zinc-400 cursor-pointer hover:text-blue-700"
@click="insertPlaceholder('{songs}')"
>{songs}</code
>
(所有歌名),
<code
class="bg-zinc-800 px-1 rounded text-zinc-400 cursor-pointer hover:text-blue-400"
class="bg-zinc-100 px-1 rounded text-zinc-400 cursor-pointer hover:text-blue-700"
@click="insertPlaceholder('{date}')"
>{date}</code
>
@@ -184,14 +184,14 @@
<!-- 标准化选项 (仅在合并模式或需要时显示,这里允许单独使用) -->
<div
class="flex items-center justify-between pt-3 border-t border-zinc-800/50"
class="flex items-center justify-between pt-3 border-t border-zinc-200"
>
<div class="flex flex-col">
<div class="flex items-center gap-2">
<span class="text-xs font-bold text-zinc-200">统一音量 (标准化)</span>
<span class="text-xs font-bold text-zinc-700">统一音量 (标准化)</span>
<span
v-if="normalizeAudio"
class="text-[10px] bg-blue-500/10 text-blue-400 px-1.5 py-0.5 rounded border border-blue-500/20"
class="text-[10px] bg-blue-500/10 text-blue-700 px-1.5 py-0.5 rounded border border-blue-200"
>Peak {{ targetDb }}dB</span
>
</div>
@@ -201,7 +201,7 @@
</div>
<div class="flex items-center gap-3">
<button
class="text-[10px] text-blue-400 hover:text-blue-300 transition-colors flex items-center gap-1"
class="text-[10px] text-blue-700 hover:text-blue-300 transition-colors flex items-center gap-1"
title="保存当前音量设置(包括开启状态)为默认"
@click="saveDbPreset"
>
@@ -210,7 +210,7 @@
</button>
<button
class="w-10 h-6 rounded-full transition-colors relative"
:class="normalizeAudio ? 'bg-blue-600' : 'bg-zinc-700'"
:class="normalizeAudio ? 'bg-blue-600' : 'bg-zinc-200'"
@click="normalizeAudio = !normalizeAudio"
>
<div
@@ -232,9 +232,9 @@
min="-10"
max="0"
step="0.5"
class="flex-1 h-1.5 bg-zinc-800 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500"
class="flex-1 h-1.5 bg-zinc-100 rounded-lg appearance-none cursor-pointer [&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:w-3 [&::-webkit-slider-thumb]:h-3 [&::-webkit-slider-thumb]:rounded-full [&::-webkit-slider-thumb]:bg-blue-500"
>
<span class="text-xs font-mono text-zinc-300 w-12 text-right"
<span class="text-xs font-mono text-zinc-600 w-12 text-right"
>{{ targetDb }} dB</span
>
</div>
@@ -255,7 +255,7 @@
>
<div
v-if="estimatedTotalDuration.count > 0"
class="flex items-center gap-1.5 text-[10px] text-blue-400 font-bold bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-500/20"
class="flex items-center gap-1.5 text-[10px] text-blue-700 font-bold bg-blue-500/10 px-2 py-0.5 rounded-full border border-blue-200"
>
<Clock class="w-3 h-3" />
<span>预估总时长: {{ formatDuration(estimatedTotalDuration.total) }}</span>
@@ -264,7 +264,7 @@
<div class="flex items-center gap-2">
<button
v-if="selectedSongs.size > 0"
class="text-[10px] font-bold text-zinc-500 hover:text-zinc-300 transition-colors flex items-center gap-1"
class="text-[10px] font-bold text-zinc-500 hover:text-zinc-600 transition-colors flex items-center gap-1"
title="预下载选中歌曲到浏览器缓存"
@click="preloadSelectedSongs"
>
@@ -272,7 +272,7 @@
预下载选中
</button>
<button
class="text-[10px] font-bold text-blue-500/80 hover:text-blue-400 transition-colors"
class="text-[10px] font-bold text-blue-500/80 hover:text-blue-700 transition-colors"
@click="toggleSelectAll"
>
{{ isAllSelected ? '取消全选' : '全选' }}
@@ -281,12 +281,12 @@
</div>
<div
class="bg-zinc-950/50 border border-zinc-800/50 rounded-2xl overflow-hidden max-h-[300px] overflow-y-auto custom-scrollbar"
class="bg-white/70 border border-zinc-200 rounded-2xl overflow-hidden max-h-[300px] overflow-y-auto custom-scrollbar"
>
<div
v-for="song in songs"
:key="song.id"
class="w-full flex items-center gap-3 p-3.5 hover:bg-zinc-800/30 transition-all text-left border-b border-zinc-800/30 last:border-0 group relative"
class="w-full flex items-center gap-3 p-3.5 hover:bg-zinc-100/30 transition-all text-left border-b border-zinc-200/30 last:border-0 group relative"
>
<!-- 预下载进度条背景 -->
<div
@@ -310,7 +310,7 @@
:class="[
selectedSongs.has(song.song.id)
? 'bg-blue-600 border-blue-600 shadow-sm'
: 'bg-zinc-900 border-zinc-800 group-hover:border-zinc-700'
: 'bg-white border-zinc-200 group-hover:border-zinc-200'
]"
@click="toggleSongSelection(song.song.id)"
>
@@ -326,7 +326,7 @@
@click="toggleSongSelection(song.song.id)"
>
<div class="flex items-center gap-2">
<p class="text-xs font-bold text-zinc-300 truncate">{{ song.song.title }}</p>
<p class="text-xs font-bold text-zinc-600 truncate">{{ song.song.title }}</p>
<!-- 预下载标记 -->
<div
v-if="
@@ -360,7 +360,7 @@
</button>
<button
v-else
class="p-1.5 rounded-lg hover:bg-blue-500/10 text-zinc-600 hover:text-blue-400 transition-colors"
class="p-1.5 rounded-lg hover:bg-blue-500/10 text-zinc-600 hover:text-blue-700 transition-colors"
title="预下载此歌曲"
@click.stop="preloadSong(song.song)"
>
@@ -378,16 +378,16 @@
<!-- 进度条 -->
<section
v-if="downloading || downloadedCount > 0"
class="space-y-3 pt-4 border-t border-zinc-800/50"
class="space-y-3 pt-4 border-t border-zinc-200"
>
<div
class="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider"
>
<span class="text-zinc-400">{{ mergeSongs ? '处理进度' : '下载进度' }}</span>
<span class="text-blue-400">{{ downloadedCount }} / {{ totalCount }}</span>
<span class="text-blue-700">{{ downloadedCount }} / {{ totalCount }}</span>
</div>
<div
class="h-2 bg-zinc-950 rounded-full overflow-hidden border border-zinc-800/50 relative"
class="h-2 bg-white rounded-full overflow-hidden border border-zinc-200 relative"
>
<div
class="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-300 ease-out relative overflow-hidden"
@@ -402,7 +402,7 @@
</div>
<div class="text-[10px] text-zinc-500 truncate">
<template v-if="downloading">
<span v-if="processingStatus" class="text-blue-400 animate-pulse">{{
<span v-if="processingStatus" class="text-blue-700 animate-pulse">{{
processingStatus
}}</span>
<span v-else>{{
@@ -438,14 +438,14 @@
<!-- 底部按钮 -->
<div
class="p-4 border-t border-zinc-800 bg-zinc-900/50 flex items-center justify-between shrink-0"
class="p-4 border-t border-zinc-200 bg-zinc-50/50 flex items-center justify-between shrink-0"
>
<div class="text-[11px] font-black text-zinc-500 uppercase tracking-widest">
已选择 <span class="text-blue-400">{{ selectedSongs.size }}</span> 首歌曲
已选择 <span class="text-blue-700">{{ selectedSongs.size }}</span> 首歌曲
</div>
<div class="flex items-center gap-2">
<button
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-300 transition-colors uppercase tracking-wider"
class="px-4 py-2 text-xs font-bold text-zinc-500 hover:text-zinc-600 transition-colors uppercase tracking-wider"
:disabled="downloading"
@click="closeDialog"
>
@@ -453,7 +453,7 @@
</button>
<button
v-if="!downloading && downloadedCount > 0"
class="px-6 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-bold rounded-xl border border-zinc-700 transition-all uppercase tracking-wider"
class="px-6 py-2.5 bg-zinc-100 hover:bg-zinc-200 text-zinc-700 text-xs font-bold rounded-xl border border-zinc-200 transition-all uppercase tracking-wider"
@click="closeDialog"
>
关闭
@@ -1228,7 +1228,7 @@ onUnmounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
+82 -82
View File
@@ -4,13 +4,13 @@
<div class="space-y-6">
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 px-1">
<div class="space-y-1">
<h2 class="text-3xl font-black text-zinc-100 tracking-tight">歌曲管理</h2>
<h2 class="text-3xl font-black text-zinc-900 tracking-tight">歌曲管理</h2>
<div class="flex items-center gap-6 mt-4">
<div class="flex items-center gap-2">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>总计</span
>
<span class="text-sm font-bold text-zinc-300">{{ filteredSongs.length }}</span>
<span class="text-sm font-bold text-zinc-600">{{ filteredSongs.length }}</span>
</div>
<div class="flex items-center gap-2">
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
@@ -39,10 +39,10 @@
>
<div
v-if="selectedSongs.length > 0"
class="flex items-center gap-2 p-1.5 bg-zinc-900/90 border border-zinc-800 rounded-lg backdrop-blur-xl shadow-2xl"
class="flex items-center gap-2 p-1.5 bg-zinc-50/90 border border-zinc-200 rounded-lg backdrop-blur-xl shadow-2xl"
>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-emerald-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
class="flex items-center gap-2 px-4 py-2 bg-zinc-100 hover:bg-zinc-200 text-emerald-700 text-[11px] font-black rounded-lg border border-zinc-200 transition-all active:scale-95 uppercase tracking-widest"
@click="openDownloadDialog"
>
<Download :size="14" class="text-emerald-500/70" /> 下载 ({{
@@ -50,25 +50,25 @@
}})
</button>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-emerald-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
class="flex items-center gap-2 px-4 py-2 bg-zinc-100 hover:bg-zinc-200 text-emerald-700 text-[11px] font-black rounded-lg border border-zinc-200 transition-all active:scale-95 uppercase tracking-widest"
@click="batchMarkPlayed"
>
<Check :size="14" class="text-emerald-500/70" /> 已播放 ({{ selectedSongs.length }})
</button>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-blue-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
class="flex items-center gap-2 px-4 py-2 bg-zinc-100 hover:bg-zinc-200 text-blue-700 text-[11px] font-black rounded-lg border border-zinc-200 transition-all active:scale-95 uppercase tracking-widest"
@click="batchMarkUnplayed"
>
<RotateCcw :size="14" class="text-blue-500/70" /> 未播放 ({{ selectedSongs.length }})
</button>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-amber-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
class="flex items-center gap-2 px-4 py-2 bg-zinc-100 hover:bg-zinc-200 text-amber-700 text-[11px] font-black rounded-lg border border-zinc-200 transition-all active:scale-95 uppercase tracking-widest"
@click="batchReject"
>
<X :size="14" class="text-amber-500/70" /> 驳回 ({{ selectedSongs.length }})
</button>
<button
class="flex items-center gap-2 px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-red-400 text-[11px] font-black rounded-lg border border-zinc-700/50 transition-all active:scale-95 uppercase tracking-widest"
class="flex items-center gap-2 px-4 py-2 bg-zinc-100 hover:bg-zinc-200 text-red-400 text-[11px] font-black rounded-lg border border-zinc-200 transition-all active:scale-95 uppercase tracking-widest"
@click="batchDelete"
>
<Trash2 :size="14" class="text-red-500/70" /> 删除 ({{ selectedSongs.length }})
@@ -77,14 +77,14 @@
</Transition>
<button
class="flex items-center gap-2 px-5 py-2.5 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-300 text-xs font-bold rounded-lg transition-all active:scale-95"
class="flex items-center gap-2 px-5 py-2.5 bg-white border border-zinc-200 hover:border-zinc-200 text-zinc-600 text-xs font-bold rounded-lg transition-all active:scale-95"
@click="openAddSongModal"
>
<Plus :size="16" /> 手动添加
</button>
<button
:disabled="loading"
class="flex items-center gap-2 px-5 py-2.5 bg-blue-600/10 hover:bg-blue-600/20 text-blue-400 text-xs font-bold rounded-lg border border-blue-500/20 transition-all disabled:opacity-50"
class="flex items-center gap-2 px-5 py-2.5 bg-blue-50 hover:bg-blue-600/20 text-blue-700 text-xs font-bold rounded-lg border border-blue-200 transition-all disabled:opacity-50"
@click="refreshSongs(true)"
>
<RotateCcw :size="16" :class="{ 'animate-spin': loading }" /> 刷新
@@ -94,7 +94,7 @@
<!-- 过滤器栏 -->
<div
class="bg-zinc-900/30 border border-zinc-800/60 rounded-xl p-4 flex flex-col lg:flex-row gap-4 items-stretch lg:items-center"
class="bg-zinc-50/30 border border-zinc-200/60 rounded-xl p-4 flex flex-col lg:flex-row gap-4 items-stretch lg:items-center"
>
<div class="relative flex-1 group w-full lg:w-auto">
<Search
@@ -105,7 +105,7 @@
v-model="searchQuery"
type="text"
placeholder="搜索歌曲标题、艺术家或投稿人..."
class="w-full bg-zinc-950/50 border border-zinc-800/80 rounded-lg pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-blue-500/30 transition-all placeholder:text-zinc-800 text-zinc-200"
class="w-full bg-white/70 border border-zinc-200/80 rounded-lg pl-12 pr-4 py-3 text-sm focus:outline-none focus:border-blue-200 transition-all placeholder:text-zinc-500 text-zinc-700"
>
</div>
<div class="grid grid-cols-2 md:grid-cols-3 lg:flex lg:items-center gap-3 w-full lg:w-auto">
@@ -138,16 +138,16 @@
<!-- 歌曲表格 -->
<div
class="bg-zinc-900/10 border border-zinc-800/40 rounded-xl overflow-hidden shadow-2xl relative"
class="bg-zinc-50/10 border border-zinc-200 rounded-xl overflow-hidden shadow-2xl relative"
>
<!-- 加载遮罩 -->
<div
v-if="loading"
class="absolute inset-0 bg-zinc-950/40 backdrop-blur-sm z-10 flex items-center justify-center"
class="absolute inset-0 bg-white/80 backdrop-blur-sm z-10 flex items-center justify-center"
>
<div class="flex flex-col items-center gap-3">
<div
class="w-8 h-8 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
class="w-8 h-8 border-2 border-blue-200 border-t-blue-500 rounded-full animate-spin"
/>
<span class="text-xs font-bold text-zinc-400 tracking-widest uppercase">正在加载...</span>
</div>
@@ -155,13 +155,13 @@
<!-- 表头 -->
<div
class="hidden lg:grid grid-cols-12 gap-4 px-8 py-5 border-b border-zinc-800/60 bg-zinc-900/40"
class="hidden lg:grid grid-cols-12 gap-4 px-8 py-5 border-b border-zinc-200/60 bg-zinc-50/40"
>
<div class="col-span-1 flex items-center">
<input
type="checkbox"
:checked="isAllSelected"
class="w-5 h-5 rounded-lg border-2 border-zinc-800 bg-zinc-950 accent-blue-600 transition-all cursor-pointer"
class="w-5 h-5 rounded-lg border-2 border-zinc-200 bg-white accent-blue-600 transition-all cursor-pointer"
@change="toggleSelectAll"
>
</div>
@@ -204,14 +204,14 @@
:key="song.id"
:class="[
'grid grid-cols-1 lg:grid-cols-12 gap-4 px-6 lg:px-8 py-5 transition-all items-center group relative',
selectedSongs.includes(song.id) ? 'bg-blue-600/5' : 'hover:bg-zinc-800/20'
selectedSongs.includes(song.id) ? 'bg-blue-600/5' : 'hover:bg-zinc-100/20'
]"
>
<div class="hidden lg:flex col-span-1 items-center">
<input
type="checkbox"
:checked="selectedSongs.includes(song.id)"
class="w-5 h-5 rounded-lg border-2 border-zinc-800 bg-zinc-950 accent-blue-600 transition-all cursor-pointer"
class="w-5 h-5 rounded-lg border-2 border-zinc-200 bg-white accent-blue-600 transition-all cursor-pointer"
@change="toggleSelectSong(song.id)"
>
</div>
@@ -219,7 +219,7 @@
<div class="col-span-12 lg:col-span-4 flex items-center gap-4">
<div
:class="[
'w-12 h-12 rounded-xl flex items-center justify-center shrink-0 border transition-all overflow-hidden bg-zinc-800/40 border-zinc-700/30 group-hover:border-zinc-600 cursor-pointer hover:opacity-80',
'w-12 h-12 rounded-xl flex items-center justify-center shrink-0 border transition-all overflow-hidden bg-zinc-100/40 border-zinc-200/30 group-hover:border-zinc-300 cursor-pointer hover:opacity-80',
selectedSongs.includes(song.id) ? 'border-blue-500/50' : ''
]"
@click.stop="playSong(song)"
@@ -237,8 +237,8 @@
:class="[
'font-bold transition-colors flex items-center',
selectedSongs.includes(song.id)
? 'text-blue-400'
: 'text-zinc-100 group-hover:text-blue-400'
? 'text-blue-700'
: 'text-zinc-900 group-hover:text-blue-700'
]"
>
<span
@@ -250,7 +250,7 @@
<span v-else class="truncate">{{ song.title }}</span>
<button
v-if="song.hasSubmissionNote && song.submissionNote"
class="ml-2 inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-500/30 bg-blue-500/10 text-blue-400 hover:bg-blue-500/20 transition-all"
class="ml-2 inline-flex items-center justify-center w-5 h-5 rounded-full border border-blue-200 bg-blue-500/10 text-blue-700 hover:bg-blue-500/20 transition-all"
title="查看备注留言"
@click.stop="openSubmissionRemark(song)"
>
@@ -261,7 +261,7 @@
<!-- 期望播出日期标签 -->
<div v-if="song.expectedPlayDate" class="mt-1 flex items-center">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/10 text-blue-400 rounded border border-blue-500/20 text-[10px] font-medium">
<span class="inline-flex items-center gap-1 px-1.5 py-0.5 bg-blue-500/10 text-blue-700 rounded border border-blue-200 text-[10px] font-medium">
<CalendarDays :size="10" />
期望: {{ song.expectedPlayDate }}
<template v-if="song.expectedPlaySequence">
@@ -295,7 +295,7 @@
'inline-flex items-center gap-1.5 text-xs font-bold px-2 py-1 rounded-lg transition-all',
song.voteCount > 0
? 'bg-pink-500/10 text-pink-500 hover:bg-pink-500/20 cursor-pointer'
: 'bg-zinc-950/30 text-zinc-700 cursor-default'
: 'bg-white/60 text-zinc-700 cursor-default'
]"
@click="showVoters(song.id)"
>
@@ -312,7 +312,7 @@
? 'bg-emerald-500/10 text-emerald-500'
: song.scheduled
? 'bg-blue-500/10 text-blue-500'
: 'bg-zinc-800 text-zinc-500'
: 'bg-zinc-100 text-zinc-500'
]"
>
{{ getStatusText(song) }}
@@ -323,7 +323,7 @@
class="col-span-12 lg:col-span-2 flex items-center justify-end gap-2 opacity-100 lg:opacity-0 group-hover:opacity-100 transition-all"
>
<button
class="p-2 bg-zinc-800/50 text-blue-400 hover:bg-blue-600 hover:text-white rounded-xl transition-all border border-zinc-700/30"
class="p-2 bg-zinc-100/50 text-blue-700 hover:bg-blue-600 hover:text-zinc-900 rounded-xl transition-all border border-zinc-200/30"
title="编辑歌曲"
@click="editSong(song)"
>
@@ -332,7 +332,7 @@
<button
v-if="!song.played"
class="p-2 bg-zinc-800/50 text-emerald-400 hover:bg-emerald-600 hover:text-white rounded-xl transition-all border border-zinc-700/30"
class="p-2 bg-zinc-100/50 text-emerald-700 hover:bg-emerald-600 hover:text-zinc-900 rounded-xl transition-all border border-zinc-200/30"
title="标记为已播放"
@click="markAsPlayed(song.id)"
>
@@ -340,7 +340,7 @@
</button>
<button
v-else
class="p-2 bg-zinc-800/50 text-zinc-400 hover:bg-zinc-600 hover:text-white rounded-xl transition-all border border-zinc-700/30"
class="p-2 bg-zinc-100/50 text-zinc-400 hover:bg-zinc-600 hover:text-zinc-900 rounded-xl transition-all border border-zinc-200/30"
title="标记为未播放"
@click="markAsUnplayed(song.id)"
>
@@ -348,14 +348,14 @@
</button>
<button
class="p-2 bg-zinc-800/50 text-amber-500 hover:bg-amber-600 hover:text-white rounded-xl transition-all border border-zinc-700/30"
class="p-2 bg-zinc-100/50 text-amber-500 hover:bg-amber-600 hover:text-zinc-900 rounded-xl transition-all border border-zinc-200/30"
title="驳回歌曲"
@click="rejectSong(song.id)"
>
<X :size="14" />
</button>
<button
class="p-2 bg-zinc-800/50 text-red-400 hover:bg-red-600 hover:text-white rounded-xl transition-all border border-zinc-700/30"
class="p-2 bg-zinc-100/50 text-red-400 hover:bg-red-600 hover:text-zinc-900 rounded-xl transition-all border border-zinc-200/30"
title="删除歌曲"
@click="deleteSong(song.id)"
>
@@ -407,16 +407,16 @@
>
<div
v-if="showRejectDialog"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-white/80 backdrop-blur-sm"
>
<div
class="w-full max-w-lg bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden"
class="w-full max-w-lg bg-white border border-zinc-200 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">{{ isBatchReject ? `批量驳回歌曲 (${selectedSongs.length} 首)` : '驳回歌曲' }}</h3>
<div class="px-8 py-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-900">{{ isBatchReject ? `批量驳回歌曲 (${selectedSongs.length} 首)` : '驳回歌曲' }}</h3>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
class="text-zinc-500 hover:text-zinc-600 transition-colors"
@click="cancelReject"
>
<X :size="20" />
@@ -424,25 +424,25 @@
</div>
<div class="p-8 space-y-6">
<div v-if="!isBatchReject" class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
<div v-if="!isBatchReject" class="p-5 bg-white border border-zinc-200 rounded-lg flex items-center gap-4">
<div
class="w-10 h-10 rounded-lg bg-red-500/10 text-red-500 flex items-center justify-center border border-red-500/10"
>
<Music :size="18" />
</div>
<div>
<h4 class="font-bold text-zinc-100 text-sm">{{ rejectSongInfo.title }}</h4>
<h4 class="font-bold text-zinc-900 text-sm">{{ rejectSongInfo.title }}</h4>
<p class="text-xs text-zinc-500">投稿人: {{ rejectSongInfo.requester }}</p>
</div>
</div>
<div v-else class="p-5 bg-zinc-950 border border-zinc-800 rounded-lg flex items-center gap-4">
<div v-else class="p-5 bg-white border border-zinc-200 rounded-lg flex items-center gap-4">
<div
class="w-10 h-10 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center border border-amber-500/10"
>
<ListMusic :size="18" />
</div>
<div>
<h4 class="font-bold text-zinc-100 text-sm">即将批量驳回 {{ selectedSongs.length }} 首歌曲</h4>
<h4 class="font-bold text-zinc-900 text-sm">即将批量驳回 {{ selectedSongs.length }} 首歌曲</h4>
<p class="text-xs text-zinc-500">驳回后将一并清理投票、排期记录并发送通知给对应投稿人</p>
</div>
</div>
@@ -454,7 +454,7 @@
<textarea
v-model="rejectReason"
placeholder="请输入驳回原因,将通过系统通知发送给投稿人..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-4 text-sm text-zinc-200 focus:outline-none focus:border-red-500/30 min-h-[120px] resize-none transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-4 text-sm text-zinc-700 focus:outline-none focus:border-red-200 min-h-[120px] resize-none transition-all"
/>
</div>
@@ -462,11 +462,11 @@
<input
v-model="addToBlacklist"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-red-600"
class="w-4 h-4 rounded border-zinc-200 bg-white accent-red-600"
>
<div>
<span
class="text-xs font-bold text-zinc-300 group-hover:text-red-400 transition-colors"
class="text-xs font-bold text-zinc-600 group-hover:text-red-400 transition-colors"
>同时将此歌曲加入黑名单</span
>
<p class="text-[10px] text-zinc-600 font-medium">
@@ -476,9 +476,9 @@
</label>
</div>
<div class="px-8 py-6 bg-zinc-900/50 border-t border-zinc-800/50 flex gap-3 justify-end">
<div class="px-8 py-6 bg-zinc-50/50 border-t border-zinc-200 flex gap-3 justify-end">
<button
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-600"
@click="cancelReject"
>
取消
@@ -516,18 +516,18 @@
>
<div
v-if="showEditModal || showAddSongModal"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm overflow-y-auto"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-white/80 backdrop-blur-sm overflow-y-auto"
>
<div
class="w-full max-w-2xl bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden my-auto"
class="w-full max-w-2xl bg-white border border-zinc-200 rounded-xl shadow-2xl overflow-hidden my-auto"
@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">
<div class="px-8 py-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-900">
{{ showEditModal ? '编辑歌曲' : '手动添加歌曲' }}
</h3>
<button
class="text-zinc-500 hover:text-zinc-300 transition-colors"
class="text-zinc-500 hover:text-zinc-600 transition-colors"
@click="showEditModal ? cancelEditSong() : cancelAddSong()"
>
<X :size="20" />
@@ -545,14 +545,14 @@
v-model="editForm.title"
type="text"
placeholder="输入歌曲标题"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all"
>
<input
v-else
v-model="addForm.title"
type="text"
placeholder="输入歌曲标题"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all"
>
</div>
<div class="space-y-2">
@@ -564,14 +564,14 @@
v-model="editForm.artist"
type="text"
placeholder="输入歌手姓名"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all"
>
<input
v-else
v-model="addForm.artist"
type="text"
placeholder="输入歌手姓名"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all"
>
</div>
</div>
@@ -587,7 +587,7 @@
v-model="editUserSearchQuery"
type="text"
placeholder="搜索用户姓名或用户名"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all"
@focus="showEditUserDropdown = true"
@input="searchEditUsers()"
>
@@ -596,7 +596,7 @@
v-model="userSearchQuery"
type="text"
placeholder="搜索用户姓名或用户名"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all"
@focus="showUserDropdown = true"
@input="searchUsers()"
>
@@ -605,7 +605,7 @@
class="absolute right-4 top-1/2 -translate-y-1/2"
>
<div
class="w-4 h-4 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
class="w-4 h-4 border-2 border-blue-200 border-t-blue-500 rounded-full animate-spin"
/>
</div>
</div>
@@ -617,18 +617,18 @@
? showEditUserDropdown && filteredEditUsers.length > 0
: showUserDropdown && filteredUsers.length > 0
"
class="absolute z-10 w-full mt-2 bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl overflow-hidden max-h-48 overflow-y-auto"
class="absolute z-10 w-full mt-2 bg-white border border-zinc-200 rounded-lg shadow-2xl overflow-hidden max-h-48 overflow-y-auto"
>
<div
v-for="user in showEditModal
? filteredEditUsers.slice(0, 10)
: filteredUsers.slice(0, 10)"
:key="user.id"
class="px-4 py-3 hover:bg-zinc-800 cursor-pointer transition-colors flex items-center justify-between group"
class="px-4 py-3 hover:bg-zinc-100 cursor-pointer transition-colors flex items-center justify-between group"
@click="showEditModal ? selectEditUser(user) : selectUser(user)"
>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{ user.name }}</span>
<span class="text-sm font-bold text-zinc-700">{{ user.name }}</span>
<span class="text-[10px] text-zinc-500">@{{ user.username }}</span>
</div>
<Check
@@ -674,7 +674,7 @@
<span
v-for="collaborator in selectedEditCollaborators"
:key="collaborator.id"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-zinc-950 border border-zinc-800 text-xs text-zinc-300"
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-white border border-zinc-200 text-xs text-zinc-600"
>
{{ getCollaboratorDisplayName(collaborator) }}
<button
@@ -690,7 +690,7 @@
v-model="editCollaboratorSearchQuery"
type="text"
placeholder="搜索并添加联合投稿人"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 transition-all"
@focus="showEditCollaboratorDropdown = true"
@input="searchEditCollaborators()"
>
@@ -699,21 +699,21 @@
class="absolute right-4 top-1/2 -translate-y-1/2"
>
<div
class="w-4 h-4 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin"
class="w-4 h-4 border-2 border-blue-200 border-t-blue-500 rounded-full animate-spin"
/>
</div>
<div
v-if="showEditCollaboratorDropdown && filteredEditCollaborators.length > 0"
class="absolute z-10 w-full mt-2 bg-zinc-900 border border-zinc-800 rounded-lg shadow-2xl overflow-hidden max-h-48 overflow-y-auto"
class="absolute z-10 w-full mt-2 bg-white border border-zinc-200 rounded-lg shadow-2xl overflow-hidden max-h-48 overflow-y-auto"
>
<div
v-for="user in filteredEditCollaborators.slice(0, 10)"
:key="user.id"
class="px-4 py-3 hover:bg-zinc-800 cursor-pointer transition-colors flex items-center justify-between group"
class="px-4 py-3 hover:bg-zinc-100 cursor-pointer transition-colors flex items-center justify-between group"
@click="selectEditCollaborator(user)"
>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{ user.name }}</span>
<span class="text-sm font-bold text-zinc-700">{{ user.name }}</span>
<span class="text-[10px] text-zinc-500">@{{ user.username }}</span>
</div>
<Plus :size="14" class="text-blue-500 opacity-70 group-hover:opacity-100" />
@@ -759,8 +759,8 @@
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg border text-[10px] font-black tracking-widest uppercase transition-all disabled:opacity-40 disabled:cursor-not-allowed"
:class="
submissionNoteClearRequested
? 'border-zinc-700 bg-zinc-950 text-zinc-300 hover:border-zinc-600'
: 'border-amber-500/20 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20'
? 'border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300'
: 'border-amber-500/20 bg-amber-500/10 text-amber-700 hover:bg-amber-500/20'
"
@click="
submissionNoteClearRequested
@@ -775,7 +775,7 @@
v-model="editForm.submissionNote"
:disabled="submissionNoteClearRequested"
placeholder="填写歌曲备注"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-4 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 min-h-[120px] resize-none transition-all disabled:opacity-50 disabled:cursor-not-allowed"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-4 text-sm text-zinc-700 focus:outline-none focus:border-blue-200 min-h-[120px] resize-none transition-all disabled:opacity-50 disabled:cursor-not-allowed"
/>
<div
v-if="submissionNoteClearRequested"
@@ -787,16 +787,16 @@
<textarea
v-model="submissionNoteClearReason"
placeholder="可选:请输入清空备注的理由,例如:备注内容违规"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-amber-500/30 min-h-[96px] resize-none transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none focus:border-amber-200 min-h-[96px] resize-none transition-all"
/>
<label class="flex items-center gap-3 cursor-pointer group">
<input
v-model="notifyOnSubmissionNoteClear"
type="checkbox"
class="w-4 h-4 rounded border-zinc-800 bg-zinc-950 accent-amber-500"
class="w-4 h-4 rounded border-zinc-200 bg-white accent-amber-500"
>
<div>
<span class="text-xs font-bold text-zinc-300 group-hover:text-amber-300 transition-colors"
<span class="text-xs font-bold text-zinc-600 group-hover:text-amber-300 transition-colors"
>清空后发送通知</span
>
<p class="text-[10px] text-zinc-600 font-medium">
@@ -806,7 +806,7 @@
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pt-4 border-t border-zinc-800/50">
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 pt-4 border-t border-zinc-200">
<div class="space-y-2">
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
>音乐平台 (可选)</label
@@ -841,14 +841,14 @@
v-model="editForm.musicId"
type="text"
placeholder="输入平台唯一标识符"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none transition-all"
>
<input
v-else
v-model="addForm.musicId"
type="text"
placeholder="输入平台唯一标识符"
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none transition-all"
>
</div>
</div>
@@ -862,14 +862,14 @@
v-model="editForm.cover"
type="text"
placeholder="http://..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none transition-all"
>
<input
v-else
v-model="addForm.cover"
type="text"
placeholder="http://..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none transition-all"
>
<p
v-if="
@@ -901,14 +901,14 @@
v-model="editForm.playUrl"
type="text"
placeholder="http://..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none transition-all"
>
<input
v-else
v-model="addForm.playUrl"
type="text"
placeholder="http://..."
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-4 py-3 text-sm text-zinc-200 focus:outline-none transition-all"
class="w-full bg-white border border-zinc-200 rounded-lg px-4 py-3 text-sm text-zinc-700 focus:outline-none transition-all"
>
<p
v-if="
@@ -932,9 +932,9 @@
</div>
</div>
<div class="px-8 py-6 bg-zinc-900/50 border-t border-zinc-800/50 flex gap-3 justify-end">
<div class="px-8 py-6 bg-zinc-50/50 border-t border-zinc-200 flex gap-3 justify-end">
<button
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-300"
class="px-6 py-2.5 text-xs font-bold text-zinc-500 hover:text-zinc-600"
@click="showEditModal ? cancelEditSong() : cancelAddSong()"
>
取消
@@ -2179,7 +2179,7 @@ onUnmounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--bg-tertiary, #27272a);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
@@ -9,16 +9,16 @@
>
<div
v-if="show"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-zinc-950/80 backdrop-blur-sm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-white/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"
class="w-full max-w-lg bg-white border border-zinc-200 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">
<div class="px-8 py-6 border-b border-zinc-200 flex items-center justify-between">
<h3 class="text-xl font-black text-zinc-900">投稿备注留言</h3>
<button class="text-zinc-500 hover:text-zinc-600 transition-colors" @click="close">
<X :size="20" />
</button>
</div>
@@ -29,15 +29,15 @@
: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-blue-500/10 text-blue-700 border-blue-200'
: '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">
<div class="bg-white/70 border border-zinc-200 rounded-xl p-4">
<p class="text-sm text-zinc-700 leading-relaxed whitespace-pre-wrap">
{{ content }}
</p>
</div>
+105 -105
View File
@@ -3,7 +3,7 @@
<!-- Header -->
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mt-4">
<div>
<h2 class="text-2xl font-black text-zinc-100 tracking-tight">用户管理</h2>
<h2 class="text-2xl font-black text-zinc-900 tracking-tight">用户管理</h2>
<p class="text-xs text-zinc-500 mt-1">系统共有 {{ totalUsers }} 位成员 · 权限与账户管理</p>
</div>
<div class="flex flex-wrap items-center gap-2">
@@ -15,14 +15,14 @@
添加
</button>
<button
class="flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-zinc-900 border border-zinc-800 text-zinc-300 text-xs font-black rounded-lg transition-all uppercase tracking-widest"
class="flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-white border border-zinc-200 text-zinc-600 text-xs font-black rounded-lg transition-all uppercase tracking-widest"
@click="showImportModal = true"
>
<FileSpreadsheet class="text-emerald-500" :size="14" />
导入
</button>
<button
class="flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-zinc-900 border border-zinc-800 text-zinc-300 text-xs font-black rounded-lg transition-all uppercase tracking-widest"
class="flex-1 md:flex-none flex items-center justify-center gap-2 px-4 py-2.5 bg-white border border-zinc-200 text-zinc-600 text-xs font-black rounded-lg transition-all uppercase tracking-widest"
@click="showBatchUpdateModal = true"
>
<Layers class="text-purple-500" :size="14" />
@@ -33,7 +33,7 @@
<!-- Filter Bar -->
<div
class="bg-zinc-900/40 border border-zinc-800/60 rounded-xl p-3 flex flex-col lg:flex-row gap-3 items-center"
class="bg-zinc-50/40 border border-zinc-200/60 rounded-xl p-3 flex flex-col lg:flex-row gap-3 items-center"
>
<div class="relative flex-1 w-full group">
<Search
@@ -42,7 +42,7 @@
/>
<input
v-model="searchQuery"
class="w-full bg-zinc-950 border border-zinc-800/80 rounded-lg pl-11 pr-4 py-2.5 text-xs focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
class="w-full bg-white border border-zinc-200/80 rounded-lg pl-11 pr-4 py-2.5 text-xs focus:outline-none focus:border-blue-200 transition-all text-zinc-700"
placeholder="通过姓名或学号搜索..."
type="text"
>
@@ -84,7 +84,7 @@
/>
<button
class="p-3 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-600 hover:text-blue-400 transition-all shadow-sm"
class="p-3 bg-white border border-zinc-200 rounded-lg text-zinc-600 hover:text-blue-700 transition-all shadow-sm"
@click="loadUsers(1)"
>
<RefreshCw :size="14" />
@@ -96,17 +96,17 @@
<div class="space-y-4">
<div v-if="loading" class="flex flex-col items-center justify-center py-20 text-zinc-500">
<div
class="w-8 h-8 border-2 border-blue-500/20 border-t-blue-500 rounded-full animate-spin mb-4"
class="w-8 h-8 border-2 border-blue-200 border-t-blue-500 rounded-full animate-spin mb-4"
/>
<div class="text-xs font-black uppercase tracking-widest">正在加载用户...</div>
</div>
<div
v-else-if="users.length === 0"
class="flex flex-col items-center justify-center py-20 bg-zinc-900/20 border border-zinc-800/50 rounded-xl"
class="flex flex-col items-center justify-center py-20 bg-zinc-50/20 border border-zinc-200 rounded-xl"
>
<div
class="w-16 h-16 rounded-lg bg-zinc-800/50 flex items-center justify-center text-zinc-600 mb-4"
class="w-16 h-16 rounded-lg bg-zinc-100/50 flex items-center justify-center text-zinc-600 mb-4"
>
<Search :size="32" />
</div>
@@ -118,12 +118,12 @@
<template v-else>
<!-- 桌面端表格 -->
<div
class="hidden lg:block bg-zinc-900/20 border border-zinc-800/50 rounded-xl overflow-hidden shadow-lg custom-scrollbar"
class="hidden lg:block bg-zinc-50/20 border border-zinc-200 rounded-xl overflow-hidden shadow-lg custom-scrollbar"
>
<table class="w-full">
<thead>
<tr
class="bg-zinc-900/60 border-b border-zinc-800 text-[10px] font-black text-zinc-600 uppercase tracking-widest"
class="bg-zinc-50/60 border-b border-zinc-200 text-[10px] font-black text-zinc-600 uppercase tracking-widest"
>
<th class="px-6 py-5 text-left">用户详情</th>
<th class="px-6 py-5 text-left">角色权限</th>
@@ -137,7 +137,7 @@
<tr
v-for="user in users"
:key="user.id"
class="group hover:bg-zinc-800/30 transition-all text-xs cursor-pointer"
class="group hover:bg-zinc-100/30 transition-all text-xs cursor-pointer"
@click="showUserDetail(user, $event)"
>
<td class="px-6 py-5">
@@ -145,17 +145,17 @@
<img
v-if="user.avatar && !failedImages[user.id]"
:src="user.avatar"
class="w-10 h-10 rounded-xl object-cover border border-zinc-700/50"
class="w-10 h-10 rounded-xl object-cover border border-zinc-200"
@error="handleImageError(user.id)"
>
<div
v-else
class="w-10 h-10 rounded-xl bg-zinc-800 flex items-center justify-center font-black text-zinc-500 group-hover:text-zinc-300 transition-colors border border-zinc-700/50"
class="w-10 h-10 rounded-xl bg-zinc-100 flex items-center justify-center font-black text-zinc-500 group-hover:text-zinc-600 transition-colors border border-zinc-200"
>
{{ (user.name || user.username || '?').charAt(0).toUpperCase() }}
</div>
<div>
<p class="font-black text-zinc-100">{{ user.name || user.username }}</p>
<p class="font-black text-zinc-900">{{ user.name || user.username }}</p>
<p class="text-[10px] text-zinc-600 font-mono mt-0.5">
ID: {{ user.username }}
</p>
@@ -167,9 +167,9 @@
class="px-2 py-0.5 text-[10px] font-black rounded border uppercase tracking-widest"
:class="{
'bg-orange-500/10 text-orange-400 border-orange-500/20': user.role?.toUpperCase() === 'SUPER_ADMIN',
'bg-blue-500/10 text-blue-400 border-blue-500/20': user.role?.toUpperCase() === 'ADMIN' || user.role?.toUpperCase() === 'MINISTER',
'bg-blue-500/10 text-blue-700 border-blue-200': user.role?.toUpperCase() === 'ADMIN' || user.role?.toUpperCase() === 'MINISTER',
'bg-purple-500/10 text-purple-400 border-purple-500/20': user.role?.toUpperCase() === 'SONG_ADMIN' || user.role?.toUpperCase() === 'STAFF' || user.role?.toUpperCase() === 'VICE_MINISTER',
'bg-zinc-800 text-zinc-500 border-zinc-700/50': !['SUPER_ADMIN', 'ADMIN', 'MINISTER', 'SONG_ADMIN', 'STAFF', 'VICE_MINISTER'].includes(user.role?.toUpperCase())
'bg-zinc-100 text-zinc-500 border-zinc-200': !['SUPER_ADMIN', 'ADMIN', 'MINISTER', 'SONG_ADMIN', 'STAFF', 'VICE_MINISTER'].includes(user.role?.toUpperCase())
}"
>
{{ getRoleDisplayName(user.role?.toUpperCase()) }}
@@ -222,14 +222,14 @@
>
<button
:disabled="isSelf(user) || !canManageTargetUser(user)"
class="p-2 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-blue-400 transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
class="p-2 bg-white border border-zinc-200 rounded-xl text-zinc-500 hover:text-blue-700 transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
:title="!canManageTargetUser(user) ? '权限不足:无法编辑该用户' : '编辑用户'"
@click="editUser(user)"
>
<Edit2 :size="13" />
</button>
<button
class="p-2 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-purple-400 transition-colors action-btn"
class="p-2 bg-white border border-zinc-200 rounded-xl text-zinc-500 hover:text-purple-400 transition-colors action-btn"
title="查看歌曲"
@click="viewUserSongs(user)"
>
@@ -237,7 +237,7 @@
</button>
<button
:disabled="isSelf(user) || !canResetPasswordFor(user)"
class="p-2 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-amber-400 transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
class="p-2 bg-white border border-zinc-200 rounded-xl text-zinc-500 hover:text-amber-700 transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
:title="!canResetPasswordFor(user) ? '权限不足:无法重置该用户的密码' : '重置密码'"
@click="resetPassword(user)"
>
@@ -245,7 +245,7 @@
</button>
<button
:disabled="isSelf(user) || !canManageTargetUser(user)"
class="p-2 bg-zinc-950 border border-zinc-800 rounded-xl text-zinc-500 hover:text-red-400 transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
class="p-2 bg-white border border-zinc-200 rounded-xl text-zinc-500 hover:text-red-400 transition-colors disabled:opacity-20 disabled:cursor-not-allowed action-btn"
:title="!canManageTargetUser(user) ? '权限不足:无法删除该用户' : '删除用户'"
@click="confirmDeleteUser(user)"
>
@@ -263,7 +263,7 @@
<div
v-for="user in users"
:key="user.id"
class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-5 space-y-5 shadow-lg shadow-black/20"
class="bg-zinc-50/40 border border-zinc-200 rounded-xl p-5 space-y-5 shadow-lg shadow-zinc-900/5"
@click="showUserDetail(user, $event)"
>
<div class="flex items-start justify-between">
@@ -271,17 +271,17 @@
<img
v-if="user.avatar && !failedImages[user.id]"
:src="user.avatar"
class="w-12 h-12 rounded-lg object-cover border border-zinc-700"
class="w-12 h-12 rounded-lg object-cover border border-zinc-200"
@error="handleImageError(user.id)"
>
<div
v-else
class="w-12 h-12 rounded-lg bg-zinc-800 flex items-center justify-center font-black text-lg text-zinc-500 border border-zinc-700"
class="w-12 h-12 rounded-lg bg-zinc-100 flex items-center justify-center font-black text-lg text-zinc-500 border border-zinc-200"
>
{{ (user.name || user.username || '?').charAt(0).toUpperCase() }}
</div>
<div>
<h4 class="text-base font-black text-zinc-100">{{ user.name || user.username }}</h4>
<h4 class="text-base font-black text-zinc-900">{{ user.name || user.username }}</h4>
<p class="text-xs text-zinc-500 font-mono">@{{ user.username }}</p>
</div>
</div>
@@ -320,7 +320,7 @@
</div>
</div>
<div class="grid grid-cols-2 gap-4 py-4 border-y border-zinc-800/50">
<div class="grid grid-cols-2 gap-4 py-4 border-y border-zinc-200">
<div class="space-y-1">
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-widest">
角色/等级
@@ -330,9 +330,9 @@
class="px-2 py-0.5 text-[10px] font-black rounded border uppercase tracking-widest"
:class="{
'bg-orange-500/10 text-orange-400 border-orange-500/20': user.role?.toUpperCase() === 'SUPER_ADMIN',
'bg-blue-500/10 text-blue-400 border-blue-500/20': user.role?.toUpperCase() === 'ADMIN' || user.role?.toUpperCase() === 'MINISTER',
'bg-blue-500/10 text-blue-700 border-blue-200': user.role?.toUpperCase() === 'ADMIN' || user.role?.toUpperCase() === 'MINISTER',
'bg-purple-500/10 text-purple-400 border-purple-500/20': user.role?.toUpperCase() === 'SONG_ADMIN' || user.role?.toUpperCase() === 'STAFF' || user.role?.toUpperCase() === 'VICE_MINISTER',
'bg-zinc-800 text-zinc-500 border-zinc-700/50': !['SUPER_ADMIN', 'ADMIN', 'MINISTER', 'SONG_ADMIN', 'STAFF', 'VICE_MINISTER'].includes(user.role?.toUpperCase())
'bg-zinc-100 text-zinc-500 border-zinc-200': !['SUPER_ADMIN', 'ADMIN', 'MINISTER', 'SONG_ADMIN', 'STAFF', 'VICE_MINISTER'].includes(user.role?.toUpperCase())
}"
>
{{ getRoleDisplayName(user.role?.toUpperCase()) }}
@@ -343,7 +343,7 @@
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-widest">
所在班级
</p>
<p class="text-xs font-bold text-zinc-300">
<p class="text-xs font-bold text-zinc-600">
{{ user.grade || '-' }} {{ user.class || '-' }}
</p>
</div>
@@ -364,21 +364,21 @@
<div class="flex gap-2 action-buttons">
<button
:disabled="isSelf(user) || !canManageTargetUser(user)"
class="flex-1 py-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-blue-600 active:text-white transition-colors disabled:opacity-20 action-btn"
class="flex-1 py-2.5 bg-white border border-zinc-200 rounded-lg text-zinc-400 flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-blue-600 active:text-white transition-colors disabled:opacity-20 action-btn"
:title="!canManageTargetUser(user) ? '权限不足:无法编辑该用户' : '编辑用户'"
@click="editUser(user)"
>
<Edit2 :size="12" /> 编辑
</button>
<button
class="flex-1 py-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-purple-600 active:text-white transition-colors action-btn"
class="flex-1 py-2.5 bg-white border border-zinc-200 rounded-lg text-zinc-400 flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-purple-600 active:text-white transition-colors action-btn"
@click="viewUserSongs(user)"
>
<Music :size="12" /> 记录
</button>
<button
:disabled="isSelf(user) || !canResetPasswordFor(user)"
class="flex-1 py-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-amber-600 active:text-white transition-colors disabled:opacity-20 action-btn"
class="flex-1 py-2.5 bg-white border border-zinc-200 rounded-lg text-zinc-400 flex items-center justify-center gap-2 text-[10px] font-black uppercase tracking-widest active:bg-amber-600 active:text-white transition-colors disabled:opacity-20 action-btn"
:title="!canResetPasswordFor(user) ? '权限不足:无法重置该用户的密码' : '重置密码'"
@click="resetPassword(user)"
>
@@ -386,7 +386,7 @@
</button>
<button
:disabled="isSelf(user) || !canManageTargetUser(user)"
class="px-3 py-2.5 bg-zinc-950 border border-zinc-800 rounded-lg text-zinc-400 active:bg-red-600 active:text-white transition-colors disabled:opacity-20 action-btn"
class="px-3 py-2.5 bg-white border border-zinc-200 rounded-lg text-zinc-400 active:bg-red-600 active:text-white transition-colors disabled:opacity-20 action-btn"
:title="!canManageTargetUser(user) ? '权限不足:无法删除该用户' : '删除用户'"
@click="confirmDeleteUser(user)"
>
@@ -422,15 +422,15 @@
@click="closeModal"
>
<div
class="bg-zinc-900 border border-zinc-800 w-full max-w-lg rounded-3xl overflow-hidden shadow-2xl"
class="bg-white border border-zinc-200 w-full max-w-lg rounded-3xl overflow-hidden shadow-2xl"
@click.stop
>
<div class="p-8">
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<h3 class="text-xl font-black text-zinc-900 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"
class="w-10 h-10 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-500"
>
<UserPlus v-if="!editingUser" :size="20" />
<Edit2 v-else :size="20" />
@@ -440,7 +440,7 @@
<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"
class="p-3 bg-zinc-100/50 hover:bg-zinc-100 text-zinc-500 hover:text-zinc-700 rounded-2xl transition-all"
@click="closeModal"
>
<X :size="20" />
@@ -460,7 +460,7 @@
/>
<input
v-model="userForm.name"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
class="w-full bg-white border border-zinc-200 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-blue-200 transition-all text-zinc-700"
placeholder="请输入真实姓名"
type="text"
>
@@ -478,7 +478,7 @@
<input
v-model="userForm.username"
:disabled="!!editingUser"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200 disabled:opacity-50"
class="w-full bg-white border border-zinc-200 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-blue-200 transition-all text-zinc-700 disabled:opacity-50"
placeholder="登录唯一标识"
type="text"
>
@@ -497,7 +497,7 @@
/>
<input
v-model="userForm.password"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-blue-500/30 transition-all text-zinc-200"
class="w-full bg-white border border-zinc-200 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-blue-200 transition-all text-zinc-700"
placeholder="设置安全访问密码"
type="password"
>
@@ -571,7 +571,7 @@
<div class="flex gap-3 mt-10">
<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"
class="flex-1 px-6 py-4 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="closeModal"
>
取消操作
@@ -606,7 +606,7 @@
@click="closeResetPassword"
>
<div
class="bg-zinc-900 border border-zinc-800 w-full max-w-md rounded-3xl overflow-hidden shadow-2xl"
class="bg-white border border-zinc-200 w-full max-w-md rounded-3xl overflow-hidden shadow-2xl"
@click.stop
>
<div class="p-8 text-center">
@@ -615,10 +615,10 @@
>
<Lock :size="32" />
</div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight">重置访问密码</h3>
<h3 class="text-xl font-black text-zinc-900 tracking-tight">重置访问密码</h3>
<p class="text-xs text-zinc-500 mt-2 mb-8">
正在为
<span class="text-zinc-200 font-bold">{{ resetPasswordUser.name }}</span> 修改登录凭据
<span class="text-zinc-700 font-bold">{{ resetPasswordUser.name }}</span> 修改登录凭据
</p>
<div class="space-y-4 text-left">
@@ -633,7 +633,7 @@
/>
<input
v-model="passwordForm.password"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-500/30 transition-all text-zinc-200"
class="w-full bg-white border border-zinc-200 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-200 transition-all text-zinc-700"
placeholder="设置高强度新密码"
type="password"
>
@@ -650,7 +650,7 @@
/>
<input
v-model="passwordForm.confirmPassword"
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-500/30 transition-all text-zinc-200"
class="w-full bg-white border border-zinc-200 rounded-2xl pl-11 pr-4 py-3 text-xs focus:outline-none focus:border-amber-200 transition-all text-zinc-700"
placeholder="请再次输入以确认"
type="password"
>
@@ -668,7 +668,7 @@
<div class="flex gap-3 mt-8">
<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"
class="flex-1 px-6 py-4 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="closeResetPassword"
>
取消
@@ -703,13 +703,13 @@
@click="closeImportModal"
>
<div
class="bg-zinc-900 border border-zinc-800 w-full max-w-2xl rounded-3xl overflow-hidden shadow-2xl"
class="bg-white border border-zinc-200 w-full max-w-2xl rounded-3xl overflow-hidden shadow-2xl"
@click.stop
>
<div class="p-8">
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<h3 class="text-xl font-black text-zinc-900 tracking-tight flex items-center gap-3">
<div
class="w-10 h-10 rounded-2xl bg-emerald-600/10 flex items-center justify-center text-emerald-500"
>
@@ -722,7 +722,7 @@
</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"
class="p-3 bg-zinc-100/50 hover:bg-zinc-100 text-zinc-500 hover:text-zinc-700 rounded-2xl transition-all"
@click="closeImportModal"
>
<X :size="20" />
@@ -730,9 +730,9 @@
</div>
<div class="space-y-6 max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
<div class="p-5 bg-zinc-950/50 border border-zinc-800/50 rounded-3xl space-y-4">
<div class="flex items-center gap-2 text-zinc-300 font-bold text-sm mb-2">
<Info :size="16" class="text-blue-400" />
<div class="p-5 bg-white/70 border border-zinc-200 rounded-3xl space-y-4">
<div class="flex items-center gap-2 text-zinc-600 font-bold text-sm mb-2">
<Info :size="16" class="text-blue-700" />
导入说明
</div>
<p class="text-xs text-zinc-500 leading-relaxed">
@@ -740,16 +740,16 @@
注意:第一行可以是标题行(会自动跳过),角色字段必须匹配系统定义的角色。
</p>
<div class="overflow-hidden rounded-2xl border border-zinc-800/80">
<div class="overflow-hidden rounded-2xl border border-zinc-200/80">
<table class="w-full text-[10px] text-left">
<thead class="bg-zinc-900 text-zinc-400 uppercase tracking-tighter">
<thead class="bg-white text-zinc-400 uppercase tracking-tighter">
<tr>
<th class="px-3 py-2 border-b border-zinc-800">姓名</th>
<th class="px-3 py-2 border-b border-zinc-800">账号名</th>
<th class="px-3 py-2 border-b border-zinc-800">密码</th>
<th class="px-3 py-2 border-b border-zinc-800">角色</th>
<th class="px-3 py-2 border-b border-zinc-800">年级</th>
<th class="px-3 py-2 border-b border-zinc-800">班级</th>
<th class="px-3 py-2 border-b border-zinc-200">姓名</th>
<th class="px-3 py-2 border-b border-zinc-200">账号名</th>
<th class="px-3 py-2 border-b border-zinc-200">密码</th>
<th class="px-3 py-2 border-b border-zinc-200">角色</th>
<th class="px-3 py-2 border-b border-zinc-200">年级</th>
<th class="px-3 py-2 border-b border-zinc-200">班级</th>
</tr>
</thead>
<tbody class="text-zinc-500">
@@ -767,7 +767,7 @@
<div class="flex items-center justify-between gap-4">
<div
class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-2xl text-[10px] text-blue-400/80 leading-relaxed flex-1"
class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-2xl text-[10px] text-blue-700/80 leading-relaxed flex-1"
>
<strong>支持的角色:</strong>
<span v-if="isSuperAdmin"
@@ -804,15 +804,15 @@
@change="handleFileUpload"
>
<div
class="w-full py-10 border-2 border-dashed border-zinc-800 group-hover:border-emerald-500/50 group-hover:bg-emerald-500/5 rounded-3xl transition-all flex flex-col items-center justify-center gap-3"
class="w-full py-10 border-2 border-dashed border-zinc-200 group-hover:border-emerald-500/50 group-hover:bg-emerald-500/5 rounded-3xl transition-all flex flex-col items-center justify-center gap-3"
>
<div
class="w-12 h-12 rounded-2xl bg-zinc-900 flex items-center justify-center text-zinc-600 group-hover:text-emerald-500 transition-colors"
class="w-12 h-12 rounded-2xl bg-white flex items-center justify-center text-zinc-600 group-hover:text-emerald-500 transition-colors"
>
<Upload :size="24" />
</div>
<div class="text-center">
<p class="text-sm font-bold text-zinc-300">点击或拖拽上传 Excel 文件</p>
<p class="text-sm font-bold text-zinc-600">点击或拖拽上传 Excel 文件</p>
<p class="text-xs text-zinc-500 mt-1">仅支持 .xlsx 格式</p>
</div>
</div>
@@ -829,7 +829,7 @@
<div
v-if="importSuccess"
class="p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-2xl flex items-center gap-3 text-emerald-400 text-xs"
class="p-4 bg-emerald-500/10 border border-emerald-500/20 rounded-2xl flex items-center gap-3 text-emerald-700 text-xs"
>
<CheckCircle2 :size="16" />
{{ importSuccess }}
@@ -841,9 +841,9 @@
>预览数据 ({{ previewData.length }}条记录)</label
>
</div>
<div class="overflow-hidden rounded-3xl border border-zinc-800 bg-zinc-950/30">
<div class="overflow-hidden rounded-3xl border border-zinc-200 bg-white/60">
<table class="w-full text-xs text-left">
<thead class="bg-zinc-900/50 text-zinc-500">
<thead class="bg-zinc-50/50 text-zinc-500">
<tr>
<th class="px-4 py-3 font-medium">姓名</th>
<th class="px-4 py-3 font-medium">账号</th>
@@ -855,13 +855,13 @@
<tr
v-for="(row, index) in previewData.slice(0, 5)"
:key="index"
class="text-zinc-300"
class="text-zinc-600"
>
<td class="px-4 py-3">{{ row.name }}</td>
<td class="px-4 py-3">{{ row.username }}</td>
<td class="px-4 py-3">
<span
class="px-2 py-0.5 bg-zinc-800 rounded-md text-[10px] text-zinc-400 uppercase"
class="px-2 py-0.5 bg-zinc-100 rounded-md text-[10px] text-zinc-400 uppercase"
>{{ row.role }}</span
>
</td>
@@ -883,7 +883,7 @@
<div class="flex gap-3 mt-8">
<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"
class="flex-1 px-6 py-4 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="closeImportModal"
>
取消
@@ -945,15 +945,15 @@
@click="closeUserDetailModal"
>
<div
class="bg-zinc-900 border border-zinc-800 w-full max-w-3xl rounded-3xl overflow-hidden shadow-2xl"
class="bg-white border border-zinc-200 w-full max-w-3xl rounded-3xl overflow-hidden shadow-2xl"
@click.stop
>
<div class="p-8">
<div class="flex items-center justify-between mb-8">
<div>
<h3 class="text-xl font-black text-zinc-100 tracking-tight flex items-center gap-3">
<h3 class="text-xl font-black text-zinc-900 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"
class="w-10 h-10 rounded-2xl bg-blue-50 flex items-center justify-center text-blue-500"
>
<User :size="20" />
</div>
@@ -962,7 +962,7 @@
<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"
class="p-3 bg-zinc-100/50 hover:bg-zinc-100 text-zinc-500 hover:text-zinc-700 rounded-2xl transition-all"
@click="closeUserDetailModal"
>
<X :size="20" />
@@ -982,27 +982,27 @@
基本信息
</div>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
<div class="p-4 bg-white/70 border border-zinc-200 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
用户 ID
</div>
<div class="text-sm font-bold text-zinc-300">{{ selectedUserDetail.id }}</div>
<div class="text-sm font-bold text-zinc-600">{{ selectedUserDetail.id }}</div>
</div>
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
<div class="p-4 bg-white/70 border border-zinc-200 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
姓名
</div>
<div class="text-sm font-bold text-zinc-300">{{ selectedUserDetail.name }}</div>
<div class="text-sm font-bold text-zinc-600">{{ selectedUserDetail.name }}</div>
</div>
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
<div class="p-4 bg-white/70 border border-zinc-200 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
用户名
</div>
<div class="text-sm font-bold text-zinc-300">
<div class="text-sm font-bold text-zinc-600">
{{ selectedUserDetail.username }}
</div>
</div>
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
<div class="p-4 bg-white/70 border border-zinc-200 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
角色权限
</div>
@@ -1017,19 +1017,19 @@
</span>
</div>
</div>
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
<div class="p-4 bg-white/70 border border-zinc-200 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
年级
</div>
<div class="text-sm font-bold text-zinc-300">
<div class="text-sm font-bold text-zinc-600">
{{ selectedUserDetail.grade || '未设置' }}
</div>
</div>
<div class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-1">
<div class="p-4 bg-white/70 border border-zinc-200 rounded-2xl space-y-1">
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
班级
</div>
<div class="text-sm font-bold text-zinc-300">
<div class="text-sm font-bold text-zinc-600">
{{ selectedUserDetail.class || '未设置' }}
</div>
</div>
@@ -1046,7 +1046,7 @@
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div
class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl flex items-center justify-between"
class="p-4 bg-white/70 border border-zinc-200 rounded-2xl flex items-center justify-between"
>
<div class="space-y-1">
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
@@ -1089,7 +1089,7 @@
</div>
</div>
<div
class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl flex items-center justify-between"
class="p-4 bg-white/70 border border-zinc-200 rounded-2xl flex items-center justify-between"
>
<div class="space-y-1">
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
@@ -1113,7 +1113,7 @@
'w-8 h-8 rounded-xl flex items-center justify-center',
selectedUserDetail.meowNickname
? 'bg-emerald-500/10 text-emerald-500'
: 'bg-zinc-800 text-zinc-600'
: 'bg-zinc-100 text-zinc-600'
]"
>
<AtSign :size="16" />
@@ -1133,18 +1133,18 @@
</div>
<div class="space-y-3">
<div
class="flex items-center justify-between text-xs p-3 bg-zinc-950/30 rounded-xl border border-zinc-800/30"
class="flex items-center justify-between text-xs p-3 bg-white/60 rounded-xl border border-zinc-200/30"
>
<span class="text-zinc-500">最后登录</span>
<span class="text-zinc-300 font-medium">{{
<span class="text-zinc-600 font-medium">{{
formatDate(selectedUserDetail.lastLogin)
}}</span>
</div>
<div
class="flex items-center justify-between text-xs p-3 bg-zinc-950/30 rounded-xl border border-zinc-800/30"
class="flex items-center justify-between text-xs p-3 bg-white/60 rounded-xl border border-zinc-200/30"
>
<span class="text-zinc-500">登录 IP</span>
<span class="text-zinc-300 font-medium">{{
<span class="text-zinc-600 font-medium">{{
selectedUserDetail.lastLoginIp || '未知'
}}</span>
</div>
@@ -1159,18 +1159,18 @@
</div>
<div class="space-y-3">
<div
class="flex items-center justify-between text-xs p-3 bg-zinc-950/30 rounded-xl border border-zinc-800/30"
class="flex items-center justify-between text-xs p-3 bg-white/60 rounded-xl border border-zinc-200/30"
>
<span class="text-zinc-500">创建时间</span>
<span class="text-zinc-300 font-medium">{{
<span class="text-zinc-600 font-medium">{{
formatDate(selectedUserDetail.createdAt)
}}</span>
</div>
<div
class="flex items-center justify-between text-xs p-3 bg-zinc-950/30 rounded-xl border border-zinc-800/30"
class="flex items-center justify-between text-xs p-3 bg-white/60 rounded-xl border border-zinc-200/30"
>
<span class="text-zinc-500">最近更新</span>
<span class="text-zinc-300 font-medium">{{
<span class="text-zinc-600 font-medium">{{
formatDate(selectedUserDetail.updatedAt)
}}</span>
</div>
@@ -1201,21 +1201,21 @@
<div
v-else-if="statusLogs.length === 0"
class="py-12 text-center bg-zinc-950/30 border border-zinc-800/50 rounded-3xl"
class="py-12 text-center bg-white/60 border border-zinc-200 rounded-3xl"
>
<p class="text-xs text-zinc-600">暂无状态变更记录</p>
</div>
<div v-else class="space-y-4">
<div
class="relative pl-6 space-y-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-0.5 before:bg-zinc-800"
class="relative pl-6 space-y-6 before:absolute before:left-[11px] before:top-2 before:bottom-2 before:w-0.5 before:bg-zinc-100"
>
<div v-for="log in statusLogs" :key="log.id" class="relative">
<div
class="absolute -left-[23px] top-1.5 w-3 h-3 rounded-full bg-zinc-900 border-2 border-zinc-700 ring-4 ring-zinc-900"
class="absolute -left-[23px] top-1.5 w-3 h-3 rounded-full bg-white border-2 border-zinc-200 ring-4 ring-zinc-900"
/>
<div
class="p-4 bg-zinc-950/50 border border-zinc-800/50 rounded-2xl space-y-3"
class="p-4 bg-white/70 border border-zinc-200 rounded-2xl space-y-3"
>
<div class="flex items-center justify-between">
<div class="flex items-center gap-2">
@@ -1244,7 +1244,7 @@
<div
v-if="log.reason"
class="text-xs text-zinc-400 bg-zinc-900/50 p-2.5 rounded-xl border border-zinc-800/30 leading-relaxed"
class="text-xs text-zinc-400 bg-zinc-50/50 p-2.5 rounded-xl border border-zinc-200/30 leading-relaxed"
>
<span class="text-zinc-600 font-bold mr-1">原因:</span>
{{ log.reason }}
@@ -1252,12 +1252,12 @@
<div class="flex items-center gap-2 text-[10px]">
<div
class="w-4 h-4 rounded-full bg-zinc-800 flex items-center justify-center text-zinc-500"
class="w-4 h-4 rounded-full bg-zinc-100 flex items-center justify-center text-zinc-500"
>
<User :size="8" />
</div>
<span class="text-zinc-500">操作者:</span>
<span class="text-zinc-300 font-bold">{{
<span class="text-zinc-600 font-bold">{{
log.operator?.name || '系统'
}}</span>
</div>
@@ -1277,7 +1277,7 @@
<div class="mt-8">
<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"
class="w-full px-6 py-4 bg-zinc-100 hover:bg-zinc-200 text-zinc-600 text-xs font-black rounded-2xl transition-all uppercase tracking-widest"
@click="closeUserDetailModal"
>
关闭窗口
+15 -15
View File
@@ -26,16 +26,16 @@
>
<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"
class="relative w-full max-w-4xl max-h-[85vh] flex flex-col bg-white border border-zinc-200 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"
class="flex items-center justify-between px-6 py-4 border-b border-zinc-200 bg-zinc-50/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"
class="p-2 text-zinc-400 hover:text-zinc-900 hover:bg-zinc-100 rounded-lg transition-colors"
@click="$emit('close')"
>
<svg
@@ -56,7 +56,7 @@
</div>
<!-- 主体内容 -->
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-zinc-900">
<div class="flex-1 flex flex-col min-h-0 overflow-hidden bg-white">
<!-- 加载状态 -->
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center">
<LoadingState message="加载数据中..." spinner-type="circle" />
@@ -80,7 +80,7 @@
<!-- 内容 -->
<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="px-6 py-4 bg-white border-b border-zinc-200">
<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>
@@ -99,7 +99,7 @@
<!-- 工具栏 (标签页 + 过滤) -->
<div
class="flex flex-col sm:flex-row items-center justify-between border-b border-zinc-800 bg-zinc-900/50"
class="flex flex-col sm:flex-row items-center justify-between border-b border-zinc-200 bg-zinc-50/50"
>
<!-- 标签页 -->
<div class="flex w-full sm:w-auto overflow-x-auto no-scrollbar">
@@ -110,7 +110,7 @@
:class="
activeTab === tab.id
? 'text-blue-500'
: 'text-zinc-400 hover:text-white hover:bg-zinc-800/50'
: 'text-zinc-400 hover:text-zinc-900 hover:bg-zinc-100/50'
"
@click="activeTab = tab.id"
>
@@ -120,7 +120,7 @@
:class="
activeTab === tab.id
? 'bg-blue-500/10 text-blue-500'
: 'bg-zinc-800 text-zinc-500'
: 'bg-zinc-100 text-zinc-500'
"
>
{{ getTabCount(tab.id) }}
@@ -135,7 +135,7 @@
<!-- 学期过滤器 -->
<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"
class="w-full sm:w-auto px-4 py-2 border-t sm:border-t-0 sm:border-l border-zinc-200 flex items-center gap-3 bg-zinc-50/30"
>
<CustomSelect
v-model="selectedSemester"
@@ -160,7 +160,7 @@
<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"
class="group flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-4 rounded-lg border border-zinc-200 bg-zinc-100/20 hover:bg-zinc-100/50 hover:border-zinc-200 transition-all duration-200"
>
<!-- 歌曲信息 -->
<div class="flex-1 min-w-0">
@@ -168,7 +168,7 @@
<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"
class="px-1.5 py-0.5 rounded text-[10px] font-medium bg-zinc-100 text-zinc-400 border border-zinc-200"
>
{{ song.semester }}
</span>
@@ -214,7 +214,7 @@
<!-- 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-xs text-zinc-600">{{ song.requester.name }}</div>
<div class="text-[10px] text-zinc-500">
{{ song.requester.grade }}{{ song.requester.class }}
</div>
@@ -224,10 +224,10 @@
<!-- 投稿人信息 (移动端) -->
<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"
class="sm:hidden pt-3 mt-1 border-t border-zinc-200 flex justify-between items-center text-xs"
>
<span class="text-zinc-500">投稿人:</span>
<span class="text-zinc-300">
<span class="text-zinc-600">
{{ song.requester.name }}
<span class="text-zinc-500 ml-1">
{{ song.requester.grade }}{{ song.requester.class }}
@@ -422,7 +422,7 @@ const getStatusClasses = (song) => {
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-blue-500/10 text-blue-500 border-blue-200'
}
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
}
+27 -27
View File
@@ -202,9 +202,9 @@ watch(
}
.modal-content {
background: #1a1a1a;
background: var(--surface-primary, var(--bg-primary, #ffffff));
border-radius: 16px;
border: 1px solid #2a2a2a;
border: 1px solid var(--border-primary, #2a2a2a);
width: 100%;
max-width: 500px;
max-height: 80vh;
@@ -223,14 +223,14 @@ watch(
.modal-title {
font-size: 20px;
font-weight: 600;
color: #ffffff;
color: var(--text-primary, #ffffff);
margin: 0;
}
.close-btn {
background: none;
border: none;
color: #888;
color: var(--text-tertiary, #888888);
cursor: pointer;
padding: 8px;
border-radius: 8px;
@@ -238,8 +238,8 @@ watch(
}
.close-btn:hover {
background: #2a2a2a;
color: #ffffff;
background: var(--bg-tertiary, #2a2a2a);
color: var(--text-primary, #ffffff);
}
.close-btn svg {
@@ -254,22 +254,22 @@ watch(
}
.song-info {
background: #2a2a2a;
background: var(--bg-tertiary, #2a2a2a);
border-radius: 12px;
padding: 20px;
margin-bottom: 24px;
border: 1px solid #3a3a3a;
border: 1px solid var(--border-secondary, #3a3a3a);
}
.song-title {
font-size: 18px;
font-weight: 600;
color: #ffffff;
color: var(--text-primary, #ffffff);
margin: 0 0 8px 0;
}
.song-artist {
color: #888;
color: var(--text-tertiary, #888888);
margin: 0 0 16px 0;
font-size: 14px;
}
@@ -304,7 +304,7 @@ watch(
.spinner {
width: 32px;
height: 32px;
border: 3px solid #3a3a3a;
border: 3px solid var(--border-secondary, #3a3a3a);
border-top: 3px solid #667eea;
border-radius: 50%;
animation: spin 1s linear infinite;
@@ -342,7 +342,7 @@ watch(
.retry-btn {
background: #667eea;
color: white;
color: var(--text-primary, #09090b);
border: none;
padding: 8px 16px;
border-radius: 8px;
@@ -366,12 +366,12 @@ watch(
align-items: center;
justify-content: space-between;
padding-bottom: 12px;
border-bottom: 1px solid #3a3a3a;
border-bottom: 1px solid var(--border-secondary, #3a3a3a);
}
.voters-title {
font-weight: 600;
color: #ffffff;
color: var(--text-primary, #ffffff);
font-size: 16px;
}
@@ -388,15 +388,15 @@ watch(
align-items: center;
justify-content: space-between;
padding: 12px 16px;
background: #2a2a2a;
background: var(--bg-tertiary, #2a2a2a);
border-radius: 8px;
border: 1px solid #3a3a3a;
border: 1px solid var(--border-secondary, #3a3a3a);
transition: all 0.2s ease;
}
.voter-item:hover {
background: #333333;
border-color: #4a4a4a;
background: var(--bg-quaternary, #333333);
border-color: var(--border-secondary, #4a4a4a);
}
.voter-info {
@@ -414,7 +414,7 @@ watch(
display: flex;
align-items: center;
justify-content: center;
color: white;
color: var(--text-primary, #09090b);
font-weight: 600;
font-size: 14px;
}
@@ -426,18 +426,18 @@ watch(
}
.voter-name {
color: #ffffff;
color: var(--text-primary, #ffffff);
font-weight: 500;
font-size: 14px;
}
.vote-time {
color: #888;
color: var(--text-tertiary, #888888);
font-size: 12px;
}
.voter-number {
color: #888;
color: var(--text-tertiary, #888888);
font-size: 12px;
font-weight: 500;
}
@@ -454,11 +454,11 @@ watch(
.empty-icon {
width: 48px;
height: 48px;
color: #666;
color: var(--text-quaternary, #666666);
}
.empty-state p {
color: #888;
color: var(--text-tertiary, #888888);
margin: 0;
}
@@ -469,8 +469,8 @@ watch(
}
.close-button {
background: #3a3a3a;
color: #ffffff;
background: var(--bg-quaternary, #3a3a3a);
color: var(--text-primary, #ffffff);
border: none;
padding: 12px 24px;
border-radius: 8px;
@@ -490,7 +490,7 @@ watch(
}
.voters-container::-webkit-scrollbar-track {
background: #2a2a2a;
background: var(--bg-tertiary, #2a2a2a);
border-radius: 3px;
}
+30 -30
View File
@@ -1,36 +1,36 @@
<template>
<div class="space-y-6 max-w-5xl mx-auto pb-24 md:pb-8 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="flex flex-col gap-2">
<h2 class="text-2xl md:text-3xl font-bold tracking-tight text-zinc-100">工作流与使用教程</h2>
<p class="text-zinc-400">了解校园广播站点歌系统的标准操作流程和功能说明,帮助您更高效地管理点歌与排期。</p>
<h2 class="text-2xl md:text-3xl font-bold tracking-tight text-zinc-900">工作流与使用教程</h2>
<p class="text-zinc-400">了解宝一外校园点歌系统的标准操作流程和功能说明,帮助您更高效地管理点歌与排期。</p>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<!-- 歌曲管理流程 -->
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
<div class="flex items-center gap-3 mb-4 text-blue-400">
<div class="bg-zinc-50/50 border border-zinc-200 rounded-xl p-6 hover:border-zinc-200 transition-colors">
<div class="flex items-center gap-3 mb-4 text-blue-700">
<CheckCircle2 class="w-6 h-6" />
<h3 class="text-xl font-semibold">1. 歌曲管理与审核</h3>
</div>
<ol class="space-y-4 text-zinc-300">
<ol class="space-y-4 text-zinc-600">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center text-sm font-bold">1</span>
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-700 flex items-center justify-center text-sm font-bold">1</span>
<div>
<p class="font-medium text-zinc-100">查看所有歌曲</p>
<p class="font-medium text-zinc-900">查看所有歌曲</p>
<p class="text-sm text-zinc-500 mt-1">进入“歌曲管理”页面,系统会列出所有用户提交的歌曲。</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center text-sm font-bold">2</span>
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-700 flex items-center justify-center text-sm font-bold">2</span>
<div>
<p class="font-medium text-zinc-100">试听与检查</p>
<p class="font-medium text-zinc-900">试听与检查</p>
<p class="text-sm text-zinc-500 mt-1">点击播放按钮试听歌曲质量,检查留言内容是否合规,确保没有不当言论。</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-400 flex items-center justify-center text-sm font-bold">3</span>
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-blue-500/20 text-blue-700 flex items-center justify-center text-sm font-bold">3</span>
<div>
<p class="font-medium text-zinc-100">拒绝或修改状态</p>
<p class="font-medium text-zinc-900">拒绝或修改状态</p>
<p class="text-sm text-zinc-500 mt-1">如不符合要求,可以在歌曲管理中拒绝该歌曲,并填写退回原因;审核通过的歌曲可直接用于排期。</p>
</div>
</li>
@@ -38,30 +38,30 @@
</div>
<!-- 排期流程 -->
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
<div class="flex items-center gap-3 mb-4 text-emerald-400">
<div class="bg-zinc-50/50 border border-zinc-200 rounded-xl p-6 hover:border-zinc-200 transition-colors">
<div class="flex items-center gap-3 mb-4 text-emerald-700">
<CalendarDays class="w-6 h-6" />
<h3 class="text-xl font-semibold">2. 节目排期流程</h3>
</div>
<ol class="space-y-4 text-zinc-300">
<ol class="space-y-4 text-zinc-600">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-sm font-bold">1</span>
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-700 flex items-center justify-center text-sm font-bold">1</span>
<div>
<p class="font-medium text-zinc-100">选择日期与时段</p>
<p class="font-medium text-zinc-900">选择日期与时段</p>
<p class="text-sm text-zinc-500 mt-1">在“排期管理”中,选择目标播放日期和播出时段。</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-sm font-bold">2</span>
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-700 flex items-center justify-center text-sm font-bold">2</span>
<div>
<p class="font-medium text-zinc-100">拖拽编排</p>
<p class="font-medium text-zinc-900">拖拽编排</p>
<p class="text-sm text-zinc-500 mt-1">将左侧“待排歌曲”拖拽至右侧的“播放列表”中,可上下拖拽调整播放顺序。</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-400 flex items-center justify-center text-sm font-bold">3</span>
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-emerald-500/20 text-emerald-700 flex items-center justify-center text-sm font-bold">3</span>
<div>
<p class="font-medium text-zinc-100">发布排期</p>
<p class="font-medium text-zinc-900">发布排期</p>
<p class="text-sm text-zinc-500 mt-1">排期完成后,点击“发布排期”。发布后用户即可在前端页面看到该日期的节目单。</p>
</div>
</li>
@@ -69,23 +69,23 @@
</div>
<!-- 播音流程 -->
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
<div class="bg-zinc-50/50 border border-zinc-200 rounded-xl p-6 hover:border-zinc-200 transition-colors">
<div class="flex items-center gap-3 mb-4 text-purple-400">
<Mic2 class="w-6 h-6" />
<h3 class="text-xl font-semibold">3. 播音与打印</h3>
</div>
<ol class="space-y-4 text-zinc-300">
<ol class="space-y-4 text-zinc-600">
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 flex items-center justify-center text-sm font-bold">1</span>
<div>
<p class="font-medium text-zinc-100">生成歌单图片/打印</p>
<p class="text-sm text-zinc-500 mt-1">播音前,前往“图片歌单”生成今日节目单图片用于宣传,或在“打印排期”中生成播音员用的纸质稿。</p>
<p class="font-medium text-zinc-900">生成歌单图片/打印</p>
<p class="text-sm text-zinc-500 mt-1">播音前,前往“导入酷狗”生成今日节目单图片用于宣传,或在“打印排期”中生成播音员用的纸质稿。</p>
</div>
</li>
<li class="flex gap-3">
<span class="flex-shrink-0 w-6 h-6 rounded-full bg-purple-500/20 text-purple-400 flex items-center justify-center text-sm font-bold">2</span>
<div>
<p class="font-medium text-zinc-100">标记已播</p>
<p class="font-medium text-zinc-900">标记已播</p>
<p class="text-sm text-zinc-500 mt-1">播音结束后,系统可自动/手动将排期内的歌曲标记为“已播”,方便统计数据。</p>
</div>
</li>
@@ -93,22 +93,22 @@
</div>
<!-- 常见问题 -->
<div class="bg-zinc-900/50 border border-zinc-800 rounded-xl p-6 hover:border-zinc-700 transition-colors">
<div class="flex items-center gap-3 mb-4 text-amber-400">
<div class="bg-zinc-50/50 border border-zinc-200 rounded-xl p-6 hover:border-zinc-200 transition-colors">
<div class="flex items-center gap-3 mb-4 text-amber-700">
<HelpCircle class="w-6 h-6" />
<h3 class="text-xl font-semibold">常见问题 (FAQ)</h3>
</div>
<div class="space-y-4">
<div>
<h4 class="text-sm font-medium text-zinc-200">Q: 为什么找不到某些待排歌曲?</h4>
<h4 class="text-sm font-medium text-zinc-700">Q: 为什么找不到某些待排歌曲?</h4>
<p class="text-sm text-zinc-500 mt-1">A: 请检查是否在排期页面的左侧选错了标签(如普通投稿/重播申请),或者该歌曲还未通过审核。</p>
</div>
<div>
<h4 class="text-sm font-medium text-zinc-200">Q: 什么是选曲日期?</h4>
<h4 class="text-sm font-medium text-zinc-700">Q: 什么是选曲日期?</h4>
<p class="text-sm text-zinc-500 mt-1">A: 选曲日期是用户在点歌时可选择的期望播放日期。请在“选曲日期”设置中提前开放可用的日期。</p>
</div>
<div>
<h4 class="text-sm font-medium text-zinc-200">Q: 权限不足怎么办?</h4>
<h4 class="text-sm font-medium text-zinc-700">Q: 权限不足怎么办?</h4>
<p class="text-sm text-zinc-500 mt-1">A: 不同的功能需要不同的管理员角色(如歌曲管理员、超级管理员),请联系超级管理员调整您的角色权限。</p>
</div>
</div>
+27 -3
View File
@@ -3,7 +3,7 @@
<div class="form-header">
<h2>{{ isBindMode ? '绑定账号' : '欢迎回来' }}</h2>
<p v-if="isBindMode">即将绑定 {{ providerName }} 账号: {{ providerUsername }}</p>
<p v-else>登录您的VoiceHub账户</p>
<p v-else>登录宝一外校园点歌系统账户</p>
</div>
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleLogin">
@@ -74,6 +74,11 @@
</svg>
</button>
</div>
<div class="forgot-password-row">
<NuxtLink to="/forgot-password" class="forgot-password-link">
忘记密码?
</NuxtLink>
</div>
</div>
<div v-if="error" class="error-container">
@@ -138,10 +143,10 @@
</button>
</div>
<AuthOAuthButtons v-if="!isBindMode" />
<AuthOAuthButtons v-if="!isBindMode" :always-show="true" />
<div class="form-footer">
<p class="help-text">不同VoiceHub平台的账号不互通</p>
<p class="help-text">不同宝一外校园点歌系统平台的账号不互通</p>
<div style="margin-top: 16px;">
<NuxtLink to="/register" class="register-link-btn">
还没有账号?去注册
@@ -556,6 +561,25 @@ const handleWebAuthnLogin = async () => {
height: 20px;
}
.forgot-password-row {
display: flex;
justify-content: flex-end;
margin-top: 10px;
}
.forgot-password-link {
font-size: 13px;
color: var(--primary);
text-decoration: none;
font-weight: var(--font-medium);
transition: opacity 0.2s ease;
}
.forgot-password-link:hover {
opacity: 0.8;
text-decoration: underline;
}
.form-footer {
margin-top: 24px;
text-align: center;
+40
View File
@@ -117,6 +117,45 @@
</button>
</div>
<!-- 微信 (聚合登录 aggregate) -->
<div v-if="config.public.oauth.aggregate" :class="itemClass">
<div class="flex items-center gap-4">
<div
class="w-12 h-12 rounded-xl bg-[#07C160]/10 flex items-center justify-center border border-[#07C160]/25 text-[#07C160]"
>
<svg viewBox="0 0 24 24" width="26" height="26" fill="currentColor" aria-hidden="true">
<path d="M8.69 3.5C5.06 3.5 2.12 6.03 2.12 9.14c0 1.79 1.04 3.37 2.66 4.41l-.68 2.05 2.56-1.28c.52.11 1.06.17 1.61.17h.51c-.08-.36-.11-.73-.08-1.09H8.69c-2.59 0-4.69-1.93-4.69-4.31s2.1-4.31 4.69-4.31h1.37c.25-.98 1.09-1.7 2.09-1.7.43 0 .83.13 1.17.36C12.61 3.06 10.76 3.5 8.69 3.5zM10.4 5.62c-.93 0-1.69.76-1.69 1.69s.76 1.69 1.69 1.69 1.69-.76 1.69-1.69-.76-1.69-1.69-1.69zm7.19.91c-2.13 0-3.86 1.7-3.86 3.8s1.73 3.8 3.86 3.8c.5 0 1.02-.1 1.47-.27L21.5 15l-.67-1.95c1.21-.88 2-2.24 2-3.72 0-2.1-1.73-3.8-3.84-3.8-.1 0-.2 0-.4.01zm-1.84 2.69c-.66 0-1.2.54-1.2 1.2s.54 1.2 1.2 1.2 1.2-.54 1.2-1.2-.54-1.2-1.2-1.2z"/>
</svg>
</div>
<div class="flex flex-col">
<span class="text-sm font-bold text-zinc-200">{{
getProviderDisplayName('aggregate')
}}</span>
<span v-if="aggregateIdentity" class="text-[11px] text-[#07C160] font-semibold mt-0.5">{{
aggregateIdentity.providerUsername
}}</span>
<span v-else class="text-[11px] text-zinc-500 mt-0.5">未绑定</span>
</div>
</div>
<button
v-if="aggregateIdentity"
class="px-4 py-2 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-500 text-sm font-semibold rounded-xl transition-all disabled:opacity-50"
:disabled="actionLoading"
@click="confirmUnbind('aggregate')"
>
{{ actionLoading ? '处理中...' : '解绑' }}
</button>
<button
v-else
class="px-5 py-2.5 bg-[#07C160] hover:bg-[#06ad56] active:bg-[#059a4c] text-white text-sm font-bold rounded-xl shadow-lg shadow-[#07C160]/30 transition-all active:scale-95 disabled:opacity-50"
:disabled="actionLoading"
@click="handleBind('aggregate')"
>
{{ actionLoading ? '跳转中...' : '立即绑定微信' }}
</button>
</div>
<!-- WebAuthn / Passkey -->
<div
v-if="isWebAuthnSupported || webauthnIdentities.length > 0 || !isSecureContext"
@@ -350,6 +389,7 @@ const itemClass =
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 aggregateIdentity = computed(() => identities.value.find((i) => i.provider === 'aggregate'))
const webauthnIdentities = computed(() => identities.value.filter((i) => i.provider === 'webauthn'))
const fetchIdentities = async () => {
+169 -12
View File
@@ -1,15 +1,13 @@
<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 v-if="hasEnabledProviders" class="oauth-buttons-section">
<div class="oauth-divider">
<span>或使用第三方账号登录</span>
</div>
<div class="flex justify-center gap-4">
<div class="oauth-buttons">
<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]"
class="oauth-btn oauth-btn-github"
title="使用 GitHub 登录"
@click="loginWith('github')"
>
@@ -19,7 +17,7 @@
<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]"
class="oauth-btn oauth-btn-google"
title="使用 Google 登录"
@click="loginWith('google')"
>
@@ -29,26 +27,185 @@
<button
v-if="config.public.oauth.aggregate"
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-[#12B7F5] hover:text-white hover:border-[#12B7F5]"
title="使用 QQ / 微信登录"
class="oauth-btn oauth-btn-wechat"
title="使用微信登录"
@click="loginWith('aggregate')"
>
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
<path d="M8.5 4C4.9 4 2 6.8 2 10.3c0 2 1.1 3.7 2.8 4.9L4 18l3-1.5c.5.1 1 .2 1.5.2h.6c-.1-.4-.1-.8 0-1.2H8.5C5.5 15.5 3 13.2 3 10.3 3 7.4 5.5 5 8.5 5h1.6c.3-1.1 1.3-1.9 2.5-1.9.5 0 1 .2 1.4.5C13.3 3 10.7 4 8.5 4zM10.1 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm8.4 1.2c-2.5 0-4.5 2-4.5 4.5s2 4.5 4.5 4.5c.6 0 1.2-.1 1.7-.3L22 17.5l-.8-2.3c1.4-1 2.3-2.6 2.3-4.3C23.5 9.2 21.5 7.2 19 7.2c-.5 0-1 0-1.4.1C16.9 7.8 17.9 7.2 19 7.2zM17 9.7c-.8 0-1.5.7-1.5 1.5s.7 1.5 1.5 1.5 1.5-.7 1.5-1.5-.7-1.5-1.5-1.5z"/>
<svg viewBox="0 0 24 24" width="28" height="28" fill="currentColor" aria-hidden="true">
<path d="M8.69 3.5C5.06 3.5 2.12 6.03 2.12 9.14c0 1.79 1.04 3.37 2.66 4.41l-.68 2.05 2.56-1.28c.52.11 1.06.17 1.61.17h.51c-.08-.36-.11-.73-.08-1.09H8.69c-2.59 0-4.69-1.93-4.69-4.31s2.1-4.31 4.69-4.31h1.37c.25-.98 1.09-1.7 2.09-1.7.43 0 .83.13 1.17.36C12.61 3.06 10.76 3.5 8.69 3.5zM10.4 5.62c-.93 0-1.69.76-1.69 1.69s.76 1.69 1.69 1.69 1.69-.76 1.69-1.69-.76-1.69-1.69-1.69zm7.19.91c-2.13 0-3.86 1.7-3.86 3.8s1.73 3.8 3.86 3.8c.5 0 1.02-.1 1.47-.27L21.5 15l-.67-1.95c1.21-.88 2-2.24 2-3.72 0-2.1-1.73-3.8-3.84-3.8-.1 0-.2 0-.4.01zm-1.84 2.69c-.66 0-1.2.54-1.2 1.2s.54 1.2 1.2 1.2 1.2-.54 1.2-1.2-.54-1.2-1.2-1.2z"/>
</svg>
</button>
</div>
</div>
<div v-else-if="showPlaceholderHint" class="oauth-buttons-section">
<div class="oauth-divider oauth-divider-dim">
<span>第三方登录</span>
</div>
<div class="oauth-empty-hint">
<svg
class="oauth-empty-icon"
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
>
<path d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<p>请联系管理员在 <code>.env</code> 中配置第三方 OAuth 应用后即可启用</p>
</div>
</div>
</template>
<script setup lang="ts">
interface Props {
alwaysShow?: boolean
}
const props = withDefaults(defineProps<Props>(), {
alwaysShow: false
})
const config = useRuntimeConfig()
const hasEnabledProviders = computed(() => {
return config.public.oauth && Object.values(config.public.oauth).some((enabled) => enabled)
})
const showPlaceholderHint = computed(() => false)
const loginWith = (provider: string) => {
navigateTo(`/api/auth/${provider}`, { external: true })
}
</script>
<style scoped>
.oauth-buttons-section {
width: 100%;
margin-top: 24px;
}
.oauth-divider {
display: flex;
align-items: center;
text-align: center;
margin: 0 0 18px 0;
color: var(--text-quaternary, #9ca3af);
font-size: 12px;
}
.oauth-divider::before,
.oauth-divider::after {
content: '';
flex: 1;
border-bottom: 1px solid var(--border-secondary, #d1d5db);
opacity: 0.85;
}
.oauth-divider span {
padding: 0 12px;
white-space: nowrap;
}
.oauth-divider-dim {
color: var(--text-quaternary, #9ca3af);
opacity: 0.8;
}
.oauth-buttons {
display: flex;
justify-content: center;
gap: 16px;
}
.oauth-btn {
width: 48px;
height: 48px;
border-radius: 999px;
border: 1px solid var(--border-secondary, #d1d5db);
background: var(--bg-secondary, #f5f7fa);
color: var(--text-primary, #1a2332);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
transition:
transform 0.2s ease,
box-shadow 0.2s ease,
background-color 0.2s ease,
border-color 0.2s ease,
color 0.2s ease;
}
.oauth-btn:hover {
transform: translateY(-1px);
box-shadow: var(--shadow-sm);
}
.oauth-btn-github:hover {
background: var(--bg-tertiary, #24292e);
color: var(--text-primary, #ffffff);
border-color: var(--border-secondary, #24292e);
}
.oauth-btn-google:hover {
background: var(--card-bg, #ffffff);
color: var(--text-primary, #111111);
border-color: #dadce0;
}
.oauth-btn-wechat {
width: 56px;
height: 56px;
background: #07C160;
color: #ffffff;
border-color: #07C160;
box-shadow: 0 4px 12px rgba(7, 193, 96, 0.25);
}
.oauth-btn-wechat:hover {
background: #06ad56;
color: #ffffff;
border-color: #06ad56;
box-shadow: 0 6px 18px rgba(7, 193, 96, 0.35);
}
.oauth-btn-wechat:active {
background: #059a4c;
border-color: #059a4c;
transform: translateY(0);
box-shadow: 0 2px 8px rgba(7, 193, 96, 0.25);
}
.oauth-empty-hint {
display: flex;
align-items: center;
justify-content: center;
gap: 10px;
padding: 14px 16px;
border: 1px dashed var(--border-secondary, #d1d5db);
border-radius: var(--radius-lg);
background: var(--bg-tertiary, #edf0f5);
color: var(--text-tertiary, #6b7280);
font-size: 13px;
}
.oauth-empty-icon {
width: 18px;
height: 18px;
opacity: 0.8;
flex-shrink: 0;
}
.oauth-empty-hint p {
margin: 0;
line-height: 1.5;
}
.oauth-empty-hint code {
background: var(--input-bg, #ffffff);
color: var(--primary, #1e40af);
padding: 1px 6px;
border-radius: 4px;
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
font-size: 12px;
}
</style>
+7 -7
View File
@@ -224,24 +224,24 @@ const handleUpdateProfile = async () => {
<style scoped>
.custom-select-profile :deep(> .flex) {
padding: 10px 16px;
background-color: #09090b;
border: 1px solid #27272a;
background-color: var(--input-bg, #09090b);
border: 1px solid var(--input-border, #27272a);
border-radius: 0.75rem;
color: #f4f4f5;
color: var(--input-text, #f4f4f5);
font-size: 0.875rem;
line-height: 1.25rem;
height: 46px;
}
.custom-select-profile :deep(> .flex:hover) {
border-color: #3f3f46;
border-color: var(--input-border-focus, #3f3f46);
}
.custom-select-profile :deep(> .border-blue-500\/50) {
border-color: rgba(34, 197, 94, 0.5) !important;
--tw-ring-color: rgba(34, 197, 94, 0.5);
border-color: var(--primary, rgba(34, 197, 94, 0.5)) !important;
--tw-ring-color: var(--primary-light, rgba(34, 197, 94, 0.5));
box-shadow: var(--tw-ring-inset) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color);
background-color: #09090b !important;
background-color: var(--input-bg, #09090b) !important;
}
.custom-select-profile :deep(.text-\[11px\]) {
+2 -2
View File
@@ -2,7 +2,7 @@
<div class="login-form">
<div class="form-header">
<h2>注册账号</h2>
<p>创建您的VoiceHub账户</p>
<p>创建您的宝一外校园点歌系统账户</p>
</div>
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleRegister">
@@ -575,7 +575,7 @@ const handleRegister = async () => {
width: 100%;
padding: 16px;
background: var(--primary);
color: #ffffff;
color: var(--text-primary, #ffffff);
border: none;
border-radius: var(--radius-lg);
font-size: 16px;
@@ -209,7 +209,7 @@ input[type='range']::-webkit-slider-thumb {
-webkit-appearance: none;
width: 18px;
height: 18px;
background: #2563eb; /* blue-600 */
background: var(--bg-tertiary, #2563eb); /* blue-600 */
border-radius: 50%;
cursor: pointer;
transition: all 0.2s ease;
@@ -218,7 +218,7 @@ input[type='range']::-webkit-slider-thumb {
input[type='range']::-moz-range-thumb {
width: 18px;
height: 18px;
background: #2563eb;
background: var(--bg-tertiary, #2563eb);
border-radius: 50%;
cursor: pointer;
border: none;
@@ -160,7 +160,7 @@ const jumpSeek = (line: LyricLineMouseEvent) => {
:deep(.am-lyric .is-active),
:deep(.am-lyric .lyric-line.current),
:deep(.am-lyric .lyric-line.is-current) {
color: #ffffff;
color: var(--text-primary, #ffffff);
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
}
}
@@ -824,6 +824,6 @@ onBeforeUnmount(() => {
align-items: center;
justify-content: center;
font-size: 22px;
color: #fff;
color: var(--text-primary, #ffffff);
}
</style>
+13 -8
View File
@@ -23,11 +23,16 @@
</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">响应耗时 {{ responseTime }}ms</span>
<span class="footer-item">
<NuxtLink class="voicehub-link" to="/changelog">
<a
class="voicehub-link"
href="https://github.com/laoshuikaixue/VoiceHub"
target="_blank"
rel="noopener noreferrer"
>
{{ displayName }} v{{ systemVersion }}
</NuxtLink>
</a>
</span>
<span v-if="isNetlify" class="footer-item">
<a
@@ -91,7 +96,7 @@ onMounted(() => {
text-align: center;
padding: 20px 0;
margin-top: 30px;
border-top: 1px solid rgba(255, 255, 255, 0.1);
border-top: 1px solid var(--border-primary);
width: 100%;
}
@@ -104,7 +109,7 @@ onMounted(() => {
}
.footer-item {
color: rgba(255, 255, 255, 0.6);
color: var(--text-tertiary);
font-size: 12px;
white-space: nowrap;
}
@@ -112,7 +117,7 @@ onMounted(() => {
.footer-item:not(:last-child)::after {
content: ' | ';
margin: 0 10px;
color: rgba(255, 255, 255, 0.4);
color: var(--text-quaternary);
}
.footer-item a {
@@ -122,7 +127,7 @@ onMounted(() => {
}
.footer-item a:hover {
color: rgba(255, 255, 255, 0.8);
color: var(--text-secondary);
}
.icp-link,
@@ -134,7 +139,7 @@ onMounted(() => {
.icp-link:hover,
.voicehub-link:hover {
color: rgba(255, 255, 255, 0.8);
color: var(--text-secondary);
}
.netlify-badge {
File diff suppressed because it is too large Load Diff
+97 -88
View File
@@ -104,13 +104,13 @@
</div>
<div v-else-if="!schedules || schedules.length === 0" key="empty-all" class="empty">
<div class="icon mb-4">🎵</div>
<Icon class="icon mb-4" name="music" :size="48" />
<p>暂无排期信息</p>
<p class="text-sm text-gray">点歌后等待管理员安排播出时间</p>
<p class="text-sm text-text-secondary">点歌后等待管理员安排播出时间</p>
</div>
<div v-else-if="currentDateSchedules.length === 0" key="empty-date" class="empty">
<div class="icon mb-4">📅</div>
<Icon class="icon mb-4" name="calendar" :size="48" />
<p>当前日期暂无排期</p>
<p>请选择其他日期查看</p>
</div>
@@ -1600,15 +1600,15 @@ const vRipple = {
align-items: center;
margin-bottom: 1.5rem;
padding: 1rem 1.25rem;
background: linear-gradient(135deg, rgba(11, 90, 254, 0.1) 0%, rgba(33, 36, 45, 0.9) 100%);
background: var(--bg-secondary);
border-radius: 12px;
border: 1px solid rgba(11, 90, 254, 0.2);
border: 1px solid var(--border-secondary);
backdrop-filter: blur(10px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.semester-label {
color: #ffffff;
color: var(--text-primary);
font-size: 15px;
font-weight: 500;
margin-right: 0.75rem;
@@ -1617,10 +1617,10 @@ const vRipple = {
}
.semester-select {
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%);
border: 1px solid rgba(255, 255, 255, 0.25);
background: var(--bg-tertiary);
border: 1px solid var(--border-tertiary);
border-radius: 8px;
color: #ffffff;
color: var(--text-primary);
padding: 0.6rem 1rem;
font-size: 14px;
font-weight: 500;
@@ -1648,8 +1648,8 @@ const vRipple = {
}
.semester-select option {
background: #1a1d24;
color: #ffffff;
background: var(--bg-secondary, #1a1d24);
color: var(--text-primary);
padding: 0.5rem;
}
@@ -1690,7 +1690,7 @@ const vRipple = {
font-family: 'MiSans', sans-serif;
font-weight: 400;
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
color: var(--text-secondary);
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 0.5rem;
@@ -1703,24 +1703,24 @@ const vRipple = {
}
.date-item:hover {
background: #21242d;
background: var(--bg-tertiary);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
.date-item.active {
background: #21242d;
color: #ffffff;
background: var(--bg-tertiary);
color: var(--text-primary);
font-weight: 600;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(11, 90, 254, 0.2);
box-shadow: 0 4px 12px rgba(11, 90, 254, 0.12);
border-left: 3px solid #0b5afe;
}
.empty-dates {
padding: 2rem 1rem;
text-align: center;
color: rgba(255, 255, 255, 0.6);
color: var(--text-tertiary);
width: 100%;
box-sizing: border-box;
display: flex;
@@ -1775,7 +1775,7 @@ const vRipple = {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 20px;
color: #ffffff;
color: var(--text-primary);
margin: 0;
}
@@ -1784,9 +1784,9 @@ const vRipple = {
padding: 3rem;
text-align: center;
border-radius: 10px;
background: #21242d;
background: var(--bg-tertiary);
margin: 1rem 0;
color: rgba(255, 255, 255, 0.6);
color: var(--text-tertiary);
display: flex;
flex-direction: column;
align-items: center;
@@ -1819,13 +1819,13 @@ const vRipple = {
padding: 2rem;
text-align: center;
border-radius: 10px;
background: #21242d;
background: var(--bg-tertiary);
margin: 1rem 0;
color: rgba(255, 255, 255, 0.6);
color: var(--text-tertiary);
}
.error {
color: #ef4444;
color: var(--error);
}
.empty .icon {
@@ -1849,13 +1849,13 @@ const vRipple = {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
color: rgba(255, 255, 255, 0.6);
color: var(--text-secondary);
margin: 0 0 1rem 0;
}
.playtime-time {
font-size: 14px;
color: rgba(255, 255, 255, 0.4);
color: var(--text-tertiary);
margin-left: 0.5rem;
}
@@ -1869,7 +1869,7 @@ const vRipple = {
.song-card {
width: 320px;
flex-shrink: 0;
background: #21242d;
background: var(--bg-tertiary);
border-radius: 10px;
overflow: hidden;
position: relative;
@@ -1883,22 +1883,22 @@ const vRipple = {
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
border-radius: 999px;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
border-radius: 12px;
border: 1px solid var(--border-secondary);
background: var(--bg-secondary);
color: var(--text-primary);
font-size: 14px;
font-weight: 500;
cursor: pointer;
white-space: nowrap;
backdrop-filter: blur(10px);
backdrop-filter: blur(6px);
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
.add-playlist-btn:hover {
background: rgba(255, 255, 255, 0.2);
background: var(--bg-tertiary);
transform: translateY(-1px);
border-color: rgba(255, 255, 255, 0.2);
border-color: var(--border-tertiary);
}
.add-playlist-btn:active {
@@ -1930,7 +1930,7 @@ const vRipple = {
width: 100%;
max-width: 580px;
max-height: 85vh;
background: #1e1e24;
background: var(--bg-secondary, #1e1e24);
border-radius: 20px;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.08);
@@ -1964,7 +1964,7 @@ const vRipple = {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #ffffff;
color: var(--text-primary);
letter-spacing: 0.5px;
}
@@ -1984,7 +1984,7 @@ const vRipple = {
.playlist-modal-close:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
color: var(--text-primary);
transform: rotate(90deg);
}
@@ -1993,7 +1993,7 @@ const vRipple = {
flex: 1;
overflow-y: auto;
padding: 1.5rem;
background: #1e1e24;
background: var(--bg-secondary, #1e1e24);
}
/* 滚动条美化 */
@@ -2066,7 +2066,7 @@ const vRipple = {
height: 40px;
border-radius: 50%;
overflow: hidden;
background: #2a2a32;
background: var(--bg-tertiary, #2a2a32);
display: flex;
align-items: center;
justify-content: center;
@@ -2087,7 +2087,7 @@ const vRipple = {
.user-name {
font-weight: 600;
color: #ffffff;
color: var(--text-primary);
font-size: 15px;
}
@@ -2161,9 +2161,9 @@ const vRipple = {
.custom-select,
.custom-input {
width: 100%;
background: #141418;
background: var(--bg-secondary, #141418);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #ffffff;
color: var(--text-primary);
padding: 0.75rem 1rem;
border-radius: 10px;
font-size: 14px;
@@ -2188,9 +2188,9 @@ const vRipple = {
.custom-select:focus,
.custom-input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
background: #1a1a20;
border-color: var(--input-border-focus, var(--primary));
box-shadow: var(--input-shadow-focus, 0 0 0 3px rgba(102, 126, 234, 0.1));
background: var(--input-bg, #1a1a20);
}
.btn-icon {
@@ -2210,7 +2210,7 @@ const vRipple = {
.btn-icon:hover {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
color: var(--text-primary);
}
.btn-icon:disabled {
@@ -2421,7 +2421,7 @@ const vRipple = {
.song-name {
font-size: 14px;
color: #ffffff;
color: var(--text-primary);
font-weight: 500;
white-space: nowrap;
overflow: hidden;
@@ -2469,7 +2469,7 @@ const vRipple = {
.btn-primary {
background: var(--primary);
color: #ffffff;
color: var(--text-primary);
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25);
}
@@ -2488,7 +2488,7 @@ const vRipple = {
.btn-secondary {
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
color: var(--text-primary);
}
.btn-secondary:hover {
@@ -2502,7 +2502,7 @@ const vRipple = {
.btn-ghost:hover {
background: rgba(255, 255, 255, 0.05);
color: #ffffff;
color: var(--text-primary);
}
/* 动画定义 */
@@ -2537,7 +2537,7 @@ const vRipple = {
border-radius: 50%;
border: 1px solid rgba(255, 255, 255, 0.1);
background: rgba(255, 255, 255, 0.1);
color: #ffffff;
color: var(--text-primary);
cursor: pointer;
margin-right: 0.5rem;
backdrop-filter: blur(10px);
@@ -2661,8 +2661,8 @@ const vRipple = {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0043f8 0%, #0075f8 100%);
color: #ffffff;
background: linear-gradient(135deg, var(--bg-secondary, #0043f8) 0%, #0075f8 100%);
color: var(--text-primary);
font-size: 28px;
font-weight: bold;
font-family: 'MiSans', sans-serif;
@@ -2725,7 +2725,7 @@ const vRipple = {
font-weight: 600;
font-size: 16px;
letter-spacing: 0.04em;
color: #ffffff;
color: var(--text-primary);
margin-bottom: 0.5rem;
display: flex;
align-items: flex-start;
@@ -2777,14 +2777,14 @@ const vRipple = {
font-family: 'MiSans', sans-serif;
font-weight: normal;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
color: var(--text-secondary, #52525b);
text-align: left;
}
/* 重播申请人数样式 */
.replay-requester {
/* 使用和普通投稿人相同的颜色 */
color: rgba(255, 255, 255, 0.4);
color: var(--text-secondary, #52525b);
font-weight: normal;
cursor: help;
}
@@ -2824,8 +2824,8 @@ const vRipple = {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 12px;
color: #ffffff;
opacity: 0.4;
color: var(--text-secondary, #52525b);
opacity: 0.95;
}
/* ==================== 移动端设计 ==================== */
@@ -2919,7 +2919,7 @@ const vRipple = {
.date-text {
font-size: 15px;
font-weight: 600;
color: #ffffff;
color: var(--text-primary);
white-space: nowrap;
text-shadow:
0 0 10px rgba(255, 255, 255, 0.2),
@@ -2928,7 +2928,7 @@ const vRipple = {
.dropdown-icon {
opacity: 0.5;
color: #ffffff;
color: var(--text-primary);
}
.mobile-action-btn {
@@ -2937,7 +2937,7 @@ const vRipple = {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0b5afe 0%, #0043f8 100%);
background: linear-gradient(135deg, var(--bg-secondary, #0b5afe) 0%, #0043f8 100%);
border: none;
border-radius: 16px;
box-shadow: 0 4px 15px rgba(11, 90, 254, 0.4);
@@ -3118,13 +3118,13 @@ const vRipple = {
font-weight: 600;
margin-bottom: 2px;
line-height: 1.4;
color: #ffffff;
color: var(--text-primary);
letter-spacing: 0.01em;
}
.requester {
font-size: 12px;
color: rgba(255, 255, 255, 0.5);
color: var(--text-secondary, #52525b);
font-weight: 400;
}
@@ -3140,7 +3140,7 @@ const vRipple = {
.vote-count .label {
font-size: 11px;
font-weight: 600;
color: rgba(255, 255, 255, 0.4);
color: var(--text-secondary, #52525b);
margin-top: 2px;
}
@@ -3168,33 +3168,35 @@ const vRipple = {
/* 日期选择弹窗 */
.date-picker-content {
background: #1a1a1f;
background: var(--bg-primary, #ffffff);
border-radius: 20px 20px 0 0;
width: 100%;
max-width: 100%;
max-height: 70vh;
border: none;
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.5);
box-shadow: 0 -10px 40px rgba(0, 0, 0, 0.12);
}
.date-picker-header {
padding: 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
border-bottom: 1px solid var(--border-secondary, rgba(0, 0, 0, 0.06));
}
.date-picker-header h3 {
font-size: 17px;
font-weight: 600;
color: var(--text-primary, #09090b);
}
.close-btn {
width: 32px;
height: 32px;
border-radius: 10px;
background: rgba(255, 255, 255, 0.06);
background: var(--bg-tertiary, rgba(0, 0, 0, 0.06));
display: flex;
align-items: center;
justify-content: center;
color: var(--text-secondary, #52525b);
}
.date-picker-list {
@@ -3205,20 +3207,23 @@ const vRipple = {
padding: 14px 16px;
border-radius: 12px;
margin-bottom: 6px;
background: rgba(255, 255, 255, 0.03);
background: var(--bg-secondary, #f4f4f5);
border: none;
font-size: 14px;
color: var(--text-primary, #09090b);
font-weight: 600;
}
.date-picker-item:hover {
background: rgba(255, 255, 255, 0.06);
background: rgba(79, 70, 229, 0.08);
transform: none;
color: var(--primary, #4f46e5);
}
.date-picker-item.active {
background: rgba(11, 90, 254, 0.15);
background: rgba(79, 70, 229, 0.14);
border-left: none;
color: #0b5afe;
color: var(--primary, #4f46e5);
}
}
@@ -3366,7 +3371,7 @@ const vRipple = {
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
background: #21242d;
background: var(--bg-tertiary, #21242d);
border-radius: 10px;
padding: 0.75rem 1rem;
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
@@ -3378,8 +3383,8 @@ const vRipple = {
.date-nav-btn {
background: rgba(11, 90, 254, 0.1);
border: 1px solid rgba(11, 90, 254, 0.2);
color: #ffffff;
border: 1px solid var(--border-secondary);
color: var(--text-primary);
border-radius: 50%;
width: 36px;
height: 36px;
@@ -3403,7 +3408,7 @@ const vRipple = {
font-family: 'MiSans', sans-serif;
font-weight: 600;
font-size: 16px;
color: #ffffff;
color: var(--text-primary);
text-align: center;
flex: 1;
white-space: pre-line;
@@ -3453,10 +3458,10 @@ const vRipple = {
width: 85%;
max-width: 350px;
max-height: 70vh;
background: #1a1d24;
border-radius: 12px;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--bg-primary, #ffffff);
border-radius: 18px;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12);
border: 1px solid var(--border-secondary, #e4e4e7);
overflow: hidden;
animation: scale-in 0.2s ease;
display: flex;
@@ -3468,19 +3473,19 @@ const vRipple = {
justify-content: space-between;
align-items: center;
padding: 1rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
border-bottom: 1px solid var(--border-secondary, rgba(0, 0, 0, 0.08));
}
.date-picker-header h3 {
margin: 0;
font-size: 16px;
color: white;
color: var(--text-primary, #09090b);
}
.close-btn {
background: none;
border: none;
color: rgba(255, 255, 255, 0.6);
color: var(--text-secondary, #52525b);
font-size: 22px;
cursor: pointer;
padding: 0 5px;
@@ -3495,24 +3500,28 @@ const vRipple = {
.date-picker-item {
padding: 0.75rem 1rem;
border-radius: 8px;
border-radius: 10px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: all 0.2s ease;
background: rgba(255, 255, 255, 0.05);
background: var(--bg-secondary, #f4f4f5);
position: relative;
overflow: hidden;
white-space: pre-line;
color: var(--text-primary, #09090b);
font-weight: 600;
}
.date-picker-item:hover {
background: rgba(11, 90, 254, 0.1);
background: rgba(79, 70, 229, 0.1);
transform: translateY(-2px);
color: var(--primary, #4f46e5);
}
.date-picker-item.active {
background: rgba(11, 90, 254, 0.2);
border-left: 3px solid #0b5afe;
background: rgba(79, 70, 229, 0.14);
border-left: 3px solid var(--primary, #4f46e5);
color: var(--primary, #4f46e5);
}
/* 过渡动画 */
+15 -15
View File
@@ -13,16 +13,16 @@
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
@click="emit('close')"
>
<div class="bg-zinc-950 border border-zinc-800 rounded-2xl w-full max-w-2xl overflow-hidden" @click.stop>
<div class="px-5 py-4 border-b border-zinc-800 flex items-start justify-between gap-4">
<div class="bg-white border border-zinc-200 rounded-2xl w-full max-w-2xl overflow-hidden shadow-xl" @click.stop>
<div class="px-5 py-4 border-b border-zinc-100 flex items-start justify-between gap-4">
<div class="min-w-0">
<p class="text-sm font-black text-zinc-100">歌曲评论</p>
<p class="text-[10px] text-zinc-500 mt-1 truncate">
<p class="text-sm font-black text-zinc-900">歌曲评论</p>
<p class="text-[11px] text-zinc-500 mt-1 truncate">
{{ song?.title }} - {{ song?.artist }}
</p>
</div>
<button
class="h-9 w-9 rounded-lg border border-zinc-700 text-zinc-400 hover:text-zinc-200 hover:bg-zinc-900 transition-colors"
class="h-9 w-9 rounded-xl border border-zinc-200 text-zinc-500 hover:text-zinc-900 hover:bg-zinc-50 transition-colors"
@click="emit('close')"
>
<X :size="16" class="mx-auto" />
@@ -30,36 +30,36 @@
</div>
<div class="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
<div v-if="loading" class="text-center text-zinc-500 py-6">加载中...</div>
<div v-else-if="comments.length === 0" class="text-center text-zinc-500 py-6">
<div v-if="loading" class="text-center text-zinc-400 py-6">加载中...</div>
<div v-else-if="comments.length === 0" class="text-center text-zinc-400 py-6">
暂无评论
</div>
<div v-else class="space-y-3">
<div
v-for="c in comments"
:key="c.id"
class="p-4 rounded-xl border border-zinc-800 bg-zinc-900/30"
class="p-4 rounded-xl border border-zinc-100 bg-zinc-50"
>
<div class="flex items-center justify-between gap-3">
<div class="min-w-0">
<p class="text-xs font-black text-zinc-200 truncate">{{ c.authorName || '用户' }}</p>
<p class="text-xs font-black text-zinc-900 truncate">{{ c.authorName || '用户' }}</p>
<p class="text-[10px] text-zinc-500 mt-0.5">{{ formatTime(c.createdAt) }}</p>
</div>
<button
v-if="canDelete(c)"
class="px-3 py-1.5 rounded-lg border border-red-500/30 text-red-300 hover:bg-red-500/10 text-xs font-bold disabled:opacity-50"
class="px-3 py-1.5 rounded-lg border border-red-200 text-red-500 hover:bg-red-50 text-xs font-bold disabled:opacity-50"
:disabled="deletingId === c.id"
@click="deleteComment(c)"
>
删除
</button>
</div>
<p class="text-sm text-zinc-200 mt-3 whitespace-pre-wrap leading-relaxed">{{ c.content }}</p>
<p class="text-sm text-zinc-700 mt-3 whitespace-pre-wrap leading-relaxed">{{ c.content }}</p>
</div>
</div>
</div>
<div class="px-5 py-4 border-t border-zinc-800 space-y-3">
<div class="px-5 py-4 border-t border-zinc-100 space-y-3">
<div v-if="!isAuthenticated" class="text-xs text-zinc-500">
登录后可发表评论
</div>
@@ -67,13 +67,13 @@
<textarea
v-model="draft"
:rows="3"
class="w-full bg-zinc-900 border border-zinc-800 rounded-xl px-4 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 transition-all resize-none"
class="w-full bg-white border border-zinc-200 rounded-xl px-4 py-3 text-sm text-zinc-900 placeholder-zinc-400 focus:outline-none focus:border-indigo-400 focus:ring-2 focus:ring-indigo-100 transition-all resize-none"
placeholder="写下你的评论(最多 300 字)"
/>
<div class="flex items-center justify-between gap-3">
<p class="text-[10px] text-zinc-500">{{ draft.length }} / 300</p>
<p class="text-[11px] text-zinc-400">{{ draft.length }} / 300</p>
<button
class="px-5 py-2 rounded-lg bg-blue-600 hover:bg-blue-500 text-white text-sm font-black disabled:opacity-50"
class="px-5 py-2.5 rounded-xl bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-black disabled:opacity-50 shadow-sm shadow-indigo-200 transition-colors"
:disabled="posting || !draft.trim() || draft.trim().length > 300"
@click="postComment"
>
+111 -84
View File
@@ -234,14 +234,16 @@
</span>
<span
v-else-if="song.scheduled"
:title="
song.scheduleDate
? `排期日期:${formatScheduleDate(song.scheduleDate)}`
: '已排期'
"
class="scheduled-tag"
class="scheduled-group"
>
已排期
<span class="scheduled-tag">已排期</span>
<span
v-if="song.scheduleDate"
:title="`排期日期:${formatScheduleDate(song.scheduleDate)}`"
class="scheduled-date"
>
{{ formatScheduleDate(song.scheduleDate) }}
</span>
</span>
<span v-else-if="song.isReplay" title="重播歌曲" class="replay-tag"> 重播 </span>
<button
@@ -1691,14 +1693,14 @@ const vRipple = {
border-radius: 12px;
padding: 12px 16px 12px 42px;
font-size: 14px;
color: #fff;
color: var(--text-primary, #ffffff);
width: 100%;
transition: all 0.2s ease;
}
.mobile-search-input:focus {
background: rgba(255, 255, 255, 0.08);
border-color: #3b82f6;
border-color: var(--border-secondary, #3b82f6);
outline: none;
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
}
@@ -1785,7 +1787,7 @@ const vRipple = {
}
.tab-button.active {
color: #ffffff;
color: var(--text-primary, #ffffff);
border-bottom-color: #0b5afe;
transform: none;
box-shadow: none;
@@ -1834,7 +1836,7 @@ const vRipple = {
justify-content: center;
width: 36px;
height: 36px;
background: #21242d;
background: var(--bg-tertiary, #21242d);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
cursor: pointer;
@@ -1845,7 +1847,7 @@ const vRipple = {
}
.semester-toggle-btn:hover {
background: #2a2e38;
background: var(--bg-tertiary, #2a2e38);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
color: #0b5afe;
@@ -1855,7 +1857,7 @@ const vRipple = {
position: absolute;
top: calc(100% + 8px);
right: 0;
background: #1a1d24;
background: var(--bg-secondary, #1a1d24);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
@@ -1879,7 +1881,7 @@ const vRipple = {
.semester-option:hover {
background: rgba(11, 90, 254, 0.1);
color: #ffffff;
color: var(--text-primary, #ffffff);
}
.semester-option.active {
@@ -1894,14 +1896,14 @@ const vRipple = {
}
.search-input {
background: #040e15;
border: 1px solid #242f38;
background: var(--input-bg, #040e15);
border: 1px solid var(--input-border, #242f38);
border-radius: 8px;
padding: 0.5rem 1rem;
padding-right: 2.5rem;
font-family: 'MiSans-Demibold', sans-serif;
font-size: 14px;
color: rgba(255, 255, 255, 0.8);
color: var(--input-text, rgba(255, 255, 255, 0.8));
width: 100%;
transition: all 0.2s ease;
}
@@ -1925,7 +1927,7 @@ const vRipple = {
justify-content: center;
width: 36px;
height: 36px;
background: #21242d;
background: var(--bg-tertiary, #21242d);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
cursor: pointer;
@@ -1934,7 +1936,7 @@ const vRipple = {
}
.refresh-button:hover {
background: #2a2e38;
background: var(--bg-tertiary, #2a2e38);
transform: translateY(-2px);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
}
@@ -2028,7 +2030,7 @@ const vRipple = {
.song-card-main {
padding: 1rem 0 1rem 1rem; /* 移除右侧内边距,保留左侧、上下内边距 */
background: #21242d;
background: var(--bg-tertiary, #21242d);
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
position: relative;
height: 100px; /* 减小卡片高度 */
@@ -2076,8 +2078,8 @@ const vRipple = {
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #0043f8 0%, #0075f8 100%);
color: #ffffff;
background: linear-gradient(135deg, var(--bg-secondary, #0043f8) 0%, #0075f8 100%);
color: var(--text-primary, #ffffff);
font-size: 28px;
font-weight: bold;
font-family: 'MiSans-Demibold', sans-serif;
@@ -2139,7 +2141,7 @@ const vRipple = {
font-weight: 600;
font-size: 16px;
letter-spacing: 0.04em;
color: #ffffff;
color: var(--text-primary, #ffffff);
margin-bottom: 0.5rem;
width: 100%; /* 确保标题占满整个容器宽度 */
display: flex;
@@ -2167,7 +2169,7 @@ const vRipple = {
font-family: 'MiSans', sans-serif;
font-weight: normal;
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
color: var(--text-secondary, #52525b);
text-align: left;
white-space: nowrap;
overflow: hidden;
@@ -2215,8 +2217,8 @@ const vRipple = {
font-family: 'MiSans-Demibold', sans-serif;
font-weight: 600;
font-size: 12px;
color: #ffffff;
opacity: 0.4;
color: var(--text-secondary, #52525b);
opacity: 0.9;
}
/* 点赞按钮样式 */
@@ -2231,24 +2233,27 @@ const vRipple = {
justify-content: center;
width: 48px;
height: 45px;
background: linear-gradient(180deg, #0043f8 0%, #0075f8 100%);
border: 1px solid rgba(255, 255, 255, 0.16);
border-radius: 8px;
background: linear-gradient(180deg, #4f46e5 0%, #6366f1 100%);
border: 1px solid rgba(79, 70, 229, 0.25);
border-radius: 12px;
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 4px 10px rgba(79, 70, 229, 0.18);
}
.like-button.liked {
background: #1a1d24;
border-color: #242f38;
background: var(--primary-light, rgba(79, 70, 229, 0.12));
border-color: var(--primary, #4f46e5);
background-image: none;
box-shadow: none;
}
.like-button.disabled {
background: rgba(255, 255, 255, 0.1);
border-color: rgba(255, 255, 255, 0.2);
background: var(--bg-secondary, #f4f4f5);
border-color: var(--border-secondary, #e4e4e7);
cursor: not-allowed;
opacity: 0.5;
box-shadow: none;
}
.like-button.disabled .like-icon {
@@ -2265,17 +2270,36 @@ const vRipple = {
transform: scale(1.2);
}
.scheduled-group {
display: inline-flex;
align-items: center;
gap: 0.35rem;
margin-left: 0.5rem;
flex-shrink: 0;
align-self: center;
}
.scheduled-tag {
display: inline-flex;
background: rgba(16, 185, 129, 0.2);
border: 1px solid rgba(16, 185, 129, 0.4);
border-radius: 4px;
padding: 0.15rem 0.4rem;
font-size: 0.7rem;
color: #10b981;
margin-left: 0.5rem;
flex-shrink: 0; /* 防止标签被压缩 */
align-self: center; /* 确保垂直居中 */
background: rgba(16, 185, 129, 0.12);
border: 1px solid rgba(16, 185, 129, 0.35);
border-radius: 6px;
padding: 0.15rem 0.5rem;
font-size: 0.72rem;
font-weight: 700;
color: #059669;
}
.scheduled-date {
display: inline-flex;
align-items: center;
font-size: 0.72rem;
font-weight: 600;
color: var(--text-secondary, #52525b);
background: var(--bg-secondary, #f4f4f5);
padding: 0.15rem 0.5rem;
border-radius: 6px;
border: 1px solid var(--border-secondary, #e4e4e7);
}
.played-tag {
@@ -2308,13 +2332,14 @@ const vRipple = {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.15rem 0.4rem;
border-radius: 4px;
font-size: 0.7rem;
padding: 0.25rem 0.6rem;
border-radius: 8px;
font-size: 11px;
font-weight: 600;
line-height: 1;
background: rgba(59, 130, 246, 0.15);
border: 1px solid rgba(59, 130, 246, 0.3);
color: #93c5fd;
background: rgba(79, 70, 229, 0.08);
border: 1px solid rgba(79, 70, 229, 0.2);
color: var(--primary, #4f46e5);
white-space: nowrap;
}
@@ -2329,20 +2354,21 @@ const vRipple = {
display: flex;
align-items: center;
justify-content: space-between;
background: #21242d;
border-radius: 0 0 10px 10px;
padding: 0.5rem 1rem;
background: var(--bg-secondary, #f4f4f5);
border-radius: 0 0 14px 14px;
padding: 0.6rem 1rem;
width: 95%;
position: relative;
z-index: 1;
height: 45px;
border-top: 1px solid var(--border-secondary, #e4e4e7);
}
.submission-time {
font-family: 'MiSans-Demibold', sans-serif;
font-weight: 600;
font-size: 12px;
color: rgba(255, 255, 255, 0.6);
color: var(--text-secondary, #52525b);
text-align: left;
max-width: 70%;
}
@@ -2355,7 +2381,7 @@ const vRipple = {
font-family: 'MiSans-Demibold', sans-serif;
font-weight: 600;
font-size: 12px;
color: #ffffff;
color: var(--text-primary, #ffffff);
cursor: pointer;
transition: all 0.3s ease;
height: 27px;
@@ -2368,12 +2394,12 @@ const vRipple = {
}
.replay-cancel-btn {
background: linear-gradient(180deg, #0b5afe 0%, #3d7fff 100%);
background: linear-gradient(180deg, var(--bg-secondary, #0b5afe) 0%, #3d7fff 100%);
min-width: 75px;
}
.replay-request-btn {
background: linear-gradient(180deg, #0b5afe 0%, #3d7fff 100%);
background: linear-gradient(180deg, var(--bg-secondary, #0b5afe) 0%, #3d7fff 100%);
min-width: 75px;
}
@@ -2408,13 +2434,13 @@ button:disabled {
border-radius: 4px;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
color: #ffffff;
color: var(--text-primary, #ffffff);
cursor: pointer;
transition: all 0.2s ease;
}
.page-number.active {
background: #0b5afe;
background: var(--bg-secondary, #0b5afe);
border-color: #0b5afe;
}
@@ -2444,7 +2470,7 @@ button:disabled {
border-radius: 4px;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
color: #ffffff;
color: var(--text-primary, #ffffff);
width: 60px;
text-align: center;
transition: all 0.2s ease;
@@ -2467,7 +2493,7 @@ button:disabled {
border-radius: 4px;
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
color: #ffffff;
color: var(--text-primary, #ffffff);
cursor: pointer;
transition: all 0.2s ease;
white-space: nowrap;
@@ -2593,7 +2619,7 @@ button:disabled {
}
.semester-dropdown {
background: #1a1a1f;
background: var(--surface-primary, var(--bg-primary, #1a1a1f));
border: none;
border-radius: 12px;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
@@ -2705,13 +2731,13 @@ button:disabled {
font-weight: 700;
margin-bottom: 4px;
line-height: 1.2;
color: #ffffff;
color: var(--text-primary, #09090b);
letter-spacing: 0.01em;
}
.requester {
font-size: 12px;
color: rgba(255, 255, 255, 0.4);
color: var(--text-secondary, #52525b);
font-weight: 400;
margin-top: 2px;
}
@@ -2736,7 +2762,7 @@ button:disabled {
.vote-count .label {
font-size: 10px;
font-weight: 600;
color: rgba(255, 255, 255, 0.3);
color: var(--text-secondary, #52525b);
margin-top: 2px;
text-transform: uppercase;
}
@@ -2745,8 +2771,8 @@ button:disabled {
width: 44px;
height: 44px;
border-radius: 14px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
background: var(--bg-secondary, #f4f4f5);
border: 1px solid var(--border-secondary, #e4e4e7);
display: flex;
align-items: center;
justify-content: center;
@@ -2765,16 +2791,16 @@ button:disabled {
/* 投稿时间和操作 */
.submission-footer {
background: rgba(255, 255, 255, 0.02);
background: var(--bg-secondary, #f4f4f5);
padding: 10px 16px;
height: auto;
width: 100%;
border-top: 1px solid rgba(255, 255, 255, 0.04);
border-top: 1px solid var(--border-secondary, #e4e4e7);
}
.submission-time {
font-size: 11px;
color: rgba(255, 255, 255, 0.3);
color: var(--text-secondary, #52525b);
font-weight: 400;
}
@@ -2783,9 +2809,9 @@ button:disabled {
padding: 0 12px;
font-size: 12px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.8);
background: var(--bg-secondary, #f4f4f5);
border: 1px solid var(--border-secondary, #e4e4e7);
color: var(--text-primary, #09090b);
}
.withdraw-button.replay-cancel-btn,
@@ -2930,7 +2956,7 @@ button:disabled {
border-radius: 12px;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.1);
color: #fff;
color: var(--text-primary, #ffffff);
display: flex;
align-items: center;
justify-content: center;
@@ -2964,7 +2990,7 @@ button:disabled {
width: 40px;
background: transparent;
border: none;
color: #fff;
color: var(--text-primary, #ffffff);
text-align: center;
font-size: 14px;
font-weight: 600;
@@ -2991,7 +3017,7 @@ button:disabled {
.page-nav-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.1);
border-color: #3b82f6;
border-color: var(--border-secondary, #3b82f6);
}
.page-nav-btn:disabled {
@@ -3052,22 +3078,23 @@ button:disabled {
display: inline-flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
width: 26px;
height: 26px;
margin-left: 6px;
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 999px;
background: rgba(59, 130, 246, 0.08);
color: #60a5fa;
border: 1px solid var(--border-secondary, #e4e4e7);
border-radius: 10px;
background: var(--bg-secondary, #f4f4f5);
color: var(--primary, #4f46e5);
cursor: pointer;
transition: all 0.2s ease;
box-shadow: 0 0 0 0 rgba(96, 165, 250, 0);
box-shadow: 0 0 0 0 rgba(79, 70, 229, 0);
}
.submission-note-trigger:hover {
background: rgba(59, 130, 246, 0.18);
border-color: rgba(59, 130, 246, 0.5);
box-shadow: 0 6px 12px rgba(96, 165, 250, 0.15);
background: rgba(79, 70, 229, 0.08);
border-color: rgba(79, 70, 229, 0.35);
color: #4338ca;
box-shadow: 0 4px 10px rgba(79, 70, 229, 0.12);
}
.submission-note-modal {
+25 -25
View File
@@ -1528,7 +1528,7 @@ const getFirstChar = (text) => {
.mobile-top-progress .progress-fill {
height: 100%;
background: #0b5afe;
background: var(--bg-secondary, #0b5afe);
box-shadow: 0 0 8px rgba(11, 90, 254, 0.6);
border-radius: 0 1px 1px 0;
}
@@ -1729,7 +1729,7 @@ const getFirstChar = (text) => {
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: #ffffff;
color: var(--text-primary, #ffffff);
font-size: 18px;
font-weight: 600;
font-family:
@@ -1756,7 +1756,7 @@ const getFirstChar = (text) => {
line-height: 22px;
padding-bottom: 2px;
letter-spacing: -0.4px;
color: #ffffff;
color: var(--text-primary, #ffffff);
font-family:
'SF Pro', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica,
sans-serif;
@@ -1776,7 +1776,7 @@ const getFirstChar = (text) => {
overflow: hidden;
line-height: 20px;
letter-spacing: -0.4px;
color: #ffffff75;
color: var(--text-primary, #ffffff)75;
font-family:
'SF Pro', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica,
sans-serif;
@@ -1797,7 +1797,7 @@ const getFirstChar = (text) => {
align-items: center;
justify-content: center;
border-radius: 100px;
background: #00000042;
background: var(--bg-primary, #111111)42;
padding: 6px;
width: 32px;
height: 32px;
@@ -1814,7 +1814,7 @@ const getFirstChar = (text) => {
}
.close-icon {
color: #ffffff;
color: var(--text-primary, #ffffff);
font-size: 15px;
line-height: 1;
font-family:
@@ -1851,7 +1851,7 @@ const getFirstChar = (text) => {
display: flex;
flex-shrink: 0;
align-items: center;
border: 1px solid #1a1a1a;
border: 1px solid var(--border-secondary, #1a1a1a);
border-radius: 7px;
height: 8px;
width: 100%;
@@ -1864,14 +1864,14 @@ const getFirstChar = (text) => {
}
.ios-progress-bar:hover {
border-color: #333;
border-color: var(--border-secondary, #333);
box-shadow:
0 0 20px rgba(255, 255, 255, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.progress-fill {
background: linear-gradient(90deg, #ffffffb2, #ffffff);
background: linear-gradient(90deg, var(--card-bg, #ffffff)b2, #ffffff);
height: 8px;
transition: width 0.1s linear;
border-radius: 7px;
@@ -1916,7 +1916,7 @@ const getFirstChar = (text) => {
}
.clickable-time:hover {
color: #ffffff;
color: var(--text-primary, #ffffff);
transform: scale(1.05);
}
@@ -1931,10 +1931,10 @@ const getFirstChar = (text) => {
display: flex;
align-items: center;
justify-content: center;
background: #ffffff1a;
background: var(--card-bg, #ffffff)1a;
border-radius: 4px;
padding: 0 8px;
color: #ffffffb3;
color: var(--text-primary, #ffffff)b3;
font-size: 10px;
font-weight: 600;
backdrop-filter: blur(10px);
@@ -1944,8 +1944,8 @@ const getFirstChar = (text) => {
}
.audio-quality-badge:hover {
background: #ffffff26;
color: #ffffff;
background: var(--card-bg, #ffffff)26;
color: var(--text-primary, #ffffff);
}
/* 控制按钮区域 */
@@ -1962,7 +1962,7 @@ const getFirstChar = (text) => {
.ios-control-btn {
background: none;
border: none;
color: #ffffff92;
color: var(--text-primary, #ffffff)92;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
display: flex;
@@ -1997,7 +1997,7 @@ const getFirstChar = (text) => {
}
.ios-control-btn:hover {
color: #ffffff;
color: var(--text-primary, #ffffff);
transform: scale(1.1);
box-shadow:
0 0 20px rgba(255, 255, 255, 0.2),
@@ -2196,7 +2196,7 @@ const getFirstChar = (text) => {
width: 60px;
line-height: 18px;
letter-spacing: 0.42px;
color: #ffffffb3;
color: var(--text-primary, #ffffff)b3;
font-family:
'SF Pro', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica,
sans-serif;
@@ -2215,10 +2215,10 @@ const getFirstChar = (text) => {
display: flex;
align-items: center;
justify-content: center;
background: #ffffff1a;
background: var(--card-bg, #ffffff)1a;
border-radius: 4px;
padding: 0 8px;
color: #ffffffb3;
color: var(--text-primary, #ffffff)b3;
font-size: 10px;
font-weight: 600;
backdrop-filter: blur(10px);
@@ -2228,8 +2228,8 @@ const getFirstChar = (text) => {
}
.audio-quality:hover {
background: #ffffff26;
color: #ffffff;
background: var(--card-bg, #ffffff)26;
color: var(--text-primary, #ffffff);
}
.audio-quality:active {
@@ -2251,13 +2251,13 @@ const getFirstChar = (text) => {
width: 39px;
font-size: 25px;
text-align: center;
color: #ffffff92;
color: var(--text-primary, #ffffff)92;
cursor: pointer;
transition: all 0.2s ease;
}
.lyrics-btn:hover {
color: #ffffff;
color: var(--text-primary, #ffffff);
transform: scale(1.1);
}
@@ -2265,7 +2265,7 @@ const getFirstChar = (text) => {
.control-btn {
line-height: 1;
font-size: 24px;
color: #ffffff;
color: var(--text-primary, #ffffff);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
@@ -2290,7 +2290,7 @@ const getFirstChar = (text) => {
.play-pause-btn {
line-height: 44px;
font-size: 37px;
color: #ffffff;
color: var(--text-primary, #ffffff);
cursor: pointer;
transition: all 0.2s ease;
display: flex;
+15 -15
View File
@@ -1,11 +1,11 @@
<template>
<div ref="containerRef" class="relative" :class="className">
<div
class="flex items-center gap-2 px-3 py-2 bg-zinc-950 border rounded-lg transition-all cursor-pointer select-none"
class="flex items-center gap-2 px-3 py-2 bg-white border rounded-xl transition-all cursor-pointer select-none"
:class="[
isOpen
? 'border-blue-500/50 bg-blue-600/5 shadow-lg'
: 'border-zinc-800 hover:border-zinc-700'
? 'border-indigo-400/60 bg-indigo-50/60 shadow-sm'
: 'border-zinc-200 hover:border-zinc-300'
]"
@click="toggleDropdown"
>
@@ -13,14 +13,14 @@
<span
v-if="label"
class="text-[8px] font-black uppercase tracking-widest leading-none mb-0.5 transition-colors"
:class="isOpen ? 'text-blue-400' : 'text-zinc-600'"
:class="isOpen ? 'text-indigo-500' : 'text-zinc-500'"
>
{{ label }}
</span>
<span class="text-[11px] font-bold text-zinc-300 truncate">{{ displayLabel }}</span>
<span class="text-[12px] font-bold text-zinc-800 truncate">{{ displayLabel }}</span>
</div>
<div class="transition-transform duration-200" :class="{ 'rotate-180': isOpen }">
<ChevronDown :size="12" :class="isOpen ? 'text-blue-400' : 'text-zinc-700'" />
<ChevronDown :size="12" :class="isOpen ? 'text-indigo-500' : 'text-zinc-500'" />
</div>
</div>
@@ -28,26 +28,26 @@
<Transition
enter-active-class="transition duration-100 ease-out"
enter-from-class="transform scale-95 opacity-0"
enter-to-class="transform scale-100 opacity-100"
enter-to-class="transform scale-100 opacity-1"
leave-active-class="transition duration-75 ease-in"
leave-from-class="transform scale-100 opacity-100"
leave-from-class="transform scale-100 opacity-1"
leave-to-class="transform scale-95 opacity-0"
>
<div
v-if="isOpen"
ref="dropdownRef"
:style="dropdownStyle"
class="fixed z-[9999] p-1 bg-[#0c0c0e] border border-zinc-800 rounded-lg shadow-2xl backdrop-blur-xl origin-top"
class="fixed z-[9999] p-1.5 bg-white border border-zinc-200 rounded-xl shadow-lg backdrop-blur-xl origin-top"
>
<div class="max-h-[200px] overflow-y-auto custom-scrollbar">
<div class="max-h-[220px] overflow-y-auto custom-scrollbar">
<button
v-for="option in normalizedOptions"
:key="option.value"
class="w-full flex items-center justify-between px-3 py-2 rounded-md text-[11px] font-bold transition-all"
class="w-full flex items-center justify-between px-3 py-2 rounded-lg text-[12px] font-bold transition-all"
:class="[
isSelected(option)
? 'bg-blue-600/10 text-blue-400'
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40'
? 'bg-indigo-50 text-indigo-600'
: 'text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100'
]"
@click="selectOption(option)"
>
@@ -216,10 +216,10 @@ onUnmounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--border-secondary, #e4e4e7);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
background: var(--border-primary, #d4d4d8);
}
</style>
+46
View File
@@ -157,6 +157,16 @@
<polyline points="12,6 12,12 16,14" />
</g>
<!-- 星星/闪耀图标 -->
<g v-else-if="name === 'sparkles' || name === 'star'">
<path d="M12 3l1.8 5.4L19 9.7l-4.2 3.6L16.6 19 12 15.8 7.4 19l1.8-5.7L5 9.7l5.2-1.3L12 3z" />
</g>
<!-- 灯泡图标 (提示) -->
<g v-else-if="name === 'lightbulb'">
<path d="M9 18h6M10 22h4M12 2a7 7 0 0 0-4 12.7c.6.5 1 1.2 1 2V17h6v-.3c0-.8.4-1.5 1-2A7 7 0 0 0 12 2z" />
</g>
<!-- 用户图标 -->
<g v-else-if="name === 'user'">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
@@ -367,6 +377,42 @@
/>
</g>
<!-- 服务器图标 -->
<g v-else-if="name === 'server'">
<rect x="2" y="2" width="20" height="8" rx="2" />
<rect x="2" y="14" width="20" height="8" rx="2" />
<line x1="6" x2="6.01" y1="6" y2="6" />
<line x1="6" x2="6.01" y1="18" y2="18" />
</g>
<!-- 地球图标 (globe) -->
<g v-else-if="name === 'globe'">
<circle cx="12" cy="12" r="10" />
<path d="M2 12h20" />
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
</g>
<!-- 保存图标 -->
<g v-else-if="name === 'save'">
<path d="M19 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h11l5 5v11a2 2 0 0 1-2 2z" />
<polyline points="17 21 17 13 7 13 7 21" />
<polyline points="7 3 7 8 15 8" />
</g>
<!-- 布局图标 (layout) -->
<g v-else-if="name === 'layout'">
<rect x="3" y="3" width="18" height="18" rx="2" />
<line x1="3" x2="21" y1="9" y2="9" />
<line x1="9" x2="9" y1="21" y2="9" />
</g>
<!-- 图片图标 (image) -->
<g v-else-if="name === 'image'">
<rect x="3" y="3" width="18" height="18" rx="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-5-5L5 21" />
</g>
<!-- 搜索图标 -->
<g v-else-if="name === 'search'">
<circle cx="11" cy="11" r="8" />
+11 -11
View File
@@ -953,8 +953,8 @@ onUnmounted(() => {
position: relative;
width: 100%;
height: 100%;
background: #000000;
color: #ffffff;
background: var(--bg-primary, #000000);
color: var(--text-primary, #ffffff);
overflow: hidden;
display: flex;
flex-direction: column;
@@ -1136,7 +1136,7 @@ onUnmounted(() => {
.default-cover {
width: 100%;
height: 100%;
background: linear-gradient(135deg, #333, #555);
background: linear-gradient(135deg, var(--bg-quaternary, #333333), #555);
display: flex;
align-items: center;
justify-content: center;
@@ -1152,7 +1152,7 @@ onUnmounted(() => {
font-size: 2.2rem;
font-weight: 700;
margin: 0 0 0.5rem 0;
color: #ffffff;
color: var(--text-primary, #ffffff);
line-height: 1.2;
letter-spacing: -0.02em;
white-space: normal;
@@ -1246,7 +1246,7 @@ onUnmounted(() => {
.progress-fill {
height: 100%;
background: #ffffff;
background: var(--card-bg, #ffffff);
border-radius: 3px;
pointer-events: none;
}
@@ -1256,7 +1256,7 @@ onUnmounted(() => {
top: 50%;
width: 14px;
height: 14px;
background: #ffffff;
background: var(--card-bg, #ffffff);
border-radius: 50%;
transform: translate(-50%, -50%) scale(0);
transition: transform 0.1s ease;
@@ -1306,7 +1306,7 @@ onUnmounted(() => {
.secondary-btn.active {
opacity: 1;
color: #ffffff;
color: var(--text-primary, #ffffff);
text-shadow: 0 0 8px rgba(255, 255, 255, 0.4);
}
@@ -1472,7 +1472,7 @@ onUnmounted(() => {
width: 100%;
text-align: center;
background: transparent;
color: #333; /* 深色文字 */
color: var(--text-quaternary, #333333); /* 深色文字 */
padding: 8px 12px; /* 减小内边距 */
border-radius: 8px;
font-size: 0.85rem; /* 稍微减小字体 */
@@ -1490,7 +1490,7 @@ onUnmounted(() => {
.badge-quality-option.active {
color: #007aff; /* 蓝色字 */
background: #ffffff;
background: var(--card-bg, #ffffff);
font-weight: 600;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
}
@@ -1522,7 +1522,7 @@ onUnmounted(() => {
width: 20px;
height: 5px;
border-radius: 3px;
background: #ffffff;
background: var(--card-bg, #ffffff);
box-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
}
@@ -1759,7 +1759,7 @@ onUnmounted(() => {
}
.progress-fill {
background: #ffffff;
background: var(--card-bg, #ffffff);
}
.progress-thumb {
+6 -6
View File
@@ -70,8 +70,8 @@ defineEmits(['close'])
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
background: #21242d;
color: #ffffff;
background: var(--bg-tertiary, #21242d);
color: var(--text-primary, #ffffff);
max-width: 400px;
min-width: 300px;
font-family: 'MiSans', sans-serif;
@@ -79,7 +79,7 @@ defineEmits(['close'])
}
.notification.success {
border-left: 4px solid #10b981;
border-left: 4px solid var(--border-secondary, #10b981);
}
.notification.error {
@@ -140,7 +140,7 @@ defineEmits(['close'])
}
.notification-close:hover {
color: #ffffff;
color: var(--text-primary, #ffffff);
}
/* 进度条 */
@@ -161,7 +161,7 @@ defineEmits(['close'])
}
.success .notification-progress-bar {
background-color: #10b981;
background-color: var(--bg-secondary, #10b981);
}
.error .notification-progress-bar {
@@ -169,7 +169,7 @@ defineEmits(['close'])
}
.info .notification-progress-bar {
background-color: #0b5afe;
background-color: var(--bg-secondary, #0b5afe);
}
@keyframes progress-shrink {
+6 -6
View File
@@ -82,8 +82,8 @@ onMounted(() => {
padding: 16px;
border-radius: 8px;
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
background: #21242d;
color: #ffffff;
background: var(--bg-tertiary, #21242d);
color: var(--text-primary, #ffffff);
max-width: 400px;
min-width: 300px;
font-family: 'MiSans', sans-serif;
@@ -94,7 +94,7 @@ onMounted(() => {
}
.notification-item.success {
border-left: 4px solid #10b981;
border-left: 4px solid var(--border-secondary, #10b981);
}
.notification-item.error {
@@ -158,7 +158,7 @@ onMounted(() => {
}
.notification-close:hover {
color: #ffffff;
color: var(--text-primary, #ffffff);
}
/* 进度条 */
@@ -179,7 +179,7 @@ onMounted(() => {
}
.success .notification-progress-bar {
background-color: #10b981;
background-color: var(--bg-secondary, #10b981);
}
.error .notification-progress-bar {
@@ -187,7 +187,7 @@ onMounted(() => {
}
.info .notification-progress-bar {
background-color: #0b5afe;
background-color: var(--bg-secondary, #0b5afe);
}
@keyframes progress-shrink {
+28 -28
View File
@@ -2,11 +2,11 @@
<div class="user-tutorial-container relative">
<div class="tutorial-header flex justify-between items-start">
<div>
<h3 class="text-lg font-black text-zinc-100 flex items-center gap-2">
<BookOpen class="w-5 h-5 text-blue-400" />
<h3 class="text-lg font-black text-text-primary flex items-center gap-2">
<BookOpen class="w-5 h-5 text-primary" />
使用指南
</h3>
<p class="text-xs text-zinc-500 mt-1">快速了解如何使用 VoiceHub 校园广播系统</p>
<p class="text-xs text-text-secondary mt-1">快速了解如何使用宝一外校园点歌系统</p>
</div>
<button
class="start-tour-btn"
@@ -19,7 +19,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
<div class="tutorial-card">
<div class="card-icon bg-blue-500/10 text-blue-400">
<div class="card-icon bg-primary/10 text-primary">
<Search class="w-6 h-6" />
</div>
<h4 class="card-title">1. 搜索与投稿</h4>
@@ -27,7 +27,7 @@
</div>
<div class="tutorial-card">
<div class="card-icon bg-emerald-500/10 text-emerald-400">
<div class="card-icon bg-success/10 text-success">
<ThumbsUp class="w-6 h-6" />
</div>
<h4 class="card-title">2. 互动与点赞</h4>
@@ -35,7 +35,7 @@
</div>
<div class="tutorial-card">
<div class="card-icon bg-purple-500/10 text-purple-400">
<div class="card-icon bg-[#a855f7]/10 text-[#a855f7]">
<CalendarDays class="w-6 h-6" />
</div>
<h4 class="card-title">3. 关注排期与通知</h4>
@@ -92,7 +92,7 @@ const windowSize = ref({ width: 0, height: 0 })
const tourSteps = [
{
element: '.user-tutorial-container',
title: '欢迎来到 VoiceHub!',
title: '欢迎来到宝一外校园点歌系统!',
description: '这是一个简单的引导,带您快速了解如何使用校园广播系统。',
position: 'top'
},
@@ -276,25 +276,25 @@ onMounted(() => {
<style scoped>
.user-tutorial-container {
background: rgba(0, 0, 0, 0.4);
background: var(--bg-secondary, rgba(0, 0, 0, 0.4));
border-radius: 16px;
padding: 1.5rem;
margin-top: 1.5rem;
border: 1px solid rgba(255, 255, 255, 0.05);
border: 1px solid var(--border-secondary, rgba(255, 255, 255, 0.05));
backdrop-filter: blur(10px);
}
.tutorial-card {
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
background: var(--bg-tertiary, rgba(255, 255, 255, 0.02));
border: 1px solid var(--border-secondary, rgba(255, 255, 255, 0.05));
border-radius: 12px;
padding: 1.25rem;
transition: all 0.3s ease;
}
.tutorial-card:hover {
background: rgba(255, 255, 255, 0.04);
border-color: rgba(255, 255, 255, 0.1);
background: var(--bg-hover, rgba(255, 255, 255, 0.04));
border-color: var(--border-tertiary, rgba(255, 255, 255, 0.1));
transform: translateY(-2px);
}
@@ -312,14 +312,14 @@ onMounted(() => {
font-family: 'MiSans', sans-serif;
font-weight: 800;
font-size: 0.95rem;
color: rgba(255, 255, 255, 0.9);
color: var(--text-primary, rgba(255, 255, 255, 0.9));
margin-bottom: 0.5rem;
}
.card-desc {
font-family: 'MiSans', sans-serif;
font-size: 0.8rem;
color: rgba(255, 255, 255, 0.5);
color: var(--text-secondary, rgba(255, 255, 255, 0.5));
line-height: 1.6;
}
@@ -359,15 +359,15 @@ onMounted(() => {
z-index: 9999;
border-radius: 8px;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5), 0 0 15px rgba(0, 0, 0, 0.5);
border: 2px solid #3b82f6;
border: 2px solid var(--border-secondary, #3b82f6);
pointer-events: none;
transition: all 0.4s ease;
background: transparent;
}
.tour-popover {
background: #18181b;
border: 1px solid #27272a;
background: var(--bg-secondary, #18181b);
border: 1px solid var(--border-secondary, #27272a);
border-radius: 12px;
padding: 1.25rem;
width: 320px;
@@ -387,14 +387,14 @@ onMounted(() => {
.tour-title {
font-size: 1.1rem;
font-weight: 800;
color: #f4f4f5;
color: var(--text-primary);
margin: 0;
}
.tour-close-btn {
background: none;
border: none;
color: #71717a;
color: var(--text-tertiary);
font-size: 1.5rem;
line-height: 1;
cursor: pointer;
@@ -403,11 +403,11 @@ onMounted(() => {
}
.tour-close-btn:hover {
color: #f4f4f5;
color: var(--text-primary);
}
.tour-body p {
color: #a1a1aa;
color: var(--text-secondary);
font-size: 0.9rem;
line-height: 1.5;
margin: 0 0 1rem 0;
@@ -420,7 +420,7 @@ onMounted(() => {
}
.tour-progress {
color: #71717a;
color: var(--text-tertiary);
font-size: 0.8rem;
font-weight: 600;
}
@@ -440,9 +440,9 @@ onMounted(() => {
}
.tour-btn-secondary {
background: #27272a;
color: #d4d4d8;
border: 1px solid #3f3f46;
background: var(--bg-tertiary, #27272a);
color: var(--text-primary);
border: 1px solid var(--border-secondary, #3f3f46);
}
.tour-btn-secondary:hover {
@@ -451,11 +451,11 @@ onMounted(() => {
.tour-btn-primary {
background: #3b82f6;
color: white;
color: var(--btn-primary-text);
border: none;
}
.tour-btn-primary:hover {
background: #2563eb;
background: var(--bg-tertiary, #2563eb);
}
</style>
+2 -2
View File
@@ -37,13 +37,13 @@ defineProps({
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #1a0b2e;
background-color: var(--surface-primary, var(--bg-primary, #1a0b2e));
}
.bg-overlay {
position: absolute;
inset: 0;
background: linear-gradient(to top, #000, transparent, transparent);
background: linear-gradient(to top, var(--bg-primary, #111111), transparent, transparent);
opacity: 0.8;
z-index: 10;
}
@@ -74,7 +74,7 @@ const formatDate = (dateStr) => {
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #000;
background-color: var(--bg-primary, #111111);
}
.bg-blur {
@@ -120,7 +120,7 @@ const formatDate = (dateStr) => {
}
.highlight {
color: #fff;
color: var(--text-primary, #ffffff);
font-weight: 700;
margin: 0 0.5rem;
}
@@ -141,7 +141,7 @@ const formatDate = (dateStr) => {
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
border: 1px solid rgba(255, 255, 255, 0.1);
background: #111827;
background: var(--bg-secondary, #111827);
display: flex;
align-items: center;
justify-content: center;
@@ -181,7 +181,7 @@ const formatDate = (dateStr) => {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
color: #fff;
color: var(--text-primary, #ffffff);
}
.song-artist {
+2 -2
View File
@@ -8,7 +8,7 @@
<div v-show="active" class="content">
<div class="year-bg">{{ data.year }}</div>
<div class="sub-title">年度点歌报告</div>
<h1 class="main-title">VoiceHub</h1>
<h1 class="main-title">宝一外校园点歌系统</h1>
</div>
</Transition>
</div>
@@ -31,7 +31,7 @@ defineProps({
align-items: center;
justify-content: center;
overflow: hidden;
background: radial-gradient(circle at center, #111827, #000000);
background: radial-gradient(circle at center, var(--bg-secondary, #111827), #000000);
}
.blob {
+2 -2
View File
@@ -54,7 +54,7 @@ const formatPlatform = (platform) => {
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #111827; /* gray-900 */
background-color: var(--bg-secondary, #111827); /* gray-900 */
}
.bg-gradient {
@@ -63,7 +63,7 @@ const formatPlatform = (platform) => {
left: 0;
width: 100%;
height: 50%;
background: linear-gradient(to bottom, #1f2937, transparent);
background: linear-gradient(to bottom, var(--bg-secondary, #1f2937), transparent);
opacity: 0.2;
}
+5 -5
View File
@@ -56,13 +56,13 @@ defineEmits(['share'])
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #000;
background-color: var(--bg-primary, #111111);
}
.bg-gradient {
position: absolute;
inset: 0;
background: radial-gradient(circle at center, #1f2937, #000, #000);
background: radial-gradient(circle at center, var(--bg-secondary, #1f2937), #000, #000);
opacity: 0.5;
}
@@ -132,8 +132,8 @@ defineEmits(['share'])
width: 100%;
padding: 1rem 2rem;
border-radius: 9999px;
background: #fff;
color: #000;
background: var(--card-bg, #fff);
color: var(--text-primary, #000);
font-weight: 700;
font-size: 1.125rem;
overflow: hidden;
@@ -173,7 +173,7 @@ defineEmits(['share'])
border-radius: 9999px;
background: rgba(255, 255, 255, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
color: #fff;
color: var(--text-primary, #ffffff);
font-weight: 700;
font-size: 1.125rem;
backdrop-filter: blur(12px);
+3 -3
View File
@@ -7,7 +7,7 @@
<div class="card-content">
<div class="header">
<div class="logo">VoiceHub</div>
<div class="logo">宝一外校园点歌系统</div>
<div class="year">{{ data.year }}</div>
</div>
@@ -63,7 +63,7 @@ defineExpose({
height: 100%;
min-height: 600px;
overflow: hidden;
background: linear-gradient(135deg, #0f0f23 0%, #1a1a2e 50%, #16213e 100%);
background: linear-gradient(135deg, var(--bg-secondary, #0f0f23) 0%, #1a1a2e 50%, #16213e 100%);
}
.card-background {
@@ -127,7 +127,7 @@ defineExpose({
.main-title {
font-size: 3.5rem;
font-weight: 900;
background: linear-gradient(135deg, #ffffff 0%, #e5e7eb 50%, #9ca3af 100%);
background: linear-gradient(135deg, var(--card-bg, #ffffff) 0%, #e5e7eb 50%, #9ca3af 100%);
-webkit-background-clip: text;
background-clip: text;
color: transparent;
+1 -1
View File
@@ -43,7 +43,7 @@ defineProps({
align-items: center;
justify-content: center;
overflow: hidden;
background-color: #030712; /* gray-950 */
background-color: var(--surface-primary, var(--bg-primary, #030712));
}
.bg-gradient {
+13 -5
View File
@@ -21,7 +21,9 @@ const siteConfig = ref({
icpNumber: '',
gonganNumber: '',
defaultTheme: null,
enabledThemes: null
enabledThemes: null,
statisticsCode: '',
statisticsCodeEnabled: false
})
const isLoaded = ref(false)
@@ -50,7 +52,7 @@ export const useSiteConfig = () => {
siteConfig.value = {
siteTitle: '校园广播站点歌系统',
siteLogoUrl: '/images/site-logo.png',
schoolLogoHomeUrl: '/images/school-logo-white.png',
schoolLogoHomeUrl: '/images/school-logo-black.png',
schoolLogoPrintUrl: '/images/school-logo-transparent.png',
siteDescription: '校园广播站点歌系统 - 让你的声音被听见',
siteVersion: '',
@@ -60,8 +62,10 @@ export const useSiteConfig = () => {
enableReplayRequests: false,
enableCollaborativeSubmission: true,
enableSubmissionRemarks: false,
defaultTheme: 'ClassicLight',
enabledThemes: '["ClassicLight","ModernLight"]'
defaultTheme: 'ClassicDark',
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]',
statisticsCode: '',
statisticsCodeEnabled: false
}
isLoaded.value = true
} finally {
@@ -77,7 +81,7 @@ export const useSiteConfig = () => {
})
const schoolLogoHomeUrl = computed(() => {
const raw = (siteConfig.value.schoolLogoHomeUrl || '').trim()
const fallback = '/images/school-logo-white.png'
const fallback = '/images/school-logo-black.png'
if (!raw) return fallback
if (raw === '/images/school-logo-white.png' || raw === '/images/school-logo-black.png') return fallback
@@ -99,6 +103,8 @@ export const useSiteConfig = () => {
)
const enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true)
const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled)
const statisticsCode = computed(() => siteConfig.value.statisticsCode || '')
const statisticsCodeEnabled = computed(() => !!siteConfig.value.statisticsCodeEnabled)
function parseEnabledThemes(raw) {
if (Array.isArray(raw)) return raw.filter(Boolean)
@@ -158,6 +164,8 @@ export const useSiteConfig = () => {
smtpEnabled,
defaultTheme,
enabledThemes,
statisticsCode,
statisticsCodeEnabled,
fetchSiteConfig,
initSiteConfig,
refreshSiteConfig
+56
View File
@@ -17,6 +17,7 @@ export const useSongs = () => {
const playTimes = ref<PlayTime[]>([])
const playTimeEnabled = ref(false)
const songCount = ref(0)
const expectedPlayDates = ref<any[]>([])
// 显示通知
const showNotification = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
@@ -916,6 +917,56 @@ export const useSongs = () => {
}
}
// 获取期望播出日期及空闲档期
const fetchExpectedPlayDates = async (silent = false, forceRefresh = false) => {
if (!silent) {
loading.value = true
}
try {
const data = await dedup.dedupedRequest(
'expected-play-dates',
async () => {
const url = forceRefresh
? `/api/expected-play-dates?t=${Date.now()}`
: '/api/expected-play-dates'
return await $fetch(url)
},
undefined,
forceRefresh
)
if (Array.isArray(data)) {
expectedPlayDates.value = data
} else if (data && Array.isArray((data as any).data)) {
expectedPlayDates.value = (data as any).data
} else {
expectedPlayDates.value = []
}
return expectedPlayDates.value
} catch (err: any) {
if (!silent) {
error.value = err.message || '获取档期数据失败'
}
return expectedPlayDates.value
} finally {
if (!silent) {
loading.value = false
}
}
}
const totalFreeSlots = computed(() =>
expectedPlayDates.value.reduce(
(sum, d) => sum + (Array.isArray(d.freeSequences) ? d.freeSequences.length : 0),
0
)
)
const openDaysCount = computed(() =>
expectedPlayDates.value.filter((d) => !d.isFull).length
)
const expectedPlayDatesLoaded = computed(() => Array.isArray(expectedPlayDates.value))
// 初始化加载
const initialize = async () => {
await fetchPlayTimes()
@@ -932,6 +983,10 @@ export const useSongs = () => {
publicSchedules,
visibleSongs,
songCount,
expectedPlayDates,
totalFreeSlots,
openDaysCount,
expectedPlayDatesLoaded,
loading,
error,
notification,
@@ -943,6 +998,7 @@ export const useSongs = () => {
fetchPublicSchedules,
fetchPlayTimes,
fetchSongCount,
fetchExpectedPlayDates,
refreshSongsSilent,
refreshSchedulesSilent,
checkSimilarSongs,
+7 -3
View File
@@ -99,13 +99,16 @@ export function useTheme() {
const defaultTheme = computed<string>(() => {
if (siteConfigLoaded.value && siteDefaultTheme.value) {
return siteDefaultTheme.value
const v = siteDefaultTheme.value
if (v === 'System' || v === 'dark' || v.endsWith('Dark')) return 'ModernLight'
return v
}
const fromRuntime = config.public?.defaultTheme
if (typeof fromRuntime === 'string' && fromRuntime) {
if (fromRuntime === 'System' || fromRuntime === 'dark' || fromRuntime.endsWith('Dark')) return 'ModernLight'
return fromRuntime
}
return 'System'
return 'ModernLight'
})
const theme = computed<string>({
@@ -193,7 +196,8 @@ export function useTheme() {
if (stored) {
const valid = firstValidTheme(stored)
if (valid === stored || enabledThemes.value.includes(stored)) {
const effectiveStored = resolveEffectiveTheme(valid) as string
if ((valid === stored || enabledThemes.value.includes(stored)) && effectiveStored !== 'dark') {
setTheme(stored)
watchSystemTheme()
return
+19 -16
View File
@@ -10,27 +10,30 @@ import {fileURLToPath} from 'url';
config({ path: path.resolve(process.cwd(), '.env') });
// 检查环境变量
if (!process.env.DATABASE_URL) {
throw new Error('DATABASE_URL environment variable is not set');
// 注意:在 CI/Nuxt prerender 构建阶段,可能没有 DATABASE_URL
const connectionString = process.env.DATABASE_URL || 'postgresql://postgres:dummy@localhost:5432/dummy';
if (!process.env.DATABASE_URL && !process.env.npm_lifecycle_script?.includes('build')) {
console.warn('Warning: DATABASE_URL environment variable is not set. Using dummy connection string for build/prerender.');
}
// 创建PostgreSQL连接
const connectionString = process.env.DATABASE_URL;
// 检测数据库类型
const isNeonDatabase = connectionString.includes('neon.tech') || connectionString.includes('neon.database.com');
// 根据数据库类型选择配置
// 检测 Serverless 部署环境
const isServerlessEnv = process.env.NETLIFY === 'true' || !!process.env.VERCEL;
// 根据数据库类型和部署环境选择配置
const getDatabaseConfig = () => {
if (isNeonDatabase) {
// Neon Database Serverless 优化配置
if (isNeonDatabase || isServerlessEnv) {
// Neon Database / Serverless 环境 (Netlify/Vercel) 优化配置
return {
max: 1, // Serverless 环境下每个实例保持最小连接数,利用 Neon 自身的连接池
idle_timeout: 0, // 立即释放空闲连接,适应 Serverless 的快速冻结特性
connect_timeout: 10, // Neon 连接速度快,减少超时时间
max: 1, // Serverless 环境下每个实例保持最小连接数,利用平台自身的连接池
idle_timeout: 1, // 尽快释放空闲连接,适应 Serverless 的快速冻结特性
connect_timeout: 10, // 减少连接超时时间
max_lifetime: 3600, // 连接最大生命周期(1小时)
ssl: 'require', // Neon 默认需要 SSL
ssl: isNeonDatabase ? 'require' : (connectionString.includes('sslmode=require') || connectionString.includes('ssl=true') ? 'require' : false),
prepare: false, // 禁用预处理语句以提高兼容性
keep_alive: false, // 禁用 TCP keep-alive,避免 Serverless 环境下连接残留
transform: {
undefined: null, // 将undefined转换为null
},
@@ -41,11 +44,11 @@ const getDatabaseConfig = () => {
debug: process.env.NODE_ENV === 'development' && process.env.DEBUG_SQL === 'true'
};
} else {
// 标准 PostgreSQL 数据库配置
// 标准 PostgreSQL 数据库配置(本地开发 / 常规 Node Server)
return {
max: process.env.NODE_ENV === 'production' ? 10 : 5, // 普通PostgreSQL可以支持更多连接
idle_timeout: 20, // 增加空闲超时时间
connect_timeout: 30, // 增加连接超时时间以适应网络延迟
max: 10, // 普通PostgreSQL可以支持更多连接
idle_timeout: 20, // 空闲超时时间
connect_timeout: 30, // 连接超时时间以适应网络延迟
max_lifetime: 3600, // 连接最大生命周期(1小时)
ssl: connectionString.includes('sslmode=require') || connectionString.includes('ssl=true') ? 'require' : false,
prepare: false, // 禁用预处理语句以提高兼容性
@@ -0,0 +1,28 @@
-- 微信订阅消息的授权记账表
--
-- 背景:微信没有「还剩几条订阅授权」的查询接口,而一次性订阅是发一条扣一条。
-- 这里按 (userId, templateId) 记账,让服务端和体检接口能说清「为什么收不到」。
-- 记账只是近似值,不参与是否发送的硬判定(可用 force 绕过)。
CREATE TABLE IF NOT EXISTS "WechatSubscribeGrant" (
"id" serial PRIMARY KEY,
"userId" integer NOT NULL,
"templateId" text NOT NULL,
"kind" text,
"grantedCount" integer DEFAULT 0 NOT NULL,
"sentCount" integer DEFAULT 0 NOT NULL,
"blockedCount" integer DEFAULT 0 NOT NULL,
"lastGrantedAt" timestamp,
"lastSentAt" timestamp,
"lastBlockedAt" timestamp,
"lastErrorCode" integer,
"lastErrorMessage" text,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL
);
CREATE UNIQUE INDEX IF NOT EXISTS "WechatSubscribeGrant_userId_templateId_unq"
ON "WechatSubscribeGrant" ("userId", "templateId");
CREATE INDEX IF NOT EXISTS "WechatSubscribeGrant_userId_idx"
ON "WechatSubscribeGrant" ("userId");
@@ -0,0 +1,28 @@
-- 酷狗导入:管理员账号凭证 + 按日歌单同步记录表
ALTER TABLE "SystemSettings"
ADD COLUMN IF NOT EXISTS "kugouApiBaseUrl" text,
ADD COLUMN IF NOT EXISTS "kugouAdminCookie" text,
ADD COLUMN IF NOT EXISTS "kugouAdminToken" text,
ADD COLUMN IF NOT EXISTS "kugouAdminUser" jsonb,
ADD COLUMN IF NOT EXISTS "kugouAdminBoundAt" timestamp;
CREATE TABLE IF NOT EXISTS "KuGouPlaylistSync" (
"id" serial PRIMARY KEY,
"createdAt" timestamp DEFAULT now() NOT NULL,
"updatedAt" timestamp DEFAULT now() NOT NULL,
"playDate" date NOT NULL,
"semesterId" integer,
"playlistId" text,
"playlistName" text NOT NULL,
"playlistUrl" text,
"totalSongs" integer DEFAULT 0 NOT NULL,
"addedSongs" integer DEFAULT 0 NOT NULL,
"missingSongs" jsonb DEFAULT '[]'::jsonb NOT NULL,
"status" text DEFAULT 'pending' NOT NULL,
"error" text,
"triggeredByUserId" integer,
"rawResponse" jsonb DEFAULT '{}'::jsonb NOT NULL
);
CREATE INDEX IF NOT EXISTS "KuGouPlaylistSync_playDate_idx"
ON "KuGouPlaylistSync" ("playDate" DESC);
+63 -3
View File
@@ -1,4 +1,4 @@
import {bigint, boolean, integer, pgEnum, pgTable, serial, text, timestamp, uuid, varchar, unique, index, uniqueIndex} from 'drizzle-orm/pg-core';
import {bigint, boolean, integer, pgEnum, pgTable, serial, text, timestamp, uuid, varchar, unique, index, uniqueIndex, jsonb} from 'drizzle-orm/pg-core';
import {relations, sql} from 'drizzle-orm';
// 枚举定义
@@ -350,12 +350,37 @@ export const systemSettings = pgTable('SystemSettings', {
autoBackupConfig: text('autoBackupConfig'),
statisticsCode: text('statisticsCode'),
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
defaultTheme: text('defaultTheme').default('ClassicLight').notNull(),
enabledThemes: text('enabledThemes').default('[\"ClassicLight\",\"ModernLight\"]').notNull(),
defaultTheme: text('defaultTheme').default('ClassicDark').notNull(),
enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(),
enabledPlatforms: text('enabledPlatforms'),
platformOrder: text('platformOrder'),
kugouApiBaseUrl: text('kugouApiBaseUrl'),
kugouAdminCookie: text('kugouAdminCookie'),
kugouAdminToken: text('kugouAdminToken'),
kugouAdminUser: jsonb('kugouAdminUser').$type<Record<string, any>>(),
kugouAdminBoundAt: timestamp('kugouAdminBoundAt'),
});
export const kuGouPlaylistSyncs = pgTable('KuGouPlaylistSync', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
playDate: timestamp('playDate').notNull(),
semesterId: integer('semesterId'),
playlistId: text('playlistId'),
playlistName: text('playlistName').notNull(),
playlistUrl: text('playlistUrl'),
totalSongs: integer('totalSongs').default(0).notNull(),
addedSongs: integer('addedSongs').default(0).notNull(),
missingSongs: jsonb('missingSongs').default('[]').$type<any[]>().notNull(),
status: text('status').default('pending').notNull(),
error: text('error'),
triggeredByUserId: integer('triggeredByUserId'),
rawResponse: jsonb('rawResponse').default('{}').$type<Record<string, any>>().notNull(),
}, (table) => [
index('kugou_playlist_sync_play_date_idx').on(table.playDate),
]);
// 歌曲黑名单表
export const songBlacklists = pgTable('SongBlacklist', {
id: serial('id').primaryKey(),
@@ -595,6 +620,41 @@ export const userIdentities = pgTable('UserIdentity', {
unq: unique().on(t.provider, t.providerUserId),
}));
/**
* 微信订阅消息的授权记账表。
*
* 为什么需要它:微信**不提供**「这个用户还剩几条订阅授权」的查询接口,
* 而「一次性订阅」是发一条扣一条。所以这里按 (userId, templateId) 记账:
* 用户在小程序里点「允许」时 grantedCount +1,服务端发送成功时 sentCount +1,
* 被微信拒绝(如 43101)时 blockedCount +1 并记下 errcode。
*
* ⚠️ 这里算出的额度是**我们自己数的近似值**:用户可能在别处授权、上报可能没发出去、
* 微信侧额度也可能过期,真值只有发一条收到 43101 才知道。
*
* **所以这张表只用于展示和排查,绝不参与「发不发」的决策** —— 早先的版本拿它做额度
* 预检(记账为 0 就跳过不发送),结果记账一旦和微信侧不同步,通知就被无声地全部丢掉。
* 现在服务端一律照常尝试发送,让微信回 errcode 来裁决。
*/
export const wechatSubscribeGrants = pgTable('WechatSubscribeGrant', {
id: serial('id').primaryKey(),
userId: integer('userId').notNull(),
templateId: text('templateId').notNull(),
/** 对应的模板键(如 SONG_SELECTED),模板被换掉后这里可能对不上,仅作展示用 */
kind: text('kind'),
grantedCount: integer('grantedCount').default(0).notNull(),
sentCount: integer('sentCount').default(0).notNull(),
blockedCount: integer('blockedCount').default(0).notNull(),
lastGrantedAt: timestamp('lastGrantedAt'),
lastSentAt: timestamp('lastSentAt'),
lastBlockedAt: timestamp('lastBlockedAt'),
lastErrorCode: integer('lastErrorCode'),
lastErrorMessage: text('lastErrorMessage'),
createdAt: timestamp('createdAt').defaultNow().notNull(),
updatedAt: timestamp('updatedAt').defaultNow().notNull(),
}, (t) => ({
unq: unique().on(t.userId, t.templateId),
}));
export const changelogEntries = pgTable('ChangelogEntry', {
id: serial('id').primaryKey(),
createdAt: timestamp('createdAt').defaultNow().notNull(),
+1 -1
View File
@@ -133,7 +133,7 @@ const { showToast } = useToast()
const config = useRuntimeConfig()
const hasOAuthProviders = computed(() => {
return config.public.oauth.github || config.public.oauth.casdoor || config.public.oauth.google
return config.public.oauth.github || config.public.oauth.casdoor || config.public.oauth.google || config.public.oauth.aggregate
})
const avatarError = ref(false)
+17 -16
View File
@@ -6,7 +6,7 @@
<div class="info-content">
<div class="logo-section">
<img
alt="VoiceHub Logo"
alt="宝一外校园点歌系统 Logo"
class="brand-logo"
:src="logo"
width="160"
@@ -14,11 +14,11 @@
decoding="async"
fetchpriority="high"
>
<h1 v-if="siteTitle" class="brand-title">{{ siteTitle || 'VoiceHub' }}</h1>
<h1 v-if="siteTitle" class="brand-title">宝一外校园点歌系统</h1>
</div>
<div v-if="isFirstLogin" class="welcome-message">
<h2>欢迎使用VoiceHub</h2>
<h2>欢迎使用宝一外校园点歌系统</h2>
<p>为了保障您的账号安全,请设置一个新的密码</p>
</div>
<div v-else class="security-message">
@@ -132,7 +132,7 @@ onMounted(async () => {
<style scoped>
.auth-layout {
min-height: 100vh;
background: #0a0a0a;
background: var(--bg-primary, #ffffff);
display: flex;
align-items: center;
justify-content: center;
@@ -142,13 +142,14 @@ onMounted(async () => {
.auth-container {
width: 100%;
max-width: 1200px;
background: #111111;
background: var(--surface-primary, var(--bg-primary, #ffffff));
border-radius: 24px;
border: 1px solid #1f1f1f;
border: 1px solid var(--border-primary, #e5e7eb);
overflow: hidden;
display: grid;
grid-template-columns: 1fr 1fr;
min-height: 600px;
box-shadow: 0 10px 40px -10px rgba(15, 23, 42, 0.1);
}
.info-section {
@@ -194,7 +195,7 @@ onMounted(async () => {
font-size: 36px;
font-weight: 700;
margin: 0;
background: linear-gradient(135deg, #ffffff 0%, #f0f0f0 100%);
background: linear-gradient(135deg, var(--card-bg, #ffffff) 0%, #f0f0f0 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
@@ -266,7 +267,7 @@ onMounted(async () => {
display: flex;
align-items: center;
justify-content: center;
background: #111111;
background: var(--surface-primary, var(--bg-primary, #ffffff));
}
.form-container {
@@ -285,13 +286,13 @@ onMounted(async () => {
.form-header h2 {
font-size: 28px;
font-weight: 700;
color: #ffffff;
color: var(--text-primary, #1a2332);
margin: 0 0 8px 0;
}
.form-header p {
font-size: 16px;
color: #888888;
color: var(--text-secondary, #4a5568);
margin: 0;
}
@@ -305,10 +306,10 @@ onMounted(async () => {
align-items: center;
gap: 8px;
padding: 12px 20px;
background: #1a1a1a;
border: 1px solid #2a2a2a;
background: var(--surface-secondary, var(--bg-secondary, #f7fafc));
border: 1px solid var(--border-primary, #e5e7eb);
border-radius: 8px;
color: #cccccc;
color: var(--text-secondary, #4a5568);
text-decoration: none;
font-size: 14px;
font-weight: 500;
@@ -316,9 +317,9 @@ onMounted(async () => {
}
.back-link:hover {
background: #2a2a2a;
color: #ffffff;
border-color: #3a3a3a;
background: var(--surface-tertiary, var(--bg-tertiary, #edf2f7));
color: var(--text-primary, #1a2332);
border-color: var(--border-secondary, #cbd5e1);
}
.back-link svg {
+13 -13
View File
@@ -23,14 +23,14 @@
<!-- 主内容区域 -->
<main
class="flex-1 flex flex-col h-screen overflow-hidden lg:ml-64 relative bg-[#09090b] text-zinc-100"
class="flex-1 flex flex-col h-screen overflow-hidden lg:ml-64 relative bg-[#f8fafc] text-zinc-900"
>
<header
class="admin-main-header h-16 shrink-0 flex items-center justify-between px-4 md:px-8 border-b border-zinc-800 bg-zinc-950/60 backdrop-blur-xl z-30"
class="admin-main-header h-16 shrink-0 flex items-center justify-between px-4 md:px-8 border-b border-zinc-200 bg-white/80 backdrop-blur-xl z-30"
>
<div class="flex items-center gap-3">
<button
class="admin-mobile-menu-btn lg:hidden p-2 text-zinc-400 hover:bg-zinc-800 rounded-lg transition-colors"
class="admin-mobile-menu-btn lg:hidden p-2 text-zinc-600 hover:bg-zinc-100 rounded-lg transition-colors"
@click="toggleSidebar"
>
<Menu :size="20" />
@@ -39,7 +39,7 @@
</div>
<div class="flex items-center gap-2">
<button
class="px-3 py-1.5 text-xs font-semibold rounded-lg border border-zinc-700 text-zinc-300 hover:bg-zinc-800 transition-colors"
class="px-3 py-1.5 text-xs font-semibold rounded-lg border border-zinc-200 text-zinc-600 hover:bg-zinc-100 transition-colors"
@click="openOnboardingTutorial"
>
新手引导
@@ -102,12 +102,12 @@
<LazyAdminSchedulePrinter />
</div>
<!-- 歌单卡片图片 -->
<!-- 导入酷狗:账号扫码绑定 + 按排期自动创建/同步酷狗歌单 -->
<div
v-if="activeTab === 'playlist-card' && permissions.canAccessPage('print')"
v-if="activeTab === 'playlist-card' && permissions.canAccessPage('playlist-card')"
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
>
<LazyAdminPlaylistCardPage />
<LazyAdminKuGouImporter />
</div>
<!-- 数据分析 -->
@@ -319,7 +319,7 @@ const getPageTitle = () => {
songs: '歌曲管理',
schedule: '排期管理',
print: '打印排期',
'playlist-card': '图片歌单',
'playlist-card': '导入酷狗',
users: '用户管理',
blacklist: '黑名单管理',
'api-keys': 'API密钥管理',
@@ -343,7 +343,7 @@ const dynamicTitle = computed(() => {
if (siteTitle && siteTitle.value) {
return `${currentPageTitle} | ${siteTitle.value}`
}
return `${currentPageTitle} | 校园广播站点歌系统`
return `${currentPageTitle} | 宝一外校园点歌系统`
})
// 监听activeTab变化,更新页面标题
@@ -548,8 +548,8 @@ onUnmounted(() => {
.admin-layout {
display: flex;
min-height: 100vh;
background: #0a0a0a;
color: #ffffff;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #1a2332);
position: relative;
}
@@ -561,11 +561,11 @@ onUnmounted(() => {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: #27272a;
background: var(--border-primary, #cbd5e1);
border-radius: 10px;
}
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
background: #3f3f46;
background: var(--border-secondary, #94a3b8);
}
@media (max-width: 768px) {
+269
View File
@@ -0,0 +1,269 @@
<template>
<div class="auth-layout">
<div class="auth-container">
<div class="form-section">
<div class="form-header">
<div class="logo-row">
<img
:src="brandLogoSrc"
alt="Brand Logo"
class="brand-logo-center"
width="96"
height="96"
decoding="async"
fetchpriority="high"
>
<div v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()" class="logo-divider" />
<img
v-if="schoolLogoHomeUrl && schoolLogoHomeUrl.trim()"
:src="schoolLogoHomeUrl"
alt="学校Logo"
class="school-logo"
width="160"
height="160"
decoding="async"
fetchpriority="high"
>
</div>
<h1 class="form-title">宝一外校园点歌系统</h1>
<div class="header-divider" />
</div>
<div class="forgot-password-form">
<div class="page-intro">
<h2 class="page-heading">找回密码</h2>
<p class="page-subtitle">请联系管理员重置您的账号密码</p>
</div>
<div class="contact-card">
<div class="contact-card-icon">
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
<path d="M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
</svg>
</div>
<div class="contact-card-body">
<h3>联系管理员</h3>
<p>为保障账号安全,系统暂未开放自助密码找回。请通过以下方式联系管理员进行密码重置:</p>
<ul class="contact-tips">
<li>站内联系:登录有管理员权限的账号</li>
<li>线下联系:前往广播站办公室或指导老师</li>
<li>邮件联系:如有公开的管理员邮箱</li>
</ul>
</div>
</div>
<div class="form-actions">
<NuxtLink to="/login" class="back-to-login-btn">
返回登录
</NuxtLink>
</div>
</div>
</div>
</div>
<SiteFooter />
</div>
</template>
<script setup>
import { onMounted, computed } from 'vue'
import logo from '~~/public/images/logo.png'
const { siteTitle, initSiteConfig, schoolLogoHomeUrl } = useSiteConfig()
const brandLogoSrc = computed(() => {
return logo
})
onMounted(async () => {
await initSiteConfig()
if (typeof document !== 'undefined' && siteTitle.value) {
document.title = `找回密码 | ${siteTitle.value}`
}
})
</script>
<style scoped>
.auth-layout {
min-height: 100vh;
background: var(--bg-primary);
display: flex;
flex-direction: column;
align-items: center;
justify-content: flex-start;
padding: 20px;
}
.auth-container {
width: 100%;
max-width: 520px;
background: var(--bg-secondary);
border-radius: var(--radius-2xl);
border: 1px solid var(--border-primary);
box-shadow: var(--shadow-lg);
overflow: hidden;
margin: auto 0;
margin-bottom: 40px;
}
.form-section {
padding: 40px 32px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
background: var(--bg-secondary);
}
.form-header {
text-align: center;
margin-bottom: 24px;
width: 100%;
}
.logo-row {
display: flex;
align-items: center;
justify-content: center;
gap: 24px;
margin-bottom: 12px;
}
.logo-divider {
width: 1px;
height: 96px;
background: var(--border-secondary);
}
.brand-logo-center {
width: 96px;
height: 96px;
object-fit: contain;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
}
.school-logo {
width: 160px;
height: 160px;
object-fit: contain;
filter: drop-shadow(0 2px 8px rgba(0, 0, 0, 0.15));
}
.form-title {
font-size: 24px;
font-weight: var(--font-bold);
margin: 0;
color: var(--text-primary);
}
.header-divider {
height: 1px;
background: var(--border-secondary);
margin: 14px auto 0;
width: 100%;
}
.forgot-password-form {
width: 100%;
max-width: 400px;
}
.page-intro {
text-align: center;
margin-bottom: 28px;
}
.page-heading {
font-size: 28px;
font-weight: var(--font-bold);
color: var(--text-primary);
margin: 0 0 8px 0;
}
.page-subtitle {
font-size: 14px;
color: var(--text-tertiary);
margin: 0;
}
.contact-card {
display: flex;
gap: 16px;
padding: 24px;
background: var(--bg-tertiary);
border: 1px solid var(--border-secondary);
border-radius: var(--radius-lg);
margin-bottom: 28px;
}
.contact-card-icon {
width: 40px;
height: 40px;
border-radius: 12px;
background: rgba(59, 130, 246, 0.12);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.contact-card-icon svg {
width: 20px;
height: 20px;
}
.contact-card-body h3 {
font-size: 16px;
font-weight: var(--font-semibold);
color: var(--text-primary);
margin: 0 0 8px 0;
}
.contact-card-body p {
font-size: 14px;
color: var(--text-secondary);
margin: 0 0 12px 0;
line-height: 1.6;
}
.contact-tips {
margin: 0;
padding-left: 18px;
color: var(--text-secondary);
font-size: 13px;
line-height: 1.9;
}
.form-actions {
display: flex;
flex-direction: column;
gap: 12px;
}
.back-to-login-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%;
padding: 14px;
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);
text-decoration: none;
transition: all 0.2s ease;
}
.back-to-login-btn:hover {
background: var(--btn-primary-hover);
transform: translateY(-1px);
box-shadow: var(--shadow-md);
}
@media (max-width: 768px) {
.auth-layout { padding: 10px; }
.auth-container { border-radius: 16px; }
.form-section { padding: 30px 20px; }
.contact-card { padding: 18px; gap: 12px; }
}
</style>
+533 -230
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -25,7 +25,7 @@
fetchpriority="high"
>
</div>
<h1 class="form-title">{{ siteTitle ? siteTitle + ' | VoiceHub' : 'VoiceHub' }}</h1>
<h1 class="form-title">宝一外校园点歌系统</h1>
<div class="header-divider" />
</div>
<ClientOnly>
@@ -312,7 +312,7 @@ onMounted(async () => {
.icp-link:hover,
.voicehub-link:hover {
color: rgba(255, 255, 255, 0.8);
color: var(--text-secondary);
}
@media (max-width: 768px) {
+68 -68
View File
@@ -1,19 +1,19 @@
<template>
<div class="min-h-screen bg-zinc-950 text-zinc-200 pb-24">
<div class="min-h-screen bg-zinc-50 text-zinc-900 pb-24">
<!-- 顶部导航栏 -->
<div
class="sticky top-0 z-30 bg-zinc-950/80 backdrop-blur-xl border-b border-zinc-900/50 px-4 py-4 mb-8"
class="sticky top-0 z-30 bg-white/80 backdrop-blur-xl border-b border-zinc-200/50 px-4 py-4 mb-8"
>
<div class="max-w-[1000px] mx-auto flex items-center justify-between">
<div class="flex items-center gap-4">
<button
class="p-2 hover:bg-zinc-900 rounded-xl transition-all text-zinc-400 hover:text-zinc-100"
class="p-2 hover:bg-zinc-100 rounded-xl transition-all text-zinc-500 hover:text-zinc-900"
@click="goBack"
>
<ArrowLeft :size="20" />
</button>
<div>
<h1 class="text-xl font-black text-zinc-100 tracking-tight">消息设置</h1>
<h1 class="text-xl font-black text-zinc-900 tracking-tight">消息设置</h1>
<p class="text-[10px] text-zinc-500 font-medium uppercase tracking-widest mt-0.5">
Notification Settings
</p>
@@ -22,7 +22,7 @@
<button
:disabled="loading || saving"
class="flex items-center gap-2 px-6 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl shadow-lg shadow-blue-900/20 transition-all active:scale-95 disabled:opacity-50"
class="flex items-center gap-2 px-6 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-black rounded-xl shadow-lg shadow-indigo-500/20 transition-all active:scale-95 disabled:opacity-50"
@click="saveSettings"
>
<template v-if="saving"> <Loader2 :size="14" class="animate-spin" /> 保存中... </template>
@@ -33,19 +33,19 @@
<div class="max-w-[1000px] mx-auto px-4">
<div v-if="loading" class="flex flex-col items-center justify-center py-32">
<Loader2 :size="32" class="text-blue-500 animate-spin mb-4" />
<Loader2 :size="32" class="text-indigo-500 animate-spin mb-4" />
<p class="text-zinc-500 text-sm font-medium">加载设置中...</p>
</div>
<div v-else class="space-y-8">
<!-- 站内通知设置 -->
<section :class="sectionClass">
<div class="flex items-center gap-3 border-b border-zinc-800/50 pb-5 mb-6">
<div class="p-2.5 bg-blue-500/10 rounded-xl">
<Bell :size="20" class="text-blue-500" />
<div class="flex items-center gap-3 border-b border-zinc-200/50 pb-5 mb-6">
<div class="p-2.5 bg-indigo-500/10 rounded-xl">
<Bell :size="20" class="text-indigo-600" />
</div>
<div>
<h2 class="text-base font-black text-zinc-100">站内消息设置</h2>
<h2 class="text-base font-black text-zinc-900">站内消息设置</h2>
<p class="text-xs text-zinc-500 mt-0.5">配置您希望在系统内接收的消息类型</p>
</div>
</div>
@@ -54,14 +54,14 @@
<!-- 歌曲被选中消息 -->
<div :class="itemClass">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">歌曲被选中消息</h3>
<h3 class="text-sm font-bold text-zinc-800">歌曲被选中消息</h3>
<p class="text-[11px] text-zinc-500 mt-1">当您投稿的歌曲被选中播放时通知您</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"
class="w-5 h-5 rounded border-zinc-300 bg-white accent-indigo-600 cursor-pointer"
>
</div>
</div>
@@ -69,14 +69,14 @@
<!-- 歌曲已播放消息 -->
<div :class="itemClass">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">歌曲已播放消息</h3>
<h3 class="text-sm font-bold text-zinc-800">歌曲已播放消息</h3>
<p class="text-[11px] text-zinc-500 mt-1">当您投稿的歌曲播放完成时通知您</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"
class="w-5 h-5 rounded border-zinc-300 bg-white accent-indigo-600 cursor-pointer"
>
</div>
</div>
@@ -84,14 +84,14 @@
<!-- 歌曲获得投票消息 -->
<div :class="itemClass">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">歌曲获得投票消息</h3>
<h3 class="text-sm font-bold text-zinc-800">歌曲获得投票消息</h3>
<p class="text-[11px] text-zinc-500 mt-1">当您投稿的歌曲获得投票时通知您</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"
class="w-5 h-5 rounded border-zinc-300 bg-white accent-indigo-600 cursor-pointer"
>
</div>
</div>
@@ -99,14 +99,14 @@
<!-- 系统通知 -->
<div :class="itemClass">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">系统通知</h3>
<h3 class="text-sm font-bold text-zinc-800">系统通知</h3>
<p class="text-[11px] text-zinc-500 mt-1">接收系统重要通知和公告</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"
class="w-5 h-5 rounded border-zinc-300 bg-white accent-indigo-600 cursor-pointer"
>
</div>
</div>
@@ -114,7 +114,7 @@
<!-- 投票阈值设置 -->
<div :class="[itemClass, 'md:col-span-1']">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">投票通知阈值</h3>
<h3 class="text-sm font-bold text-zinc-800">投票通知阈值</h3>
<p class="text-[11px] text-zinc-500 mt-1">当投票数达到此阈值时才发送通知</p>
</div>
<div class="flex items-center gap-2">
@@ -123,16 +123,16 @@
type="number"
max="100"
min="1"
class="w-16 bg-zinc-950 border border-zinc-800 rounded-lg px-2 py-1.5 text-xs text-center focus:outline-none focus:border-blue-500/30"
class="w-16 bg-white border border-zinc-200 rounded-lg px-2 py-1.5 text-xs text-center focus:outline-none focus:border-indigo-500/30"
>
<span class="text-[10px] font-black text-zinc-600 uppercase">票</span>
<span class="text-[10px] font-black text-zinc-500 uppercase">票</span>
</div>
</div>
<!-- 通知刷新间隔 -->
<div :class="[itemClass, 'md:col-span-1']">
<div class="flex-1">
<h3 class="text-sm font-bold text-zinc-200">通知刷新间隔</h3>
<h3 class="text-sm font-bold text-zinc-800">通知刷新间隔</h3>
<p class="text-[11px] text-zinc-500 mt-1">设置系统自动检查新通知的时间间隔</p>
</div>
<div class="flex items-center gap-3">
@@ -142,9 +142,9 @@
max="300"
min="30"
step="30"
class="w-24 h-1.5 bg-zinc-800 rounded-full appearance-none accent-blue-600 cursor-pointer"
class="w-24 h-1.5 bg-zinc-200 rounded-full appearance-none accent-indigo-600 cursor-pointer"
>
<span class="text-[11px] font-bold text-blue-500 min-w-[40px] text-right"
<span class="text-[11px] font-bold text-indigo-600 min-w-[40px] text-right"
>{{ localSettings.refreshInterval }}s</span
>
</div>
@@ -154,12 +154,12 @@
<!-- 社交账号绑定 -->
<section :class="sectionClass">
<div class="flex items-center gap-3 border-b border-zinc-800/50 pb-5 mb-6">
<div class="flex items-center gap-3 border-b border-zinc-200/50 pb-5 mb-6">
<div class="p-2.5 bg-purple-500/10 rounded-xl">
<Link :size="20" class="text-purple-500" />
<Link :size="20" class="text-purple-600" />
</div>
<div>
<h2 class="text-base font-black text-zinc-100">社交账号绑定</h2>
<h2 class="text-base font-black text-zinc-900">社交账号绑定</h2>
<p class="text-xs text-zinc-500 mt-0.5">绑定您的社交账号以接收实时推送通知</p>
</div>
</div>
@@ -168,14 +168,14 @@
<!-- 邮箱绑定 -->
<div v-if="smtpEnabled" :class="cardClass">
<div class="flex items-center gap-3 mb-4">
<div class="p-2 bg-zinc-950 rounded-lg border border-zinc-800">
<Mail :size="16" class="text-zinc-400" />
<div class="p-2 bg-white rounded-lg border border-zinc-200">
<Mail :size="16" class="text-zinc-500" />
</div>
<h3 class="text-sm font-bold text-zinc-200">邮箱消息通知</h3>
<h3 class="text-sm font-bold text-zinc-800">邮箱消息通知</h3>
</div>
<div class="space-y-4">
<div v-if="userEmail" class="p-3 bg-zinc-950/50 border border-zinc-800 rounded-xl">
<div v-if="userEmail" class="p-3 bg-zinc-50 border border-zinc-200 rounded-xl">
<div class="flex items-center justify-between">
<div>
<p
@@ -183,14 +183,14 @@
>
当前绑定邮箱
</p>
<p class="text-sm font-medium text-zinc-200">{{ userEmail }}</p>
<p class="text-sm font-medium text-zinc-800">{{ userEmail }}</p>
</div>
<div
:class="[
'px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider',
emailVerified
? 'bg-emerald-500/10 text-emerald-500'
: 'bg-amber-500/10 text-amber-500'
? 'bg-emerald-500/10 text-emerald-600'
: 'bg-amber-500/10 text-amber-600'
]"
>
{{ emailVerified ? '已验证' : '待验证' }}
@@ -207,11 +207,11 @@
:disabled="bindingEmail"
type="email"
placeholder="请输入邮箱地址"
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm focus:outline-none focus:border-blue-500/30 w-full sm:w-auto"
class="flex-1 bg-white border border-zinc-200 rounded-xl px-4 py-2 text-sm focus:outline-none focus:border-indigo-500/30 w-full sm:w-auto"
>
<button
:disabled="!newEmail || bindingEmail"
class="px-4 py-2 bg-zinc-800 hover:bg-zinc-700 text-zinc-200 text-xs font-bold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap"
class="px-4 py-2 bg-zinc-100 hover:bg-zinc-200 text-zinc-800 text-xs font-bold rounded-xl transition-all disabled:opacity-50 whitespace-nowrap"
@click="bindEmail"
>
{{ bindingEmail ? '请稍候...' : '立即绑定' }}
@@ -222,10 +222,10 @@
<!-- 待验证状态 -->
<div v-else-if="!emailVerified" class="space-y-4 pt-2">
<div
class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-xl flex items-start gap-3"
class="p-3 bg-indigo-500/5 border border-indigo-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle :size="14" class="text-blue-500 shrink-0 mt-0.5" />
<p class="text-[11px] text-zinc-500 leading-relaxed">
<AlertCircle :size="14" class="text-indigo-600 shrink-0 mt-0.5" />
<p class="text-[11px] text-zinc-600 leading-relaxed">
验证码已发送至您的邮箱,请在 5 分钟内完成验证。若未收到邮件,请检查垃圾箱。
</p>
</div>
@@ -237,10 +237,10 @@
maxlength="6"
placeholder="输入 6 位数字验证码"
:class="[
'w-full bg-zinc-950 border rounded-xl px-4 py-3 text-lg font-black tracking-[0.5em] text-center focus:outline-none transition-all',
'w-full bg-white border rounded-xl px-4 py-3 text-lg font-black tracking-[0.5em] text-center focus:outline-none transition-all',
emailCodeError
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
: 'border-zinc-800 focus:border-blue-500/30'
: 'border-zinc-200 focus:border-indigo-500/30'
]"
@input="handleEmailCodeInput"
@keydown="handleEmailCodeKeydown"
@@ -248,21 +248,21 @@
<div class="grid grid-cols-2 gap-2">
<button
:disabled="bindingEmail || emailCode.length !== 6"
class="px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl transition-all disabled:opacity-50"
class="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-black rounded-xl transition-all disabled:opacity-50"
@click="verifyEmailCode"
>
{{ bindingEmail ? '验证中...' : '确认验证' }}
</button>
<button
:disabled="resendingEmail"
class="px-4 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
class="px-4 py-2.5 bg-zinc-100 hover:bg-zinc-200 text-zinc-700 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
@click="resendVerificationEmail"
>
{{ resendingEmail ? '发送中...' : '重新发送' }}
</button>
</div>
<button
class="w-full py-2 text-zinc-600 hover:text-zinc-400 text-[10px] font-black uppercase tracking-widest transition-all"
class="w-full py-2 text-zinc-500 hover:text-zinc-700 text-[10px] font-black uppercase tracking-widest transition-all"
@click="changeEmail"
>
更换邮箱地址
@@ -273,14 +273,14 @@
<!-- 已验证状态 -->
<div v-else class="flex gap-2 pt-2">
<button
class="flex-1 py-2.5 bg-zinc-900 border border-zinc-800 hover:border-zinc-700 text-zinc-400 text-xs font-bold rounded-xl transition-all"
class="flex-1 py-2.5 bg-zinc-50 border border-zinc-200 hover:border-zinc-300 text-zinc-500 text-xs font-bold rounded-xl transition-all"
@click="changeEmail"
>
更换邮箱
</button>
<button
:disabled="unbindingEmail"
class="flex-1 py-2.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"
class="flex-1 py-2.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-600 text-xs font-black rounded-xl transition-all"
@click="unbindEmail"
>
{{ unbindingEmail ? '正在解绑...' : '解绑邮箱' }}
@@ -290,34 +290,34 @@
</div>
<!-- MeoW 账号绑定 -->
<div :class="[cardClass, 'border-blue-500/20 bg-blue-500/[0.02]']">
<div :class="[cardClass, 'border-indigo-500/20 bg-indigo-500/[0.02]']">
<div class="flex items-center justify-between mb-4">
<div class="flex items-center gap-3">
<div class="p-2 bg-blue-500/10 rounded-lg border border-blue-500/20">
<Smartphone :size="16" class="text-blue-500" />
<div class="p-2 bg-indigo-500/10 rounded-lg border border-indigo-500/20">
<Smartphone :size="16" class="text-indigo-600" />
</div>
<h3 class="text-sm font-bold text-zinc-200">MeoW App 推送</h3>
<h3 class="text-sm font-bold text-zinc-800">MeoW App 推送</h3>
</div>
</div>
<div class="space-y-4">
<div
v-if="localSettings.meowUserId"
class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-xl"
class="p-3 bg-indigo-500/10 border border-indigo-500/20 rounded-xl"
>
<div class="flex items-center justify-between">
<div>
<p
class="text-[10px] text-blue-500/60 font-black uppercase tracking-widest mb-1"
class="text-[10px] text-indigo-600/60 font-black uppercase tracking-widest mb-1"
>
当前绑定 ID
</p>
<p class="text-sm font-black text-blue-500 tracking-tight">
<p class="text-sm font-black text-indigo-600 tracking-tight">
{{ localSettings.meowUserId }}
</p>
</div>
<div
class="px-2 py-0.5 bg-blue-500 text-white rounded-full text-[10px] font-black uppercase tracking-wider"
class="px-2 py-0.5 bg-indigo-600 text-white rounded-full text-[10px] font-black uppercase tracking-wider"
>
已连接
</div>
@@ -337,11 +337,11 @@
:disabled="binding"
type="text"
placeholder="请输入 MeoW 用户 ID"
class="flex-1 bg-zinc-950 border border-zinc-800 rounded-xl px-4 py-2 text-sm focus:outline-none focus:border-blue-500/30 w-full sm:w-auto"
class="flex-1 bg-white border border-zinc-200 rounded-xl px-4 py-2 text-sm focus:outline-none focus:border-indigo-500/30 w-full sm:w-auto"
>
<button
:disabled="!meowUserId || binding"
class="px-4 py-2 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl transition-all disabled:opacity-50 shadow-lg shadow-blue-900/20 whitespace-nowrap"
class="px-4 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-black rounded-xl transition-all disabled:opacity-50 shadow-lg shadow-indigo-500/20 whitespace-nowrap"
@click="sendVerificationCode"
>
{{ binding ? '发送中...' : '发送验证码' }}
@@ -351,12 +351,12 @@
<!-- 第二步:输入验证码 -->
<div v-else class="space-y-4">
<div
class="p-3 bg-blue-500/5 border border-blue-500/10 rounded-xl flex items-start gap-3"
class="p-3 bg-indigo-500/5 border border-indigo-500/10 rounded-xl flex items-start gap-3"
>
<AlertCircle :size="14" class="text-blue-500 shrink-0 mt-0.5" />
<p class="text-[11px] text-zinc-500 leading-relaxed">
<AlertCircle :size="14" class="text-indigo-600 shrink-0 mt-0.5" />
<p class="text-[11px] text-zinc-600 leading-relaxed">
验证码已发送至 MeoW ID:
<span class="font-bold text-zinc-200">{{ meowUserId }}</span
<span class="font-bold text-zinc-800">{{ meowUserId }}</span
>,请在客户端查收。
</p>
</div>
@@ -368,10 +368,10 @@
maxlength="6"
placeholder="输入 6 位验证码"
:class="[
'w-full bg-zinc-950 border rounded-xl px-4 py-3 text-lg font-black tracking-[0.5em] text-center focus:outline-none transition-all',
'w-full bg-white border rounded-xl px-4 py-3 text-lg font-black tracking-[0.5em] text-center focus:outline-none transition-all',
verificationCodeError
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
: 'border-zinc-800 focus:border-blue-500/30'
: 'border-zinc-200 focus:border-indigo-500/30'
]"
@input="handleVerificationCodeInput"
@keydown="handleVerificationCodeKeydown"
@@ -379,14 +379,14 @@
<div class="grid grid-cols-2 gap-2">
<button
:disabled="binding || verificationCode.length !== 6"
class="px-4 py-2.5 bg-blue-600 hover:bg-blue-500 text-white text-xs font-black rounded-xl transition-all disabled:opacity-50"
class="px-4 py-2.5 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-black rounded-xl transition-all disabled:opacity-50"
@click="verifyAndBind"
>
{{ binding ? '验证中...' : '确认绑定' }}
</button>
<button
:disabled="binding"
class="px-4 py-2.5 bg-zinc-800 hover:bg-zinc-700 text-zinc-300 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
class="px-4 py-2.5 bg-zinc-100 hover:bg-zinc-200 text-zinc-700 text-xs font-bold rounded-xl transition-all disabled:opacity-50"
@click="cancelVerification"
>
取消
@@ -399,7 +399,7 @@
<!-- 已绑定状态 -->
<div v-else class="pt-2">
<button
class="w-full py-2.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"
class="w-full py-2.5 bg-rose-500/10 border border-rose-500/20 hover:bg-rose-500/20 text-rose-600 text-xs font-black rounded-xl transition-all"
@click="showUnbindConfirm"
>
解除 MeoW 账号绑定
@@ -452,11 +452,11 @@ const { siteTitle, smtpEnabled, initSiteConfig } = useSiteConfig()
const { showToast } = useToast()
// 样式类常量
const sectionClass = 'bg-zinc-900/40 border border-zinc-900 rounded-3xl p-6 md:p-8 shadow-2xl'
const sectionClass = 'bg-white border border-zinc-200 rounded-3xl p-6 md:p-8 shadow-lg'
const cardClass =
'bg-zinc-950/40 border border-zinc-800/50 rounded-2xl p-5 transition-all hover:border-zinc-700/50'
'bg-zinc-50 border border-zinc-200 rounded-2xl p-5 transition-all hover:border-zinc-300'
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'
'flex items-center justify-between p-4 bg-white border border-zinc-200 rounded-2xl hover:bg-zinc-50 transition-all group'
// 页面状态
const loading = ref(true)
+1 -1
View File
@@ -25,7 +25,7 @@
fetchpriority="high"
>
</div>
<h1 class="form-title">{{ siteTitle ? siteTitle + ' | VoiceHub' : 'VoiceHub' }}</h1>
<h1 class="form-title">宝一外校园点歌系统</h1>
<div class="header-divider" />
</div>
<ClientOnly>
+6 -6
View File
@@ -1,7 +1,7 @@
<template>
<div class="review-page">
<Head>
<Title>我的年度回顾 - VoiceHub</Title>
<Title>我的年度回顾 - 宝一外校园点歌系统</Title>
<Meta name="theme-color" content="#000000" />
<Meta
name="viewport"
@@ -211,8 +211,8 @@ const handleTouchEnd = (e) => {
.review-page {
position: fixed;
inset: 0;
background-color: #000;
color: #fff;
background-color: var(--bg-primary, #111111);
color: var(--text-primary, #ffffff);
z-index: 9999;
font-family:
'MiSans',
@@ -230,7 +230,7 @@ const handleTouchEnd = (e) => {
flex-direction: column;
align-items: center;
justify-content: center;
background-color: #000;
background-color: var(--bg-primary, #111111);
z-index: 50;
}
@@ -285,7 +285,7 @@ const handleTouchEnd = (e) => {
.empty .empty-icon-circle {
width: 5rem;
height: 5rem;
background: #111827;
background: var(--bg-secondary, #111827);
border-radius: 50%;
display: flex;
align-items: center;
@@ -364,7 +364,7 @@ const handleTouchEnd = (e) => {
.indicator-dot.active {
height: 1.5rem;
background: #fff;
background: var(--card-bg, #fff);
}
/* 滚动提示 */
+2 -1
View File
@@ -2,7 +2,8 @@ export const getProviderDisplayName = (provider: string): string => {
const map: Record<string, string> = {
github: 'GitHub',
casdoor: 'Casdoor',
google: 'Google'
google: 'Google',
aggregate: '微信'
}
return map[provider.toLowerCase()] || provider.charAt(0).toUpperCase() + provider.slice(1)
}
+28 -33
View File
@@ -133,7 +133,7 @@ export default defineNuxtConfig({
github: !!process.env.GITHUB_CLIENT_ID,
casdoor: !!process.env.CASDOOR_CLIENT_ID,
google: !!process.env.GOOGLE_CLIENT_ID,
aggregate: !!process.env.AGGREGATE_OAUTH_APP_ID
aggregate: false
},
siteTitle: process.env.NUXT_PUBLIC_SITE_TITLE || '校园广播站点歌系统',
siteLogo: process.env.NUXT_PUBLIC_SITE_LOGO || '',
@@ -156,8 +156,8 @@ export default defineNuxtConfig({
themeColor: process.env.PWA_THEME_COLOR || '#111111'
},
// 主题配置
defaultTheme: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'ClassicLight',
enabledThemes: (process.env.NUXT_PUBLIC_ENABLED_THEMES || 'ClassicLight,ModernLight')
defaultTheme: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'ClassicDark',
enabledThemes: (process.env.NUXT_PUBLIC_ENABLED_THEMES || 'System,ClassicDark,ClassicLight,ModernLight')
.split(',')
.map((s) => s.trim())
.filter(Boolean),
@@ -192,7 +192,7 @@ export default defineNuxtConfig({
{ name: 'theme-color', content: '#111111' },
{
name: 'x-default-theme',
content: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'System'
content: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'ClassicDark'
},
{ name: 'apple-mobile-web-app-capable', content: 'yes' },
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
@@ -213,7 +213,7 @@ export default defineNuxtConfig({
` var meta=document.querySelector('meta[name="x-default-theme"]');`,
` if(meta && meta.content){s=meta.content}`,
` }catch(e){s=null}`,
` if(!s){s='System'}`,
` if(!s){s='ClassicDark'}`,
` }`,
` if(s==='System'||s==='system'){eff=window.matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'}`,
` else if(typeof s==='string'){eff=s.endsWith('Dark')||s==='dark'?'dark':(s.endsWith('Light')||s==='light'?'light':s)}`,
@@ -279,17 +279,31 @@ export default defineNuxtConfig({
// 服务器端配置
nitro: {
compressPublicAssets: true,
compressPublicAssets: false, // 禁用公共资产压缩,防止构建时内存溢出
preset: process.env.VERCEL
? 'vercel'
: process.env.NETLIFY
? 'netlify'
: process.env.NITRO_PRESET || 'node-server',
// 明确关闭 prerender / 爬取链接,避免 Node 端不必要的预渲染浪费内存和依赖数据库环境
prerender: {
crawlLinks: false,
routes: []
},
minify: false, // 【绝对不要设为 true】服务端代码压缩会产生极高内存峰值,导致 Linux 环境 OOM 137
sourceMap: false,
// 增强错误处理和稳定性
experimental: {
wasm: true,
asyncContext: true
},
// 强制把巨型 Node native 库外置,防止 Nitro 打包时撑爆内存 (OOM 137)
externals: {
external: [
'drizzle-orm',
'postgres'
]
},
timing: true,
// 增加请求超时时间
routeRules: {
@@ -388,15 +402,12 @@ export default defineNuxtConfig({
build: {
chunkSizeWarningLimit: 2000,
cssCodeSplit: true,
sourcemap: false,
target: 'esnext',
reportCompressedSize: false,
rollupOptions: {
maxParallelFileOps: 2,
output: {
manualChunks(id) {
if (id.includes('node_modules/lucide-vue-next')) {
return 'lucide'
}
}
}
maxParallelFileOps: 1,
cache: false
}
},
resolve: {
@@ -418,30 +429,14 @@ export default defineNuxtConfig({
include: ['drizzle-orm'],
exclude: ['@applemusic-like-lyrics/vue', '@applemusic-like-lyrics/lyric']
},
build: {
sourcemap: false,
rollupOptions: {
output: {
manualChunks(id) {
if (!id.includes('node_modules')) return
if (id.includes('lucide-vue-next')) return 'icons'
if (id.includes('@pixi')) return 'pixi'
if (id.includes('@applemusic-like-lyrics')) return 'lyric-engine'
if (id.includes('drizzle-orm') || id.includes('postgres')) return 'database'
if (id.includes('xlsx') || id.includes('jspdf') || id.includes('jszip')) return 'office'
}
}
}
},
// 添加 WASM 支持配置
assetsInclude: ['**/*.wasm'],
// SSR配置
// SSR配置: Vercel/Netlify serverless 需要 noExternal 把 drizzle/postgres inline;
// Node-server 环境直接从 node_modules require 即可, 避免打包 inline 导致 OOM
ssr: {
noExternal: process.env.NETLIFY
? ['drizzle-orm', 'postgres']
: process.env.VERCEL
? []
: ['drizzle-orm', 'postgres']
: []
}
}
})
BIN
View File
Binary file not shown.
@@ -1,24 +1,24 @@
<template>
<template>
<div class="flex flex-col space-y-8 pb-12 lg:pb-20 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div class="flex flex-col space-y-2">
<h2 class="text-2xl font-bold tracking-tight text-zinc-100">图片歌单</h2>
<p class="text-sm text-zinc-500">选择日期生成广播排期卡片并分享</p>
<h2 class="text-2xl font-bold tracking-tight text-zinc-100">鍥剧墖姝屽崟</h2>
<p class="text-sm text-zinc-500">閫夋嫨鏃ユ湡鐢熸垚骞挎挱鎺掓湡鍗$墖骞跺垎浜?/p>
</div>
<div class="flex flex-col gap-8">
<!-- 设置面板 -->
<!-- 璁剧疆闈㈡澘 -->
<div class="w-full flex flex-col gap-6">
<div class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 space-y-6 shadow-lg">
<h3 class="text-lg font-bold flex items-center gap-2 text-zinc-100 shrink-0">
<Layout class="w-4 h-4 text-blue-500" /> 卡片设置
<Layout class="w-4 h-4 text-blue-500" /> 鍗$墖璁剧疆
</h3>
<div class="flex flex-col gap-5 items-end">
<!-- 日期选择 -->
<!-- 鏃ユ湡閫夋嫨 -->
<div class="space-y-2 flex-1 w-full">
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">排期日期</label>
<label class="text-[11px] font-black uppercase text-zinc-600 tracking-wider">鎺掓湡鏃ユ湡</label>
<!-- 类似排期管理的日期选择器 -->
<!-- 绫讳技鎺掓湡绠$悊鐨勬棩鏈熼€夋嫨鍣?-->
<AdminDatePicker
:modelValue="selectedDate"
@update:modelValue="selectedDate = $event"
@@ -32,16 +32,16 @@
@click="showModal = true"
>
<ImageIcon class="w-4 h-4" />
{{ loading ? '加载中...' : (schedules.length === 0 ? '该日期无排期' : '生成卡片') }}
{{ loading ? '鍔犺浇涓?..' : (schedules.length === 0 ? '璇ユ棩鏈熸棤鎺掓湡' : '鐢熸垚鍗$墖') }}
</button>
</div>
</div>
</div>
<!-- 预览区 -->
<!-- 棰勮鍖?-->
<div class="w-full flex flex-col gap-4">
<div v-if="schedules.length > 0" class="bg-zinc-900/40 border border-zinc-800 rounded-xl p-6 shadow-lg">
<h3 class="text-lg font-bold text-zinc-100 mb-4">排期预览 ({{ schedules.length }} 首)</h3>
<h3 class="text-lg font-bold text-zinc-100 mb-4">鎺掓湡棰勮 ({{ schedules.length }} 棣?</h3>
<div class="space-y-3">
<div v-for="(schedule, index) in schedules" :key="schedule.id" class="flex items-center gap-3 p-3 bg-zinc-950/50 border border-zinc-800/50 rounded-lg">
<span class="text-blue-500 font-bold w-6">{{ index + 1 }}.</span>
@@ -54,7 +54,7 @@
</div>
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-20 bg-zinc-900/20 border border-zinc-800/50 rounded-xl border-dashed">
<CalendarDays class="w-12 h-12 text-zinc-700 mb-4" />
<p class="text-zinc-500">该日期没有排期的歌曲</p>
<p class="text-zinc-500">璇ユ棩鏈熸病鏈夋帓鏈熺殑姝屾洸</p>
</div>
</div>
</div>
@@ -62,7 +62,7 @@
<AdminPlaylistCardGenerator
:show="showModal"
:songs="schedules"
:default-title="`${selectedDate} 校园广播歌单`"
:default-title="`${selectedDate} 鏍″洯骞挎挱姝屽崟`"
@close="showModal = false"
/>
</div>
@@ -100,7 +100,7 @@ const fetchSchedules = async () => {
} catch (error) {
console.error('Failed to fetch schedules:', error)
if (window.$showNotification) {
window.$showNotification('获取排期失败', 'error')
window.$showNotification('鑾峰彇鎺掓湡澶辫触', 'error')
}
} finally {
loading.value = false
+85 -1
View File
@@ -84,7 +84,8 @@
"@nuxt/eslint": "^1.17.0",
"eslint": "^9.39.5",
"prettier": "^3.9.6",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vue-tsc": "^3.3.11"
},
"engines": {
"node": ">=22.11.0"
@@ -7925,6 +7926,35 @@
"vue": "^3.0.0"
}
},
"node_modules/@volar/language-core": {
"version": "2.4.28",
"resolved": "https://registry.npmmirror.com/@volar/language-core/-/language-core-2.4.28.tgz",
"integrity": "sha512-w4qhIJ8ZSitgLAkVay6AbcnC7gP3glYM3fYwKV3srj8m494E3xtrCv6E+bWviiK/8hs6e6t1ij1s2Endql7vzQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/source-map": "2.4.28"
}
},
"node_modules/@volar/source-map": {
"version": "2.4.28",
"resolved": "https://registry.npmmirror.com/@volar/source-map/-/source-map-2.4.28.tgz",
"integrity": "sha512-yX2BDBqJkRXfKw8my8VarTyjv48QwxdJtvRgUpNE5erCsgEUdI2DsLbpa+rOQVAJYshY99szEcRDmyHbF10ggQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@volar/typescript": {
"version": "2.4.28",
"resolved": "https://registry.npmmirror.com/@volar/typescript/-/typescript-2.4.28.tgz",
"integrity": "sha512-Ja6yvWrbis2QtN4ClAKreeUZPVYMARDYZl9LMEv1iQ1QdepB6wn0jTRxA9MftYmYa4DQ4k/DaSZpFPUfxl8giw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.28",
"path-browserify": "^1.0.1",
"vscode-uri": "^3.0.8"
}
},
"node_modules/@vue-macros/common": {
"version": "3.1.4",
"resolved": "https://registry.npmmirror.com/@vue-macros/common/-/common-3.1.4.tgz",
@@ -8104,6 +8134,22 @@
"integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==",
"license": "MIT"
},
"node_modules/@vue/language-core": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/@vue/language-core/-/language-core-3.3.11.tgz",
"integrity": "sha512-QJmpliwAVpC/OxubIByPAhNzsQPRc8/gxlN2qnVzVfIMjMDz/9RnXRFoetjz5yEgXVXyp4LqhXq3V53PjmNzFw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/language-core": "2.4.28",
"@vue/compiler-dom": "^3.5.0",
"@vue/shared": "^3.5.0",
"alien-signals": "^3.2.1",
"muggle-string": "^0.4.1",
"path-browserify": "^1.0.1",
"picomatch": "^4.0.4"
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.42",
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz",
@@ -8252,6 +8298,13 @@
"url": "https://github.com/sponsors/epoberezkin"
}
},
"node_modules/alien-signals": {
"version": "3.2.1",
"resolved": "https://registry.npmmirror.com/alien-signals/-/alien-signals-3.2.1.tgz",
"integrity": "sha512-I8FjmltrfnDFoZedi5CG8DghVYNhzb/Ijluz7tCSJH0xpd0484Kowhbb1XDYOxfJpU1p5wnM2X54dA+IfGyD1g==",
"dev": true,
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
@@ -16839,6 +16892,13 @@
"node": ">= 0.8"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmmirror.com/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"dev": true,
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -21683,6 +21743,13 @@
"vite": "^2 || ^3 || ^4 || ^5 || ^6 || ^7 || ^8"
}
},
"node_modules/vscode-uri": {
"version": "3.2.0",
"resolved": "https://registry.npmmirror.com/vscode-uri/-/vscode-uri-3.2.0.tgz",
"integrity": "sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg==",
"dev": true,
"license": "MIT"
},
"node_modules/vue": {
"version": "3.5.42",
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz",
@@ -21764,6 +21831,23 @@
"vue": "^3.5.0"
}
},
"node_modules/vue-tsc": {
"version": "3.3.11",
"resolved": "https://registry.npmmirror.com/vue-tsc/-/vue-tsc-3.3.11.tgz",
"integrity": "sha512-gOb0B9rtU2+f1dszwPqSH5kAieIF9ReeLhD3kSRNHv5WZZUQz/JdVXW0RTdqhNTMlQkqKzrTTviqKr/4FYZraQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@volar/typescript": "2.4.28",
"@vue/language-core": "3.3.11"
},
"bin": {
"vue-tsc": "bin/vue-tsc.js"
},
"peerDependencies": {
"typescript": ">=5.0.0"
}
},
"node_modules/wasm-media-encoders": {
"version": "0.7.0",
"resolved": "https://registry.npmjs.org/wasm-media-encoders/-/wasm-media-encoders-0.7.0.tgz",
+2 -1
View File
@@ -112,6 +112,7 @@
"@nuxt/eslint": "^1.17.0",
"eslint": "^9.39.5",
"prettier": "^3.9.6",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vue-tsc": "^3.3.11"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 162 KiB

After

Width:  |  Height:  |  Size: 861 KiB

+17 -17
View File
@@ -1,19 +1,19 @@
<svg width="154" height="85" viewBox="0 0 154 85" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M147.91 80.312V82.286C147.91 82.8927 147.775 83.3127 147.504 83.546C147.233 83.7794 146.757 83.896 146.076 83.896H144.802L144.564 82.706H145.656C145.927 82.706 146.123 82.6827 146.244 82.636C146.375 82.5894 146.459 82.5147 146.496 82.412C146.543 82.3094 146.566 82.1554 146.566 81.95V80.368L142.058 80.522L141.876 79.43C143.201 79.0753 144.536 78.5947 145.88 77.988L142.814 78.142L142.646 77.302C143.775 76.826 144.83 76.2427 145.81 75.552H142.562V77.022H141.218V74.418H146.314V73.326H141.582V72.178H146.314V71.114H147.658V72.178H152.432V73.326H147.658V74.418H152.782V77.05H151.424V75.552H147.518C146.575 76.2614 145.745 76.7934 145.026 77.148L147.756 77.05C148.512 76.6207 149.119 76.238 149.576 75.902L150.416 76.798C149.091 77.6754 147.355 78.5387 145.208 79.388L149.898 79.15C149.907 79.1593 149.819 79.0847 149.632 78.926C149.259 78.618 149.063 78.4594 149.044 78.45L149.884 77.68L150.864 78.492C151.863 79.3227 152.469 79.8267 152.684 80.004L151.97 80.942L151.732 80.718C151.657 80.6527 151.569 80.578 151.466 80.494C151.373 80.41 151.261 80.312 151.13 80.2L147.91 80.312ZM145.068 81.656C144.499 82.0387 143.873 82.4213 143.192 82.804C142.511 83.1773 141.862 83.504 141.246 83.784L140.742 82.622C141.358 82.3607 142.016 82.0434 142.716 81.67C143.425 81.2874 144.009 80.9327 144.466 80.606L145.068 81.656ZM149.282 80.648C149.963 80.9747 151.275 81.6374 153.216 82.636L152.642 83.742C152.054 83.4247 151.363 83.0654 150.57 82.664C149.786 82.2534 149.156 81.936 148.68 81.712L149.282 80.648Z" fill="white" fill-opacity="0.6"/>
<path d="M138.852 83.98C137.937 83.7933 137.135 83.5927 136.444 83.378C135.763 83.1727 135.142 82.93 134.582 82.65C133.574 83.1073 132.155 83.56 130.326 84.008L129.934 82.776C131.371 82.5053 132.51 82.2207 133.35 81.922C132.631 81.418 131.866 80.704 131.054 79.78V78.716H134.106V77.666H131.012V72.486C131.703 72.3273 132.51 72.0753 133.434 71.73L133.896 72.78C133.289 73.032 132.711 73.2327 132.16 73.382V74.446H133.644V75.454H132.16V76.602H134.106V71.184H135.324V76.602H137.312V75.454H135.758V74.446H137.312V73.284H135.73V72.262H138.488V77.666H135.324V78.716H138.194V79.766C137.485 80.6713 136.724 81.39 135.912 81.922C136.733 82.23 137.839 82.4913 139.23 82.706L138.852 83.98ZM130.592 77.792L129.444 78.324V82.426C129.444 82.7993 129.397 83.084 129.304 83.28C129.22 83.476 129.066 83.6113 128.842 83.686C128.627 83.77 128.31 83.812 127.89 83.812H127.022L126.798 82.552H127.54C127.829 82.552 128.016 82.5147 128.1 82.44C128.184 82.356 128.226 82.1927 128.226 81.95V78.87L126.952 79.43L126.798 78.03C127.069 77.9273 127.545 77.7267 128.226 77.428V74.964H126.896V73.662H128.226V71.254H129.444V73.662H130.536V74.964H129.444V76.868C129.584 76.812 129.915 76.658 130.438 76.406L130.592 77.792ZM132.706 79.78C133.341 80.4427 133.985 80.9607 134.638 81.334C135.291 80.9793 135.949 80.4613 136.612 79.78H132.706Z" fill="white" fill-opacity="0.6"/>
<path d="M124.544 72.528V76.812H115.542C115.514 78.1933 115.369 79.3833 115.108 80.382C114.856 81.3807 114.417 82.5007 113.792 83.742L112.644 82.93C113.064 82.118 113.381 81.39 113.596 80.746C113.82 80.0927 113.979 79.402 114.072 78.674C114.165 77.946 114.212 77.0547 114.212 76V72.528H118.804L118.454 71.534L118.328 71.184L119.756 70.946L119.868 71.268C119.98 71.5853 120.134 72.0053 120.33 72.528H124.544ZM115.542 75.58H123.186V73.732H115.542V75.58ZM117.264 84.036H115.934V78.352H124.264V84.036H122.906V83.224H117.264V84.036ZM122.906 82.048V79.57H117.264V82.048H122.906Z" fill="white" fill-opacity="0.6"/>
<path d="M111.244 76.826V78.17H108.374V83.882H106.96V78.17H103.278C103.231 79.1033 103.11 79.8873 102.914 80.522C102.718 81.1473 102.41 81.7307 101.99 82.272C101.579 82.8133 100.982 83.4247 100.198 84.106L99.134 83.07C99.8434 82.5007 100.385 81.9873 100.758 81.53C101.131 81.0727 101.397 80.592 101.556 80.088C101.724 79.5747 101.831 78.9353 101.878 78.17H98.756V76.826H101.906V73.242H99.344V71.94H110.684V73.242H108.374V76.826H111.244ZM103.32 76.826H106.96V73.242H103.32V76.826Z" fill="white" fill-opacity="0.6"/>
<path d="M90.8601 75.706C90.5801 75.1553 90.2254 74.4927 89.7961 73.718C89.3667 72.934 89.0074 72.318 88.7181 71.87L89.7961 71.17C90.2907 71.9353 91.0327 73.214 92.0221 75.006L90.8601 75.706ZM96.2221 83.672C95.7741 83.056 94.9947 82.076 93.8841 80.732C93.4827 81.32 92.9647 81.88 92.3301 82.412C91.6954 82.9347 90.8927 83.4854 89.9221 84.064L89.1241 82.846C90.3934 82.1367 91.3594 81.4274 92.0221 80.718C92.6941 80.0087 93.1701 79.2154 93.4501 78.338C93.7301 77.4607 93.9261 76.322 94.0381 74.922C94.0847 74.3807 94.1127 73.2094 94.1221 71.408H95.4801V72.346C95.4801 73.6713 95.4614 74.5627 95.4241 75.02C95.3587 75.972 95.2607 76.798 95.1301 77.498C95.0087 78.198 94.8314 78.8327 94.5981 79.402C95.3541 80.2793 96.2361 81.3527 97.2441 82.622L96.2221 83.672ZM90.4401 77.75L91.0421 79.15L86.2121 83.336L85.3021 82.09C85.4701 81.922 85.5774 81.782 85.6241 81.67C85.6801 81.558 85.7081 81.4087 85.7081 81.222V71.702H87.1221V80.592L90.4401 77.75Z" fill="white" fill-opacity="0.6"/>
<path d="M74.8301 79.612C74.4381 79.248 74.0834 78.9214 73.7661 78.632V83.938H72.5201V78.94C72.1001 79.9574 71.6707 80.788 71.2321 81.432L70.6721 80.172C71.0081 79.6307 71.3394 79.0194 71.6661 78.338C71.9927 77.6474 72.2774 76.966 72.5201 76.294H70.8821V75.006H72.5201V73.354L71.1061 73.55L70.9941 72.346C71.6007 72.2807 72.3054 72.1827 73.1081 72.052C73.9107 71.912 74.6201 71.772 75.2361 71.632L75.4181 72.836C75.0261 72.9293 74.4754 73.0414 73.7661 73.172V75.006H75.2501V75.412C75.6234 74.8427 75.9687 74.194 76.2861 73.466C76.6034 72.7287 76.8601 71.982 77.0561 71.226L78.3301 71.436C78.2461 71.772 78.0921 72.2667 77.8681 72.92H82.9361V74.18L81.8301 76.434L80.6681 76L81.5781 74.18H77.3501C77.0327 74.8987 76.6361 75.6593 76.1601 76.462L75.2501 75.622V76.294H73.7661V77.092C74.6714 77.8574 75.2547 78.366 75.5161 78.618L74.8301 79.612ZM78.4421 75.202H79.7301V82.328C79.7301 82.8694 79.6041 83.252 79.3521 83.476C79.1094 83.7 78.7034 83.812 78.1341 83.812H76.8321L76.5941 82.524H77.6021C77.9381 82.524 78.1621 82.4774 78.2741 82.384C78.3861 82.2907 78.4421 82.104 78.4421 81.824V75.202ZM81.4101 77.036C81.7274 77.8107 82.0727 78.6927 82.4461 79.682C82.8287 80.6713 83.1181 81.432 83.3141 81.964L82.2221 82.454C81.6621 80.858 81.0367 79.22 80.3461 77.54L81.4101 77.036ZM77.6161 77.456C77.3827 78.2867 77.0887 79.1594 76.7341 80.074C76.3794 80.9794 76.0201 81.7727 75.6561 82.454L74.5081 81.936C74.8441 81.348 75.1941 80.606 75.5581 79.71C75.9314 78.8047 76.2441 77.9367 76.4961 77.106L77.6161 77.456Z" fill="white" fill-opacity="0.6"/>
<path d="M68.1241 78.072V84.022H66.8081V83.168H60.5921V84.022H59.2761V79.388C58.7347 79.5747 58.0394 79.7893 57.1901 80.032L56.7001 78.828C58.9307 78.2493 60.8161 77.5447 62.3561 76.714C63.9054 75.874 65.2307 74.894 66.3321 73.774H61.2641C61.0774 73.97 60.8814 74.166 60.6761 74.362L62.4821 75.748L61.6141 76.728L61.2781 76.462C60.4381 75.79 59.9154 75.3747 59.7101 75.216C59.1501 75.6733 58.5667 76.1027 57.9601 76.504L57.1341 75.384C58.0207 74.8613 58.9027 74.1893 59.7801 73.368C60.6667 72.5373 61.3807 71.744 61.9221 70.988L63.2241 71.352C63.0094 71.6787 62.7247 72.0707 62.3701 72.528H68.0961V73.774C67.3214 74.6607 66.4767 75.4587 65.5621 76.168C64.6474 76.868 63.6114 77.5027 62.4541 78.072H68.1241ZM60.5921 79.29V81.978H66.8081V79.29H60.5921Z" fill="white" fill-opacity="0.6"/>
<path d="M51.268 71.268V73.186H54.474V84.008H53.172V83.182H44.842V84.008H43.54V73.186H46.732V71.268H48.048V73.186H49.952V71.268H51.268ZM46.732 77.512V74.474H44.842V77.512H46.732ZM49.952 77.512V74.474H48.048V77.512H49.952ZM53.172 77.512V74.474H51.268V77.512H53.172ZM46.732 78.772H44.842V81.922H46.732V78.772ZM49.952 81.922V78.772H48.048V81.922H49.952ZM51.268 78.772V81.922H53.172V78.772H51.268Z" fill="white" fill-opacity="0.6"/>
<path d="M35.3221 75.986C36.1621 74.4927 36.7921 72.836 37.2121 71.016L38.4301 71.24C38.3181 71.7813 38.1547 72.374 37.9401 73.018H41.1461V74.222L40.2221 76.658L39.1301 76.266L39.9701 74.208H37.5061C37.1047 75.2627 36.6894 76.1307 36.2601 76.812L35.3221 75.986ZM35.0001 76.658H33.7961V72.864H28.9521V71.716H35.9101V72.864H35.0001V76.658ZM32.8861 73.69V76.294H29.4561V73.69H32.8861ZM31.8921 74.572H30.4641V75.426H31.8921V74.572ZM40.5441 83.938C39.4614 82.8087 38.6774 81.4927 38.1921 79.99C37.9587 80.7553 37.6274 81.4647 37.1981 82.118C36.7781 82.7713 36.2461 83.406 35.6021 84.022L34.9021 83.084C34.8087 83.3547 34.6314 83.546 34.3701 83.658C34.1181 83.77 33.7401 83.826 33.2361 83.826H31.9761L31.7521 82.664H32.9561C33.2081 82.664 33.3901 82.6407 33.5021 82.594C33.6234 82.538 33.7027 82.4493 33.7401 82.328C33.7867 82.2067 33.8101 82.02 33.8101 81.768V78.226H28.7841V77.12H36.1061V78.226H35.0141V82.202C35.0141 82.4353 35.0001 82.636 34.9721 82.804C35.8867 81.9453 36.5354 81.026 36.9181 80.046C37.3101 79.066 37.5154 77.89 37.5341 76.518L37.5481 75.006H38.6961L38.6821 76.406L38.6681 77.05C38.7427 78.2913 38.9947 79.374 39.4241 80.298C39.8627 81.222 40.4974 82.0667 41.3281 82.832L40.5441 83.938ZM29.3861 79.122H32.8721V82.02H29.3861V79.122ZM30.3941 81.194H31.8641V79.962H30.3941V81.194Z" fill="white" fill-opacity="0.6"/>
<path d="M21.6441 73.102C21.8587 75.1273 22.4047 76.9147 23.2821 78.464C24.1687 80.0133 25.4987 81.3947 27.2721 82.608L26.2641 83.868C24.8827 82.8133 23.7674 81.7073 22.9181 80.55C22.0781 79.3833 21.4434 78.03 21.0141 76.49C20.0994 79.15 18.3121 81.5953 15.6521 83.826L14.6581 82.636C18.0181 80.0227 19.9221 76.9473 20.3701 73.41C20.2954 73.27 20.1694 73.172 19.9921 73.116C19.8241 73.0507 19.5767 73.018 19.2501 73.018H17.3741V71.59H19.7681C20.3654 71.59 20.8134 71.7113 21.1121 71.954C21.4107 72.1873 21.5881 72.57 21.6441 73.102Z" fill="white" fill-opacity="0.6"/>
<path d="M5.02604 79.71L5.09604 80.956L4.04604 81.138V83.994H2.89804V81.334L0.826035 81.698L0.686035 80.424L2.89804 80.06V78.352H0.994035V77.106C1.27404 76.3873 1.5867 75.356 1.93204 74.012H0.854035V72.822H2.22604C2.3847 72.1593 2.50604 71.576 2.59004 71.072L3.71004 71.324C3.63537 71.6973 3.55137 72.052 3.45804 72.388L3.36004 72.822H5.33404V74.012H3.05204C2.66937 75.4213 2.35204 76.476 2.10004 77.176H3.09404V75.23H4.04604V77.176H5.04004V78.352H4.04604V79.878L5.02604 79.71ZM9.60404 71.156C10.0334 71.856 10.5654 72.5047 11.2 73.102C11.8347 73.6993 12.53 74.2033 13.286 74.614L12.712 75.762C12.18 75.4447 11.62 75.0387 11.032 74.544V75.384H6.94404V74.698C6.46804 75.0993 5.94537 75.4773 5.37604 75.832L4.70404 74.726C5.57204 74.2127 6.3047 73.6713 6.90204 73.102C7.49937 72.5327 8.0267 71.884 8.48404 71.156H9.60404ZM10.794 74.334C10.0567 73.6713 9.47804 73.0133 9.05804 72.36C8.61004 73.0507 8.04537 73.7087 7.36404 74.334H10.794ZM11.522 76H12.558V82.44C12.558 82.8133 12.5114 83.098 12.418 83.294C12.334 83.49 12.18 83.6253 11.956 83.7C11.7414 83.784 11.4287 83.826 11.018 83.826H9.87004L9.66004 82.734H10.682C11.0274 82.734 11.2514 82.692 11.354 82.608C11.466 82.5147 11.522 82.342 11.522 82.09V76ZM5.43204 83.882V76.154H8.96004V82.608C8.96004 83.0653 8.8807 83.3733 8.72204 83.532C8.56337 83.7 8.2647 83.784 7.82604 83.784H7.08404L6.88804 82.762H7.44804C7.6627 82.762 7.79804 82.7293 7.85404 82.664C7.91937 82.5987 7.95204 82.4773 7.95204 82.3V81.25H6.44004V83.882H5.43204ZM9.78604 76.434H10.724V81.586H9.78604V76.434ZM7.95204 78.198V77.148H6.44004V78.198H7.95204ZM6.44004 79.192V80.242H7.95204V79.192H6.44004Z" fill="white" fill-opacity="0.6"/>
<path d="M93.0001 44.9999V46.9999H61.0001V44.9999H93.0001ZM97.0001 40.9999V25.9999C97.0001 23.7907 95.2092 21.9999 93.0001 21.9999H61.0001C58.7909 21.9999 57.0001 23.7907 57.0001 25.9999V40.9999C57.0001 43.209 58.7909 44.9999 61.0001 44.9999V46.9999L60.6915 46.9921C57.6234 46.8366 55.1633 44.3765 55.0079 41.3085L55.0001 40.9999V25.9999C55.0001 22.6862 57.6864 19.9999 61.0001 19.9999H93.0001C96.3138 19.9999 99.0001 22.6862 99.0001 25.9999V40.9999C99.0001 44.2101 96.479 46.8314 93.3087 46.9921L93.0001 46.9999V44.9999C95.2092 44.9999 97.0001 43.209 97.0001 40.9999Z" fill="#F9F9F9" fill-opacity="0.4"/>
<path d="M72.8538 39.2659C72.1165 39.2659 71.5285 39.0699 71.0898 38.6779C70.6511 38.2859 70.4318 37.7585 70.4318 37.0959C70.4318 36.2652 70.7585 35.6352 71.4118 35.2059C72.0651 34.7672 72.9938 34.5479 74.1978 34.5479H75.1778V34.0579C75.1778 33.5819 75.0331 33.2085 74.7438 32.9379C74.4638 32.6579 74.0858 32.5179 73.6098 32.5179C72.9938 32.5179 72.3965 32.7932 71.8178 33.3439L70.8658 32.4479C71.6591 31.5985 72.6158 31.1739 73.7358 31.1739C74.6411 31.1739 75.3598 31.4259 75.8918 31.9299C76.4331 32.4339 76.7038 33.1059 76.7038 33.9459V39.0699H75.1778V38.0479H75.0938C74.8978 38.4212 74.5945 38.7199 74.1838 38.9439C73.7731 39.1585 73.3298 39.2659 72.8538 39.2659ZM73.2178 37.9359C73.5165 37.9359 73.8151 37.8752 74.1138 37.7539C74.4218 37.6325 74.6738 37.4459 74.8698 37.1939C75.0751 36.9325 75.1778 36.6152 75.1778 36.2419V35.8779H74.4358C73.6051 35.8779 72.9891 35.9712 72.5878 36.1579C72.1958 36.3352 71.9998 36.6152 71.9998 36.9979C71.9998 37.2779 72.1071 37.5065 72.3218 37.6839C72.5458 37.8519 72.8445 37.9359 73.2178 37.9359Z" fill="white" fill-opacity="0.4"/>
<path d="M69.6741 39.0699H67.9941L66.8601 35.8359H62.9821L61.8481 39.0699H60.1681L64.1021 28.2479H65.7401L69.6741 39.0699ZM64.8721 30.4459L63.5141 34.3379H66.3281L64.9561 30.4459H64.8721Z" fill="white" fill-opacity="0.4"/>
<path d="M102.903 36.9219C102.575 36.0792 103.445 35.2692 104.262 35.6558L119.231 42.7362C120.051 43.1239 119.959 44.2818 119.161 44.5752L118.992 44.6217L113.12 45.7485C112.744 45.8207 112.441 46.1025 112.342 46.4733L110.803 52.2499C110.554 53.1845 109.256 53.2567 108.905 52.3553L102.903 36.9219ZM109.659 48.7758L110.41 45.9578L110.473 45.7527C110.797 44.8096 111.571 44.0888 112.535 43.8317L112.744 43.7838L115.607 43.2344L105.675 38.5354L109.659 48.7758Z" fill="#6E6E71"/>
<path d="M115.592 0.496423C115.66 0.525313 115.724 0.561654 115.782 0.602988C115.785 0.60509 115.788 0.606816 115.791 0.608953L121.145 4.49588C121.521 4.76884 121.659 5.26445 121.479 5.69248L119.907 9.42906C119.788 9.71092 119.547 9.92278 119.253 10.0046C118.958 10.0864 118.642 10.0298 118.395 9.84942L114.072 6.68843L110.123 16.0753C109.369 17.8669 107.306 18.7083 105.514 17.9549L103.707 17.1945C101.915 16.4407 101.074 14.3774 101.827 12.5857L102.006 12.1608C102.76 10.3693 104.823 9.52787 106.615 10.2813L109.823 11.6308L114.282 1.03045C114.31 0.963126 114.345 0.900382 114.385 0.842925C114.393 0.831872 114.402 0.821788 114.41 0.811083C114.448 0.761646 114.489 0.715537 114.534 0.674709C114.54 0.669554 114.546 0.664992 114.552 0.659971C114.601 0.618325 114.653 0.582232 114.708 0.550823C114.717 0.54609 114.724 0.540157 114.733 0.535656C114.791 0.504406 114.853 0.479659 114.917 0.460559L114.935 0.455436C114.94 0.454069 114.945 0.452269 114.95 0.450979C114.969 0.445755 114.989 0.440991 115.01 0.436984L115.046 0.431097C115.051 0.430266 115.056 0.429946 115.062 0.429197C115.091 0.42488 115.121 0.420967 115.151 0.419361C115.155 0.419192 115.158 0.419306 115.161 0.419168C115.194 0.417748 115.227 0.417439 115.26 0.419276C115.265 0.419578 115.27 0.420444 115.276 0.420834C115.304 0.4229 115.333 0.426828 115.361 0.431415C115.37 0.432743 115.378 0.433042 115.387 0.43458C115.456 0.44747 115.524 0.468102 115.592 0.496423ZM105.839 12.1248C105.066 11.7997 104.175 12.1629 103.85 12.9363L103.671 13.3612C103.346 14.1347 103.709 15.0254 104.482 15.351L106.29 16.1113C107.063 16.4365 107.954 16.0732 108.28 15.2999L109.048 13.4743L105.839 12.1248ZM114.868 4.79448L118.554 7.48787L119.328 5.64703L115.637 2.96804L114.868 4.79448Z" fill="white" fill-opacity="0.4"/>
<path d="M32.6567 6.29339C33.1224 5.99653 33.7406 6.13342 34.0374 6.59913C34.3342 7.06484 34.1974 7.68306 33.7317 7.9799C29.5402 10.6516 28.3082 16.2153 30.98 20.4068C33.6517 24.5982 39.2154 25.8302 43.4069 23.1585C47.5983 20.4868 48.8302 14.9231 46.1586 10.7316C45.5018 9.70118 44.6716 8.85145 43.7316 8.19407C43.6106 8.10945 43.4873 8.02797 43.3628 7.94973L42.9841 7.72442L42.8971 7.66981C42.4827 7.37839 42.3484 6.81452 42.6016 6.36312C42.855 5.91166 43.4064 5.73272 43.8712 5.93487L43.9629 5.98063L44.4267 6.25591C44.579 6.35159 44.7296 6.45099 44.8776 6.5545C46.0294 7.3599 47.0444 8.40052 47.8451 9.6566C51.1105 14.7795 49.6048 21.5796 44.4819 24.845C39.359 28.1105 32.5589 26.6046 29.2934 21.4818C26.028 16.3589 27.5338 9.55884 32.6567 6.29339ZM37.2946 3.33708C37.9251 2.93517 38.6504 2.74126 39.3373 2.79357C40.0243 2.84604 40.7385 3.15865 41.1681 3.83269C41.5977 4.50673 41.5796 5.28614 41.3372 5.93108C41.0946 6.57576 40.6126 7.15146 39.9821 7.55336C39.6914 7.73866 39.3796 7.8767 39.0617 7.96982L42.3639 13.1504L42.4834 13.3487C43.6599 15.4185 43.0183 18.0697 40.9881 19.3639C38.8923 20.6997 36.1105 20.0837 34.7746 17.988C33.4388 15.8923 34.0548 13.1104 36.1505 11.7746C36.9305 11.2773 37.8057 11.0505 38.666 11.0698L35.9742 6.84694C35.8761 6.69297 35.8257 6.52222 35.8186 6.35205C35.7162 5.87536 35.7785 5.3877 35.9395 4.95936C36.182 4.31467 36.6641 3.73899 37.2946 3.33708ZM37.2255 13.4611C36.0612 14.2032 35.719 15.7487 36.4611 16.913C37.2033 18.0773 38.7487 18.4195 39.913 17.6774C41.0773 16.9352 41.4196 15.3897 40.6774 14.2254C39.9353 13.0612 38.3898 12.719 37.2255 13.4611ZM38.3696 5.02359C38.0687 5.21539 37.8875 5.46094 37.8114 5.66313C37.7356 5.86483 37.7796 5.95839 37.7951 5.98273C37.8107 6.00714 37.877 6.08648 38.0917 6.10287C38.3071 6.11927 38.6062 6.05863 38.9071 5.86685C39.208 5.67507 39.3892 5.42949 39.4653 5.22732C39.5411 5.02572 39.4971 4.93214 39.4816 4.90771C39.4661 4.88337 39.3999 4.80398 39.185 4.78757C38.9696 4.77117 38.6705 4.8318 38.3696 5.02359Z" fill="white" fill-opacity="0.4"/>
<path d="M147.91 80.312V82.286C147.91 82.8927 147.775 83.3127 147.504 83.546C147.233 83.7794 146.757 83.896 146.076 83.896H144.802L144.564 82.706H145.656C145.927 82.706 146.123 82.6827 146.244 82.636C146.375 82.5894 146.459 82.5147 146.496 82.412C146.543 82.3094 146.566 82.1554 146.566 81.95V80.368L142.058 80.522L141.876 79.43C143.201 79.0753 144.536 78.5947 145.88 77.988L142.814 78.142L142.646 77.302C143.775 76.826 144.83 76.2427 145.81 75.552H142.562V77.022H141.218V74.418H146.314V73.326H141.582V72.178H146.314V71.114H147.658V72.178H152.432V73.326H147.658V74.418H152.782V77.05H151.424V75.552H147.518C146.575 76.2614 145.745 76.7934 145.026 77.148L147.756 77.05C148.512 76.6207 149.119 76.238 149.576 75.902L150.416 76.798C149.091 77.6754 147.355 78.5387 145.208 79.388L149.898 79.15C149.907 79.1593 149.819 79.0847 149.632 78.926C149.259 78.618 149.063 78.4594 149.044 78.45L149.884 77.68L150.864 78.492C151.863 79.3227 152.469 79.8267 152.684 80.004L151.97 80.942L151.732 80.718C151.657 80.6527 151.569 80.578 151.466 80.494C151.373 80.41 151.261 80.312 151.13 80.2L147.91 80.312ZM145.068 81.656C144.499 82.0387 143.873 82.4213 143.192 82.804C142.511 83.1773 141.862 83.504 141.246 83.784L140.742 82.622C141.358 82.3607 142.016 82.0434 142.716 81.67C143.425 81.2874 144.009 80.9327 144.466 80.606L145.068 81.656ZM149.282 80.648C149.963 80.9747 151.275 81.6374 153.216 82.636L152.642 83.742C152.054 83.4247 151.363 83.0654 150.57 82.664C149.786 82.2534 149.156 81.936 148.68 81.712L149.282 80.648Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M138.852 83.98C137.937 83.7933 137.135 83.5927 136.444 83.378C135.763 83.1727 135.142 82.93 134.582 82.65C133.574 83.1073 132.155 83.56 130.326 84.008L129.934 82.776C131.371 82.5053 132.51 82.2207 133.35 81.922C132.631 81.418 131.866 80.704 131.054 79.78V78.716H134.106V77.666H131.012V72.486C131.703 72.3273 132.51 72.0753 133.434 71.73L133.896 72.78C133.289 73.032 132.711 73.2327 132.16 73.382V74.446H133.644V75.454H132.16V76.602H134.106V71.184H135.324V76.602H137.312V75.454H135.758V74.446H137.312V73.284H135.73V72.262H138.488V77.666H135.324V78.716H138.194V79.766C137.485 80.6713 136.724 81.39 135.912 81.922C136.733 82.23 137.839 82.4913 139.23 82.706L138.852 83.98ZM130.592 77.792L129.444 78.324V82.426C129.444 82.7993 129.397 83.084 129.304 83.28C129.22 83.476 129.066 83.6113 128.842 83.686C128.627 83.77 128.31 83.812 127.89 83.812H127.022L126.798 82.552H127.54C127.829 82.552 128.016 82.5147 128.1 82.44C128.184 82.356 128.226 82.1927 128.226 81.95V78.87L126.952 79.43L126.798 78.03C127.069 77.9273 127.545 77.7267 128.226 77.428V74.964H126.896V73.662H128.226V71.254H129.444V73.662H130.536V74.964H129.444V76.868C129.584 76.812 129.915 76.658 130.438 76.406L130.592 77.792ZM132.706 79.78C133.341 80.4427 133.985 80.9607 134.638 81.334C135.291 80.9793 135.949 80.4613 136.612 79.78H132.706Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M124.544 72.528V76.812H115.542C115.514 78.1933 115.369 79.3833 115.108 80.382C114.856 81.3807 114.417 82.5007 113.792 83.742L112.644 82.93C113.064 82.118 113.381 81.39 113.596 80.746C113.82 80.0927 113.979 79.402 114.072 78.674C114.165 77.946 114.212 77.0547 114.212 76V72.528H118.804L118.454 71.534L118.328 71.184L119.756 70.946L119.868 71.268C119.98 71.5853 120.134 72.0053 120.33 72.528H124.544ZM115.542 75.58H123.186V73.732H115.542V75.58ZM117.264 84.036H115.934V78.352H124.264V84.036H122.906V83.224H117.264V84.036ZM122.906 82.048V79.57H117.264V82.048H122.906Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M111.244 76.826V78.17H108.374V83.882H106.96V78.17H103.278C103.231 79.1033 103.11 79.8873 102.914 80.522C102.718 81.1473 102.41 81.7307 101.99 82.272C101.579 82.8133 100.982 83.4247 100.198 84.106L99.134 83.07C99.8434 82.5007 100.385 81.9873 100.758 81.53C101.131 81.0727 101.397 80.592 101.556 80.088C101.724 79.5747 101.831 78.9353 101.878 78.17H98.756V76.826H101.906V73.242H99.344V71.94H110.684V73.242H108.374V76.826H111.244ZM103.32 76.826H106.96V73.242H103.32V76.826Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M90.8601 75.706C90.5801 75.1553 90.2254 74.4927 89.7961 73.718C89.3667 72.934 89.0074 72.318 88.7181 71.87L89.7961 71.17C90.2907 71.9353 91.0327 73.214 92.0221 75.006L90.8601 75.706ZM96.2221 83.672C95.7741 83.056 94.9947 82.076 93.8841 80.732C93.4827 81.32 92.9647 81.88 92.3301 82.412C91.6954 82.9347 90.8927 83.4854 89.9221 84.064L89.1241 82.846C90.3934 82.1367 91.3594 81.4274 92.0221 80.718C92.6941 80.0087 93.1701 79.2154 93.4501 78.338C93.7301 77.4607 93.9261 76.322 94.0381 74.922C94.0847 74.3807 94.1127 73.2094 94.1221 71.408H95.4801V72.346C95.4801 73.6713 95.4614 74.5627 95.4241 75.02C95.3587 75.972 95.2607 76.798 95.1301 77.498C95.0087 78.198 94.8314 78.8327 94.5981 79.402C95.3541 80.2793 96.2361 81.3527 97.2441 82.622L96.2221 83.672ZM90.4401 77.75L91.0421 79.15L86.2121 83.336L85.3021 82.09C85.4701 81.922 85.5774 81.782 85.6241 81.67C85.6801 81.558 85.7081 81.4087 85.7081 81.222V71.702H87.1221V80.592L90.4401 77.75Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M74.8301 79.612C74.4381 79.248 74.0834 78.9214 73.7661 78.632V83.938H72.5201V78.94C72.1001 79.9574 71.6707 80.788 71.2321 81.432L70.6721 80.172C71.0081 79.6307 71.3394 79.0194 71.6661 78.338C71.9927 77.6474 72.2774 76.966 72.5201 76.294H70.8821V75.006H72.5201V73.354L71.1061 73.55L70.9941 72.346C71.6007 72.2807 72.3054 72.1827 73.1081 72.052C73.9107 71.912 74.6201 71.772 75.2361 71.632L75.4181 72.836C75.0261 72.9293 74.4754 73.0414 73.7661 73.172V75.006H75.2501V75.412C75.6234 74.8427 75.9687 74.194 76.2861 73.466C76.6034 72.7287 76.8601 71.982 77.0561 71.226L78.3301 71.436C78.2461 71.772 78.0921 72.2667 77.8681 72.92H82.9361V74.18L81.8301 76.434L80.6681 76L81.5781 74.18H77.3501C77.0327 74.8987 76.6361 75.6593 76.1601 76.462L75.2501 75.622V76.294H73.7661V77.092C74.6714 77.8574 75.2547 78.366 75.5161 78.618L74.8301 79.612ZM78.4421 75.202H79.7301V82.328C79.7301 82.8694 79.6041 83.252 79.3521 83.476C79.1094 83.7 78.7034 83.812 78.1341 83.812H76.8321L76.5941 82.524H77.6021C77.9381 82.524 78.1621 82.4774 78.2741 82.384C78.3861 82.2907 78.4421 82.104 78.4421 81.824V75.202ZM81.4101 77.036C81.7274 77.8107 82.0727 78.6927 82.4461 79.682C82.8287 80.6713 83.1181 81.432 83.3141 81.964L82.2221 82.454C81.6621 80.858 81.0367 79.22 80.3461 77.54L81.4101 77.036ZM77.6161 77.456C77.3827 78.2867 77.0887 79.1594 76.7341 80.074C76.3794 80.9794 76.0201 81.7727 75.6561 82.454L74.5081 81.936C74.8441 81.348 75.1941 80.606 75.5581 79.71C75.9314 78.8047 76.2441 77.9367 76.4961 77.106L77.6161 77.456Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M68.1241 78.072V84.022H66.8081V83.168H60.5921V84.022H59.2761V79.388C58.7347 79.5747 58.0394 79.7893 57.1901 80.032L56.7001 78.828C58.9307 78.2493 60.8161 77.5447 62.3561 76.714C63.9054 75.874 65.2307 74.894 66.3321 73.774H61.2641C61.0774 73.97 60.8814 74.166 60.6761 74.362L62.4821 75.748L61.6141 76.728L61.2781 76.462C60.4381 75.79 59.9154 75.3747 59.7101 75.216C59.1501 75.6733 58.5667 76.1027 57.9601 76.504L57.1341 75.384C58.0207 74.8613 58.9027 74.1893 59.7801 73.368C60.6667 72.5373 61.3807 71.744 61.9221 70.988L63.2241 71.352C63.0094 71.6787 62.7247 72.0707 62.3701 72.528H68.0961V73.774C67.3214 74.6607 66.4767 75.4587 65.5621 76.168C64.6474 76.868 63.6114 77.5027 62.4541 78.072H68.1241ZM60.5921 79.29V81.978H66.8081V79.29H60.5921Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M51.268 71.268V73.186H54.474V84.008H53.172V83.182H44.842V84.008H43.54V73.186H46.732V71.268H48.048V73.186H49.952V71.268H51.268ZM46.732 77.512V74.474H44.842V77.512H46.732ZM49.952 77.512V74.474H48.048V77.512H49.952ZM53.172 77.512V74.474H51.268V77.512H53.172ZM46.732 78.772H44.842V81.922H46.732V78.772ZM49.952 81.922V78.772H48.048V81.922H49.952ZM51.268 78.772V81.922H53.172V78.772H51.268Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M35.3221 75.986C36.1621 74.4927 36.7921 72.836 37.2121 71.016L38.4301 71.24C38.3181 71.7813 38.1547 72.374 37.9401 73.018H41.1461V74.222L40.2221 76.658L39.1301 76.266L39.9701 74.208H37.5061C37.1047 75.2627 36.6894 76.1307 36.2601 76.812L35.3221 75.986ZM35.0001 76.658H33.7961V72.864H28.9521V71.716H35.9101V72.864H35.0001V76.658ZM32.8861 73.69V76.294H29.4561V73.69H32.8861ZM31.8921 74.572H30.4641V75.426H31.8921V74.572ZM40.5441 83.938C39.4614 82.8087 38.6774 81.4927 38.1921 79.99C37.9587 80.7553 37.6274 81.4647 37.1981 82.118C36.7781 82.7713 36.2461 83.406 35.6021 84.022L34.9021 83.084C34.8087 83.3547 34.6314 83.546 34.3701 83.658C34.1181 83.77 33.7401 83.826 33.2361 83.826H31.9761L31.7521 82.664H32.9561C33.2081 82.664 33.3901 82.6407 33.5021 82.594C33.6234 82.538 33.7027 82.4493 33.7401 82.328C33.7867 82.2067 33.8101 82.02 33.8101 81.768V78.226H28.7841V77.12H36.1061V78.226H35.0141V82.202C35.0141 82.4353 35.0001 82.636 34.9721 82.804C35.8867 81.9453 36.5354 81.026 36.9181 80.046C37.3101 79.066 37.5154 77.89 37.5341 76.518L37.5481 75.006H38.6961L38.6821 76.406L38.6681 77.05C38.7427 78.2913 38.9947 79.374 39.4241 80.298C39.8627 81.222 40.4974 82.0667 41.3281 82.832L40.5441 83.938ZM29.3861 79.122H32.8721V82.02H29.3861V79.122ZM30.3941 81.194H31.8641V79.962H30.3941V81.194Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M21.6441 73.102C21.8587 75.1273 22.4047 76.9147 23.2821 78.464C24.1687 80.0133 25.4987 81.3947 27.2721 82.608L26.2641 83.868C24.8827 82.8133 23.7674 81.7073 22.9181 80.55C22.0781 79.3833 21.4434 78.03 21.0141 76.49C20.0994 79.15 18.3121 81.5953 15.6521 83.826L14.6581 82.636C18.0181 80.0227 19.9221 76.9473 20.3701 73.41C20.2954 73.27 20.1694 73.172 19.9921 73.116C19.8241 73.0507 19.5767 73.018 19.2501 73.018H17.3741V71.59H19.7681C20.3654 71.59 20.8134 71.7113 21.1121 71.954C21.4107 72.1873 21.5881 72.57 21.6441 73.102Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M5.02604 79.71L5.09604 80.956L4.04604 81.138V83.994H2.89804V81.334L0.826035 81.698L0.686035 80.424L2.89804 80.06V78.352H0.994035V77.106C1.27404 76.3873 1.5867 75.356 1.93204 74.012H0.854035V72.822H2.22604C2.3847 72.1593 2.50604 71.576 2.59004 71.072L3.71004 71.324C3.63537 71.6973 3.55137 72.052 3.45804 72.388L3.36004 72.822H5.33404V74.012H3.05204C2.66937 75.4213 2.35204 76.476 2.10004 77.176H3.09404V75.23H4.04604V77.176H5.04004V78.352H4.04604V79.878L5.02604 79.71ZM9.60404 71.156C10.0334 71.856 10.5654 72.5047 11.2 73.102C11.8347 73.6993 12.53 74.2033 13.286 74.614L12.712 75.762C12.18 75.4447 11.62 75.0387 11.032 74.544V75.384H6.94404V74.698C6.46804 75.0993 5.94537 75.4773 5.37604 75.832L4.70404 74.726C5.57204 74.2127 6.3047 73.6713 6.90204 73.102C7.49937 72.5327 8.0267 71.884 8.48404 71.156H9.60404ZM10.794 74.334C10.0567 73.6713 9.47804 73.0133 9.05804 72.36C8.61004 73.0507 8.04537 73.7087 7.36404 74.334H10.794ZM11.522 76H12.558V82.44C12.558 82.8133 12.5114 83.098 12.418 83.294C12.334 83.49 12.18 83.6253 11.956 83.7C11.7414 83.784 11.4287 83.826 11.018 83.826H9.87004L9.66004 82.734H10.682C11.0274 82.734 11.2514 82.692 11.354 82.608C11.466 82.5147 11.522 82.342 11.522 82.09V76ZM5.43204 83.882V76.154H8.96004V82.608C8.96004 83.0653 8.8807 83.3733 8.72204 83.532C8.56337 83.7 8.2647 83.784 7.82604 83.784H7.08404L6.88804 82.762H7.44804C7.6627 82.762 7.79804 82.7293 7.85404 82.664C7.91937 82.5987 7.95204 82.4773 7.95204 82.3V81.25H6.44004V83.882H5.43204ZM9.78604 76.434H10.724V81.586H9.78604V76.434ZM7.95204 78.198V77.148H6.44004V78.198H7.95204ZM6.44004 79.192V80.242H7.95204V79.192H6.44004Z" fill="#27272a" fill-opacity="0.7"/>
<path d="M93.0001 44.9999V46.9999H61.0001V44.9999H93.0001ZM97.0001 40.9999V25.9999C97.0001 23.7907 95.2092 21.9999 93.0001 21.9999H61.0001C58.7909 21.9999 57.0001 23.7907 57.0001 25.9999V40.9999C57.0001 43.209 58.7909 44.9999 61.0001 44.9999V46.9999L60.6915 46.9921C57.6234 46.8366 55.1633 44.3765 55.0079 41.3085L55.0001 40.9999V25.9999C55.0001 22.6862 57.6864 19.9999 61.0001 19.9999H93.0001C96.3138 19.9999 99.0001 22.6862 99.0001 25.9999V40.9999C99.0001 44.2101 96.479 46.8314 93.3087 46.9921L93.0001 46.9999V44.9999C95.2092 44.9999 97.0001 43.209 97.0001 40.9999Z" fill="#52525b" fill-opacity="0.9"/>
<path d="M72.8538 39.2659C72.1165 39.2659 71.5285 39.0699 71.0898 38.6779C70.6511 38.2859 70.4318 37.7585 70.4318 37.0959C70.4318 36.2652 70.7585 35.6352 71.4118 35.2059C72.0651 34.7672 72.9938 34.5479 74.1978 34.5479H75.1778V34.0579C75.1778 33.5819 75.0331 33.2085 74.7438 32.9379C74.4638 32.6579 74.0858 32.5179 73.6098 32.5179C72.9938 32.5179 72.3965 32.7932 71.8178 33.3439L70.8658 32.4479C71.6591 31.5985 72.6158 31.1739 73.7358 31.1739C74.6411 31.1739 75.3598 31.4259 75.8918 31.9299C76.4331 32.4339 76.7038 33.1059 76.7038 33.9459V39.0699H75.1778V38.0479H75.0938C74.8978 38.4212 74.5945 38.7199 74.1838 38.9439C73.7731 39.1585 73.3298 39.2659 72.8538 39.2659ZM73.2178 37.9359C73.5165 37.9359 73.8151 37.8752 74.1138 37.7539C74.4218 37.6325 74.6738 37.4459 74.8698 37.1939C75.0751 36.9325 75.1778 36.6152 75.1778 36.2419V35.8779H74.4358C73.6051 35.8779 72.9891 35.9712 72.5878 36.1579C72.1958 36.3352 71.9998 36.6152 71.9998 36.9979C71.9998 37.2779 72.1071 37.5065 72.3218 37.6839C72.5458 37.8519 72.8445 37.9359 73.2178 37.9359Z" fill="#52525b" fill-opacity="0.9"/>
<path d="M69.6741 39.0699H67.9941L66.8601 35.8359H62.9821L61.8481 39.0699H60.1681L64.1021 28.2479H65.7401L69.6741 39.0699ZM64.8721 30.4459L63.5141 34.3379H66.3281L64.9561 30.4459H64.8721Z" fill="#52525b" fill-opacity="0.9"/>
<path d="M102.903 36.9219C102.575 36.0792 103.445 35.2692 104.262 35.6558L119.231 42.7362C120.051 43.1239 119.959 44.2818 119.161 44.5752L118.992 44.6217L113.12 45.7485C112.744 45.8207 112.441 46.1025 112.342 46.4733L110.803 52.2499C110.554 53.1845 109.256 53.2567 108.905 52.3553L102.903 36.9219ZM109.659 48.7758L110.41 45.9578L110.473 45.7527C110.797 44.8096 111.571 44.0888 112.535 43.8317L112.744 43.7838L115.607 43.2344L105.675 38.5354L109.659 48.7758Z" fill="#71717a"/>
<path d="M115.592 0.496423C115.66 0.525313 115.724 0.561654 115.782 0.602988C115.785 0.60509 115.788 0.606816 115.791 0.608953L121.145 4.49588C121.521 4.76884 121.659 5.26445 121.479 5.69248L119.907 9.42906C119.788 9.71092 119.547 9.92278 119.253 10.0046C118.958 10.0864 118.642 10.0298 118.395 9.84942L114.072 6.68843L110.123 16.0753C109.369 17.8669 107.306 18.7083 105.514 17.9549L103.707 17.1945C101.915 16.4407 101.074 14.3774 101.827 12.5857L102.006 12.1608C102.76 10.3693 104.823 9.52787 106.615 10.2813L109.823 11.6308L114.282 1.03045C114.31 0.963126 114.345 0.900382 114.385 0.842925C114.393 0.831872 114.402 0.821788 114.41 0.811083C114.448 0.761646 114.489 0.715537 114.534 0.674709C114.54 0.669554 114.546 0.664992 114.552 0.659971C114.601 0.618325 114.653 0.582232 114.708 0.550823C114.717 0.54609 114.724 0.540157 114.733 0.535656C114.791 0.504406 114.853 0.479659 114.917 0.460559L114.935 0.455436C114.94 0.454069 114.945 0.452269 114.95 0.450979C114.969 0.445755 114.989 0.440991 115.01 0.436984L115.046 0.431097C115.051 0.430266 115.056 0.429946 115.062 0.429197C115.091 0.42488 115.121 0.420967 115.151 0.419361C115.155 0.419192 115.158 0.419306 115.161 0.419168C115.194 0.417748 115.227 0.417439 115.26 0.419276C115.265 0.419578 115.27 0.420444 115.276 0.420834C115.304 0.4229 115.333 0.426828 115.361 0.431415C115.37 0.432743 115.378 0.433042 115.387 0.43458C115.456 0.44747 115.524 0.468102 115.592 0.496423ZM105.839 12.1248C105.066 11.7997 104.175 12.1629 103.85 12.9363L103.671 13.3612C103.346 14.1347 103.709 15.0254 104.482 15.351L106.29 16.1113C107.063 16.4365 107.954 16.0732 108.28 15.2999L109.048 13.4743L105.839 12.1248ZM114.868 4.79448L118.554 7.48787L119.328 5.64703L115.637 2.96804L114.868 4.79448Z" fill="#52525b" fill-opacity="0.85"/>
<path d="M32.6567 6.29339C33.1224 5.99653 33.7406 6.13342 34.0374 6.59913C34.3342 7.06484 34.1974 7.68306 33.7317 7.9799C29.5402 10.6516 28.3082 16.2153 30.98 20.4068C33.6517 24.5982 39.2154 25.8302 43.4069 23.1585C47.5983 20.4868 48.8302 14.9231 46.1586 10.7316C45.5018 9.70118 44.6716 8.85145 43.7316 8.19407C43.6106 8.10945 43.4873 8.02797 43.3628 7.94973L42.9841 7.72442L42.8971 7.66981C42.4827 7.37839 42.3484 6.81452 42.6016 6.36312C42.855 5.91166 43.4064 5.73272 43.8712 5.93487L43.9629 5.98063L44.4267 6.25591C44.579 6.35159 44.7296 6.45099 44.8776 6.5545C46.0294 7.3599 47.0444 8.40052 47.8451 9.6566C51.1105 14.7795 49.6048 21.5796 44.4819 24.845C39.359 28.1105 32.5589 26.6046 29.2934 21.4818C26.028 16.3589 27.5338 9.55884 32.6567 6.29339ZM37.2946 3.33708C37.9251 2.93517 38.6504 2.74126 39.3373 2.79357C40.0243 2.84604 40.7385 3.15865 41.1681 3.83269C41.5977 4.50673 41.5796 5.28614 41.3372 5.93108C41.0946 6.57576 40.6126 7.15146 39.9821 7.55336C39.6914 7.73866 39.3796 7.8767 39.0617 7.96982L42.3639 13.1504L42.4834 13.3487C43.6599 15.4185 43.0183 18.0697 40.9881 19.3639C38.8923 20.6997 36.1105 20.0837 34.7746 17.988C33.4388 15.8923 34.0548 13.1104 36.1505 11.7746C36.9305 11.2773 37.8057 11.0505 38.666 11.0698L35.9742 6.84694C35.8761 6.69297 35.8257 6.52222 35.8186 6.35205C35.7162 5.87536 35.7785 5.3877 35.9395 4.95936C36.182 4.31467 36.6641 3.73899 37.2946 3.33708ZM37.2255 13.4611C36.0612 14.2032 35.719 15.7487 36.4611 16.913C37.2033 18.0773 38.7487 18.4195 39.913 17.6774C41.0773 16.9352 41.4196 15.3897 40.6774 14.2254C39.9353 13.0612 38.3898 12.719 37.2255 13.4611ZM38.3696 5.02359C38.0687 5.21539 37.8875 5.46094 37.8114 5.66313C37.7356 5.86483 37.7796 5.95839 37.7951 5.98273C37.8107 6.00714 37.877 6.08648 38.0917 6.10287C38.3071 6.11927 38.6062 6.05863 38.9071 5.86685C39.208 5.67507 39.3892 5.42949 39.4653 5.22732C39.5411 5.02572 39.4971 4.93214 39.4816 4.90771C39.4661 4.88337 39.3999 4.80398 39.185 4.78757C38.9696 4.77117 38.6705 4.8318 38.3696 5.02359Z" fill="#52525b" fill-opacity="0.85"/>
</svg>

Before

Width:  |  Height:  |  Size: 17 KiB

After

Width:  |  Height:  |  Size: 17 KiB

+1 -1
View File
@@ -25,7 +25,7 @@ export default defineEventHandler(async (event) => {
})
}
if (!['SONG', 'KEYWORD'].includes(type)) {
if (!['SONG', 'KEYWORD', 'LANGUAGE', 'GENRE'].includes(type)) {
throw createError({
statusCode: 400,
message: '无效的黑名单类型'
@@ -0,0 +1,91 @@
import { eq } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { CacheService } from '~~/server/services/cacheService'
import { kuGouFetch } from '~~/server/services/kugouService'
/**
* 粘贴 Cookie / Token 绑定酷狗 —— 不再需要扫码。
*
* 为什么可以这样做:`kuGouFetch()` 是**无状态**的 —— 它把库里存的
* `kugouAdminCookie` 放进请求头、`kugouAdminToken` 放进 query,去调 sidecar。
* 也就是说"扫码"只是**获取 cookie 的一种方式**;只要拿到一份有效 cookie,
* 后续同步的行为与扫码绑定完全一致。
*
* 好处:管理员在**电脑浏览器**上登录酷狗(滑块/短信验证码都能过)→
* 从 devtools 复制 Cookie 粘贴过来即可,不依赖另一台装了酷狗 App 的手机。
*
* 安全:这里**不接收也不保存酷狗账号密码**,只保存浏览器会话 Cookie(与扫码同款),
* 且写入前会先用它探一次 `/login/status`,无效的凭证不会落库。
*/
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未登录' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role))
throw createError({ statusCode: 403, message: '仅管理员可绑定酷狗账号' })
const body = await readBody(event)
const cookie = String(body?.cookie || '').trim()
const token = String(body?.token || '').trim()
if (!cookie && !token) {
throw createError({ statusCode: 400, message: '请粘贴 Cookie(或 token)后再保存' })
}
if (cookie.length > 8000) {
throw createError({ statusCode: 400, message: 'Cookie 太长(超过 8000 字),可能粘贴错了内容' })
}
// 先用这份凭证探一次登录状态:有效才写库,避免把废值存进去导致"看起来绑定了但同步全失败"
let loginStatus: any = null
try {
loginStatus = await kuGouFetch(
'/login/status',
{ timestamp: Date.now() },
{ forceCookie: cookie || undefined, forceToken: token || undefined },
)
} catch (e: any) {
throw createError({
statusCode: 502,
message:
'用这份 Cookie 连接酷狗失败:' +
(e?.message || String(e)) +
'(如果提示连不上 sidecar,请先到「服务设置」里确认酷狗 API 地址)',
})
}
const statusOk =
Number(loginStatus?.status ?? 0) === 1 || !!(loginStatus?.data?.profile || loginStatus?.data?.account)
if (!statusOk) {
throw createError({
statusCode: 400,
message:
'这份 Cookie 无效或已过期(酷狗侧显示未登录):' +
(loginStatus?.error || loginStatus?.message || '请在浏览器里重新登录酷狗后重新复制'),
})
}
const profile = loginStatus?.data?.profile || loginStatus?.data?.account || null
const rows = await db.select().from(systemSettings).limit(1)
const patch: Record<string, any> = {
kugouAdminToken: token || null,
kugouAdminCookie: cookie || null,
kugouAdminUser: profile || null,
kugouAdminBoundAt: new Date(),
updatedAt: new Date(),
}
if (!rows[0]) {
await db.insert(systemSettings).values(patch as any)
} else {
await db.update(systemSettings).set(patch).where(eq(systemSettings.id, rows[0].id))
}
try {
const cs = CacheService.getInstance()
await cs.setSystemSettings?.({ ...(rows[0] || {}), ...patch })
} catch {}
return {
ok: true,
bound: true,
user: profile,
hint: '绑定成功,可以直接去同步歌单了',
}
})
+64
View File
@@ -0,0 +1,64 @@
import { eq } from 'drizzle-orm'
import { db } from '~/drizzle/db'
import { systemSettings } from '~/drizzle/schema'
import { CacheService } from '~~/server/services/cacheService'
import { kuGouQrLoginCheck } from '~~/server/services/kugouService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) throw createError({ statusCode: 401, message: '未登录' })
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role))
throw createError({ statusCode: 403, message: '仅管理员可绑定酷狗账号' })
const body = await readBody(event)
const { qr_id, login_id, key, cookie } = body || {}
if (!(qr_id || login_id || key)) {
throw createError({ statusCode: 400, message: '缺少 qr_id 或 key' })
}
const res = await kuGouQrLoginCheck({ qr_id, login_id, key, cookie })
const state = Number(res?.data?.status ?? 0)
const token = (res as any)?.data?.token || (res as any)?.token || ''
const kugouCookie = (res as any)?.data?.cookie || (res as any)?.cookie || cookie || ''
const kugouUser = (res as any)?.data?.userinfo || (res as any)?.userinfo || null
let bound = false
if (state === 1 && (token || kugouCookie)) {
const rows = await db.select().from(systemSettings).limit(1)
const patch: Record<string, any> = {
kugouAdminToken: token || null,
kugouAdminCookie: kugouCookie || null,
kugouAdminUser: kugouUser || null,
kugouAdminBoundAt: new Date(),
updatedAt: new Date(),
}
if (!rows[0]) {
await db.insert(systemSettings).values(patch as any)
} else {
await db.update(systemSettings).set(patch).where(eq(systemSettings.id, rows[0].id))
}
try {
const cs = CacheService.getInstance()
await cs.setSystemSettings?.({ ...(rows[0] || {}), ...patch })
} catch {}
bound = true
}
return {
ok: true,
state, // 0 未扫码 / 1 已完成 / 2 已扫码未确认 / -1 过期
bound,
user: kugouUser,
hint:
state === 0
? '请用酷狗 App 扫二维码'
: state === 2
? '扫码成功,请在手机端确认登录'
: state === 1
? '绑定成功'
: state === -1
? '二维码已过期,请刷新重试'
: res?.error || '等待中',
raw: res,
}
})
+39
View File
@@ -0,0 +1,39 @@
import { kuGouQrLoginCreate } from '~~/server/services/kugouService'
export default defineEventHandler(async (event) => {
const user = event.context.user
if (!user) {
throw createError({ statusCode: 401, message: '未登录' })
}
if (!['ADMIN', 'SUPER_ADMIN'].includes(user.role)) {
throw createError({ statusCode: 403, message: '仅管理员可绑定酷狗账号' })
}
const query = getQuery(event)
const platform = (query.platform as string) || 'web'
try {
const res = await kuGouQrLoginCreate(
platform === 'lite' ? 'lite' : platform === 'android' ? 'android' : 'web',
)
if (res && res.status !== 1 && (res as any).errcode) {
return {
ok: false,
error: (res as any).error || '二维码生成失败',
data: null,
}
}
return {
ok: true,
data: res?.data || null,
raw: res,
}
} catch (e: any) {
throw createError({
statusCode: 502,
message:
'连接酷狗 sidecar 失败:请先启动 KuGouMusicApi 服务 (默认 http://127.0.0.1:3000),详细: ' +
(e?.message || String(e)),
})
}
})

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