Compare commits
98
Commits
d09d12828d
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4fe807eb39 | ||
|
|
f5a43b37cc | ||
|
|
dc2565da34 | ||
|
|
c51b7c660a | ||
|
|
6f9def0ffd | ||
|
|
d1b643bd93 | ||
|
|
09e7f53a1a | ||
|
|
308a2103ac | ||
|
|
81fd05b39a | ||
|
|
b64ee5cbc6 | ||
|
|
28238ab00f | ||
|
|
5962df638d | ||
|
|
3eff710d5d | ||
|
|
ebf1c3a127 | ||
|
|
752a2eae87 | ||
|
|
cd6e235848 | ||
|
|
deb71875c1 | ||
|
|
dfb4891022 | ||
|
|
792b3d4ef6 | ||
|
|
5039ab3f79 | ||
|
|
1a334bbf5c | ||
|
|
cccaf36990 | ||
|
|
9d3edc2c5e | ||
|
|
af50ff1d95 | ||
|
|
17f2aa7fcd | ||
|
|
64a81393b5 | ||
|
|
575e8b9f57 | ||
|
|
08a37e4e2f | ||
|
|
d26996350e | ||
|
|
a2ba15c3ca | ||
|
|
a080144a86 | ||
|
|
565d6e0fcb | ||
|
|
9db42861e1 | ||
|
|
3614ac6350 | ||
|
|
89cc80911e | ||
|
|
39375c23f2 | ||
|
|
12dac95e1b | ||
|
|
a140ce8621 | ||
|
|
6f5aa625e6 | ||
|
|
381f3debf1 | ||
|
|
6b3a83ebeb | ||
|
|
a0184fdeb0 | ||
|
|
573e168702 | ||
|
|
357876a716 | ||
|
|
25c7517405 | ||
|
|
87aa12d573 | ||
|
|
8a82cb77a7 | ||
|
|
43f05a6e34 | ||
|
|
90e32ad086 | ||
|
|
db685f26ba | ||
|
|
de52e4124c | ||
|
|
92704d6637 | ||
|
|
f80abe659f | ||
|
|
1a2031325b | ||
|
|
e8eada885a | ||
|
|
c2114f0d82 | ||
|
|
37fd18aaea | ||
|
|
c4c30de416 | ||
|
|
ccb39203b8 | ||
|
|
d3d2673e5c | ||
|
|
9a025f5bd5 | ||
|
|
456f676929 | ||
|
|
7034bbaedb | ||
|
|
16f0b1f6b5 | ||
|
|
bdefc105bf | ||
|
|
8ff5bec4f0 | ||
|
|
a55fa96952 | ||
|
|
ac4727551c | ||
|
|
27d718bf01 | ||
|
|
456bc39e3e | ||
|
|
28d06dfdb2 | ||
|
|
604749d08a | ||
|
|
ec39f6ab56 | ||
|
|
71bbd88bb8 | ||
|
|
19ab33b102 | ||
|
|
a481e0ae4c | ||
|
|
9fd8a9ccbd | ||
|
|
f5b7bef285 | ||
|
|
a64fa6bc72 | ||
|
|
7f3e570d8a | ||
|
|
7b605351f4 | ||
|
|
467dd38d5f | ||
|
|
a937f32e6e | ||
|
|
a0f11ff64a | ||
|
|
78c3d878ea | ||
|
|
d8060147ed | ||
|
|
9fc5e3e607 | ||
|
|
8b0934f6f4 | ||
|
|
b1c3af84de | ||
|
|
bf7c40c4fe | ||
|
|
7ee5e8b9e9 | ||
|
|
2ee8056ad1 | ||
|
|
80b7c256b5 | ||
|
|
94832e2c82 | ||
|
|
a82ff6747e | ||
|
|
0c71328efd | ||
|
|
ca190a445a | ||
|
|
d818819fff |
+35
-3
@@ -106,10 +106,42 @@ CASDOOR_ENDPOINT=
|
|||||||
GOOGLE_CLIENT_ID=
|
GOOGLE_CLIENT_ID=
|
||||||
GOOGLE_CLIENT_SECRET=
|
GOOGLE_CLIENT_SECRET=
|
||||||
|
|
||||||
# 聚合登陆 OAuth
|
# 聚合登陆 OAuth(如 auxx.cn 等聚合登录服务,支持微信/QQ 等通道
|
||||||
AGGREGATE_OAUTH_APP_ID=
|
AGGREGATE_OAUTH_APP_ID=
|
||||||
AGGREGATE_OAUTH_APP_KEY=
|
AGGREGATE_OAUTH_APP_KEY=
|
||||||
# 支持单值、逗号分隔或 JSON 数组,例如:qq,wx,sina,baidu
|
# 支持单值、逗号分隔或 JSON 数组,例如:qq,wx,sina,baidu
|
||||||
# 可用值:qq,wx,alipay,sina,baidu,douyin,huawei,xiaomi,gitee,gitea,bilibili,kuaishou
|
# 可用值:qq,wx,alipay,sina,baidu,douyin,huawei,xiaomi,gitee,gitea,bilibili,kuaishou
|
||||||
AGGREGATE_OAUTH_LOGIN_TYPE=qq
|
AGGREGATE_OAUTH_LOGIN_TYPE=wx
|
||||||
AGGREGATE_OAUTH_ENDPOINT=https://example.com/connect.php
|
# 聚合登录服务基地址(https,不带 /oauth/... 路径),例如:https://www.auxx.cn
|
||||||
|
AGGREGATE_OAUTH_ENDPOINT=
|
||||||
|
|
||||||
|
# 微信小程序登录(可选)
|
||||||
|
# 用于小程序端 wx.login() 的 code 换取 openid,对应接口 POST /api/auth/wechat/login
|
||||||
|
# AppID / AppSecret 在微信公众平台「开发管理 → 开发设置 → 开发者ID」中获取
|
||||||
|
# 注意:这与上方聚合登录的 wx 通道不是一回事——小程序 openid 按 AppID 维度生成,两者不通用
|
||||||
|
WECHAT_MINI_APPID=
|
||||||
|
WECHAT_MINI_SECRET=
|
||||||
|
|
||||||
|
# 微信「订阅消息」模板(可选:**留空也能用**,代码里内置了站长选好的模板)
|
||||||
|
# 内置模板(唯一来源:server/utils/wechatSubscribeTemplates.ts 的 BUILTIN_TEMPLATES):
|
||||||
|
# SONG_SELECTED「歌曲排期通知」—— 管理员把歌排进排期/发布排期时发
|
||||||
|
# thing1→songTitle, phrase2→status, time3→playTime, character_string4→sequence
|
||||||
|
# (微信侧真实关键词,模板原文:歌曲名称/排期状态/播放时间/排期序号;**没有日期字段**,
|
||||||
|
# 日期含在 playTime 的 'YYYY-MM-DD HH:mm' 里)
|
||||||
|
# SONG_REJECTED「审核驳回通知」—— 投稿被驳回时发
|
||||||
|
# thing6→songTitle, thing4→reason
|
||||||
|
# 下面这条环境变量**只用来覆盖内置映射**(比如站长在公众平台选了别的模板或字段名)。
|
||||||
|
# ⚠️ 必须是合法 JSON;写了坏值(例如只填一个光秃秃的模板 ID)服务端会**忽略它**并在启动日志里
|
||||||
|
# 打一行「不是合法 JSON,已忽略(仍用内置默认模板)」,属于可恢复状态,但请在体检接口里确认。
|
||||||
|
# {"SONG_SELECTED":{"id":"模板ID"}} ← 只换 ID,沿用内置字段映射
|
||||||
|
# {"SONG_REJECTED":{"id":"模板ID","fields":{"thing6":"songTitle"}}} ← 同时换字段映射
|
||||||
|
# {"SONG_PLAYED":{"id":"模板ID","fields":{"thing1":"songTitle"}}} ← 也可以补一张内置表里没有的模板
|
||||||
|
# fields 的键是模板里的字段名(thing1/phrase2/time3/character_string4,公众平台选用模板时能看到;
|
||||||
|
# **编号是微信自动分配的,不能靠命名规律猜** —— 猜错一位就是整条 47003。核不准就别写,
|
||||||
|
# 服务端会自动调 /wxaapi/newtmpl/gettemplate 读模板原文来校准),
|
||||||
|
# 值是变量名(可用:songTitle / message / time / playDate / playTime / sequence / status / reason)。
|
||||||
|
# ⚠️ 平台限制:一次性订阅=一次授权只能发一条;只有绑定了微信的账号才有 openid;
|
||||||
|
# 模板 ID 变了老用户要**重新点一次「开启微信通知」**才有额度(旧授权按模板 ID 记账,直接作废)。
|
||||||
|
# 发不出去时先看 GET /api/admin/wechat/diagnose(超管,只读不消耗额度):
|
||||||
|
# 它会把「拿不拿到 token / 有没有绑微信 / 通知开关开没开 / 还剩几条授权」逐项说清。
|
||||||
|
WECHAT_MINI_SUBSCRIBE_TEMPLATES=
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 强制 shell 脚本与工作流用 LF:runner 是 Linux,CRLF 会让 `run: |` 里的 heredoc / 多行脚本出问题
|
||||||
|
*.sh text eol=lf
|
||||||
|
*.yml text eol=lf
|
||||||
|
*.yaml text eol=lf
|
||||||
|
*.mjs text eol=lf
|
||||||
|
*.js text eol=lf
|
||||||
|
*.ts text eol=lf
|
||||||
|
*.json text eol=lf
|
||||||
|
# 迁移 SQL 也用 LF:这些文件会在容器里被 drizzle-kit 读取执行,不要混进 CRLF
|
||||||
|
*.sql text eol=lf
|
||||||
|
Dockerfile text eol=lf
|
||||||
+271
-425
@@ -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
|
name: 构建并部署 BYW VoiceHub
|
||||||
run-name: ${{ github.actor }} 触发部署 · Run ${{ github.run_number }}
|
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
@@ -7,291 +16,98 @@ on:
|
|||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
inputs:
|
inputs:
|
||||||
deploy_target:
|
deploy_target:
|
||||||
description: 构建完成后是否自动部署到服务器
|
description: "只构建不部署时不要勾选"
|
||||||
type: boolean
|
type: boolean
|
||||||
default: true
|
default: true
|
||||||
required: false
|
|
||||||
skip_build_cache:
|
skip_build_cache:
|
||||||
description: 跳过 BuildKit 分层缓存,强制重新构建(排错用)
|
description: "跳过构建缓存(强制重新构建)"
|
||||||
type: boolean
|
type: boolean
|
||||||
default: false
|
default: false
|
||||||
required: false
|
|
||||||
|
|
||||||
concurrency:
|
|
||||||
group: deploy-production
|
|
||||||
cancel-in-progress: true
|
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GITEA_HOST: gitea.fse-media.group
|
GITEA_HOST: gitea.fse-media.group
|
||||||
DEPLOY_PATH: /data/opt/byw-voicehub
|
|
||||||
IMAGE_NAME: byw-voicehub
|
|
||||||
GH_PROXY_PREFIX: https://v4.gh-proxy.org/
|
GH_PROXY_PREFIX: https://v4.gh-proxy.org/
|
||||||
APT_MIRROR_DEBIAN: https://mirrors.tuna.tsinghua.edu.cn/debian
|
IMAGE_NAME: byw-voicehub
|
||||||
APT_MIRROR_DEBIAN_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/debian-security
|
|
||||||
APT_MIRROR_DEBIAN_HTTP: http://mirrors.tuna.tsinghua.edu.cn/debian
|
|
||||||
APT_MIRROR_DEBIAN_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/debian-security
|
|
||||||
APT_MIRROR_UBUNTU: https://mirrors.tuna.tsinghua.edu.cn/ubuntu
|
|
||||||
APT_MIRROR_UBUNTU_SECURITY: https://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
|
|
||||||
APT_MIRROR_UBUNTU_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu
|
|
||||||
APT_MIRROR_UBUNTU_SECURITY_HTTP: http://mirrors.tuna.tsinghua.edu.cn/ubuntu-security
|
|
||||||
HTTP_PROXY_EXAMPLE: ""
|
|
||||||
HTTPS_PROXY_EXAMPLE: ""
|
|
||||||
# ========== Docker 官方镜像国内加速(Runner 拉 node:alpine / buildkit 等最常见卡点) ==========
|
|
||||||
# 注意:如果你知道哪个镜像源在内网最快,可以在仓库 Variables 里覆盖 DOCKER_REGISTRY_MIRROR_1/2/3
|
|
||||||
DOCKER_REGISTRY_MIRROR_1: https://docker.m.daocloud.io
|
|
||||||
DOCKER_REGISTRY_MIRROR_2: https://docker.1panel.live
|
|
||||||
DOCKER_REGISTRY_MIRROR_3: https://docker.1ms.run
|
|
||||||
# Dockerfile 内 FROM 前缀替换(ARG DOCKER_MIRROR=…),避免 buildx build 内部 FROM node:22-alpine 再被 Hub 限速
|
|
||||||
DOCKER_MIRROR_PREFIX: docker.m.daocloud.io/library/
|
DOCKER_MIRROR_PREFIX: docker.m.daocloud.io/library/
|
||||||
NODE_BUILDER_TAG: "22-alpine"
|
NODE_BUILDER_TAG: "22-alpine"
|
||||||
NODE_RUNTIME_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:
|
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:
|
build-image:
|
||||||
name: 构建并推送容器镜像
|
name: 构建并推送镜像
|
||||||
needs: preflight
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 40
|
timeout-minutes: 40
|
||||||
outputs:
|
outputs:
|
||||||
image_tag: ${{ steps.meta.outputs.tag }}
|
image_tag: ${{ steps.meta.outputs.image_tag }}
|
||||||
image_full: ${{ steps.meta.outputs.full }}
|
image_full: ${{ steps.meta.outputs.image_full }}
|
||||||
image_latest: ${{ steps.meta.outputs.latest }}
|
image_latest: ${{ steps.meta.outputs.image_latest }}
|
||||||
steps:
|
steps:
|
||||||
- name: 系统环境准备(Ubuntu 22.04 默认镜像源换清华 + 全局 GitHub gh-proxy)
|
- name: 准备 Docker(registry-mirrors)
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
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
|
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
|
sudo mkdir -p /etc/docker 2>/dev/null || true
|
||||||
OLD_MIRRORS_JSON="{}"
|
sudo tee /etc/docker/daemon.json >/dev/null <<JSON
|
||||||
if [ -f "${DOCKER_DAEMON_CONF}" ]; then
|
{
|
||||||
OLD_MIRRORS_JSON="$(sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || echo "{}")"
|
"registry-mirrors": [
|
||||||
fi
|
"${DOCKER_REGISTRY_MIRROR_1}",
|
||||||
PY_DAEMON_B64="aW1wb3J0IGpzb24sIHN5cwp0cnk6CiAgICBkYXRhID0ganNvbi5sb2FkcyhzeXMuYXJndlsxXSBvciAie30iKQpleGNlcHQgRXhjZXB0aW9uOgogICAgZGF0YSA9IHt9Cm1pcnJvcnNfZW52cyA9IFttIGZvciBtIGluIHN5cy5hcmd2WzI6NV0gaWYgbV0KZXhpc3RpbmcgPSBkYXRhLmdldCgicmVnaXN0cnktbWlycm9ycyIpIG9yIFtdCmlmIGlzaW5zdGFuY2UoZXhpc3RpbmcsIGxpc3QpOgogICAgY29tYmluZWQgPSBsaXN0KGRpY3QuZnJvbWtleXMoWyptaXJyb3JzX2VudnMsICpleGlzdGluZ10pKQplbHNlOgogICAgY29tYmluZWQgPSBtaXJyb3JzX2VudnMKaWYgY29tYmluZWQ6CiAgICBkYXRhWyJyZWdpc3RyeS1taXJyb3JzIl0gPSBjb21iaW5lZApkYXRhLnNldGRlZmF1bHQoImxvZy1kcml2ZXIiLCAianNvbi1maWxlIikKcHJpbnQoanNvbi5kdW1wcyhkYXRhLCBlbnN1cmVfYXNjaWk9RmFsc2UsIGluZGVudD0yKSk="
|
"${DOCKER_REGISTRY_MIRROR_2}",
|
||||||
NEW_DAEMON_JSON="$(printf '%s' "$PY_DAEMON_B64" | base64 -d | python3 - "$OLD_MIRRORS_JSON" \
|
"${DOCKER_REGISTRY_MIRROR_3}"
|
||||||
"${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:"
|
JSON
|
||||||
sudo cat "${DOCKER_DAEMON_CONF}" 2>/dev/null || true
|
|
||||||
# 非侵入式重启 docker;runner 没 systemd 时用 service 兜底;失败也跳过(buildkitd.toml 内部仍有 mirrors)
|
|
||||||
sudo systemctl restart docker 2>/dev/null \
|
sudo systemctl restart docker 2>/dev/null \
|
||||||
|| sudo service docker restart 2>/dev/null \
|
|| sudo service docker restart 2>/dev/null \
|
||||||
|| echo "[Docker] systemctl/service 重启失败,跳过(后续 buildx builder 内部仍有 registry mirrors)"
|
|| echo "[Docker] 重启失败,继续(下面用显式 mirror 前缀拉镜像兜底)"
|
||||||
sleep 3 2>/dev/null || true
|
sleep 3 || true
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "环境准备完成"
|
|
||||||
cat /etc/os-release 2>/dev/null | head -4 || true
|
|
||||||
docker --version
|
docker --version
|
||||||
docker buildx version || echo "buildx 未安装"
|
docker info 2>/dev/null | sed -n '/Registry Mirrors/,/^$/p' || true
|
||||||
|
|
||||||
- name: 检出代码
|
- name: 检出代码
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
|
REPO_URL="https://${GITEA_HOST}/${GITHUB_REPOSITORY}.git"
|
||||||
BRANCH="${GITHUB_REF_NAME:-main}"
|
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 clone --depth 1 -b "${BRANCH}" "${REPO_URL}" .
|
||||||
git rev-parse --short HEAD
|
|
||||||
docker version
|
- name: 注入 OAuth secrets(构建期 build-arg)
|
||||||
docker buildx version || echo "buildx 未安装,将使用 docker build --push"
|
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: 计算镜像标签
|
- name: 计算镜像标签
|
||||||
id: meta
|
id: meta
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# Docker Registry 规范:仓库路径、owner、repo name 必须全小写(Henry_Du/BYW-VoiceHub → henry_du/byw-voicehub)
|
|
||||||
REPO_OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
REPO_OWNER="${GITHUB_REPOSITORY_OWNER,,}"
|
||||||
REPO_NAME="${IMAGE_NAME,,}"
|
REPO_NAME="${IMAGE_NAME,,}"
|
||||||
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
|
SHA="${GITHUB_SHA:-$(git rev-parse HEAD)}"
|
||||||
@@ -299,114 +115,115 @@ jobs:
|
|||||||
DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}"
|
DATE_TAG="$(date +%Y%m%d)-${SHORT_SHA}"
|
||||||
IMG_FULL="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:${DATE_TAG}"
|
IMG_FULL="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:${DATE_TAG}"
|
||||||
IMG_LATEST="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:latest"
|
IMG_LATEST="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:latest"
|
||||||
CACHE_REF_INNER="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:buildcache"
|
CACHE_REF="${GITEA_HOST}/${REPO_OWNER}/${REPO_NAME}:${CACHE_TAG}"
|
||||||
echo "tag=${DATE_TAG}" >> "$GITHUB_OUTPUT"
|
{
|
||||||
echo "full=${IMG_FULL}" >> "$GITHUB_OUTPUT"
|
echo "image_tag=${DATE_TAG}"
|
||||||
echo "latest=${IMG_LATEST}" >> "$GITHUB_OUTPUT"
|
echo "image_full=${IMG_FULL}"
|
||||||
echo "cache_ref=${CACHE_REF_INNER}" >> "$GITHUB_OUTPUT"
|
echo "image_latest=${IMG_LATEST}"
|
||||||
echo "repo_owner=${REPO_OWNER}" >> "$GITHUB_OUTPUT"
|
echo "cache_ref=${CACHE_REF}"
|
||||||
echo "repo_name=${REPO_NAME}" >> "$GITHUB_OUTPUT"
|
} >> "$GITHUB_OUTPUT"
|
||||||
echo "======================================"
|
|
||||||
echo "Image tag : ${DATE_TAG}"
|
echo "Image tag : ${DATE_TAG}"
|
||||||
echo "Full image : ${IMG_FULL}"
|
echo "Full image : ${IMG_FULL}"
|
||||||
echo "Latest image : ${IMG_LATEST}"
|
echo "Latest image : ${IMG_LATEST}"
|
||||||
echo "Cache ref : ${CACHE_REF_INNER}"
|
echo "Cache ref : ${CACHE_REF}"
|
||||||
echo "======================================"
|
|
||||||
|
|
||||||
- name: 初始化 Docker Buildx builder(带国内 registry-mirrors + 内置 Gitea Basic Auth)
|
- name: 安装 BuildKit builder(可选)+ 登录 registry
|
||||||
|
id: buildx
|
||||||
env:
|
env:
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
|
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
|
||||||
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
|
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN || github.token }}
|
||||||
|
REGISTRY_PULL_USER: ${{ secrets.REGISTRY_PULL_USER }}
|
||||||
|
REGISTRY_PULL_TOKEN: ${{ secrets.REGISTRY_PULL_TOKEN }}
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
# 先校验凭据非空(避免 buildkitd.toml 写空用户名/密码导致 push 时 401)
|
REGISTRY_USER="${REGISTRY_USER,,}"
|
||||||
if [ -z "${REGISTRY_TOKEN}" ]; then
|
|
||||||
echo "ERROR: REGISTRY_TOKEN 为空。请在 Repository Secrets 中新建 REGISTRY_TOKEN(个人应用令牌,权限 read:package + write:package + read:repository)"
|
# 兜底登录凭证: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
|
exit 1
|
||||||
fi
|
fi
|
||||||
REGISTRY_USER="${REGISTRY_USER,,}"
|
|
||||||
BUILDER_NAME="multi"
|
# 先把 buildkitd 基础镜像拉到本地(多 mirror 轮询),装完插件后直接复用
|
||||||
BUILDKIT_IMAGE="moby/buildkit:buildx-stable-1"
|
for M in \
|
||||||
BKK_CFG="$(mktemp -t buildkitd_XXXXXX.toml)"
|
"${DOCKER_REGISTRY_MIRROR_1#https://}/${BUILDKIT_IMAGE}" \
|
||||||
# 用 printf 逐行写 buildkitd 配置,避免 YAML parser 把 heredoc 内部的 [...] 误判为 YAML key
|
"${DOCKER_REGISTRY_MIRROR_2#https://}/${BUILDKIT_IMAGE}" \
|
||||||
{
|
"${DOCKER_REGISTRY_MIRROR_3#https://}/${BUILDKIT_IMAGE}" \
|
||||||
printf '[worker.oci]\n max-parallelism = 2\n\n'
|
|
||||||
printf '[registry."docker.io"]\n mirrors = ["docker.m.daocloud.io","dockerproxy.com","hub-mirror.c.163.com","docker.nju.edu.cn","docker.mirrors.ustc.edu.cn"]\n\n'
|
|
||||||
printf '[registry."ghcr.io"]\n mirrors = ["ghcr.nju.edu.cn","ghcr.dockerproxy.com"]\n\n'
|
|
||||||
printf '[registry."k8s.gcr.io"]\n mirrors = ["k8s-gcr.m.daocloud.io","registry.cn-hangzhou.aliyuncs.com/google_containers"]\n\n'
|
|
||||||
# 关键:Gitea Container Registry 直接用 Basic Auth 凭据写死在 buildkitd.toml
|
|
||||||
# 绕开 buildkitd 的 OAuth2 flow(Gitea 1.27.x 有时返回空 token body:authorization server did not include a token)
|
|
||||||
printf '[registry."%s"]\n username = "%s"\n password = "%s"\n' "${GITEA_HOST}" "${REGISTRY_USER}" "${REGISTRY_TOKEN}"
|
|
||||||
} > "${BKK_CFG}"
|
|
||||||
# 预拉 buildkitd 基础镜像(优先 5 个国内镜像 fallback,避免 Docker Hub <200KB/s)
|
|
||||||
PULLED=0
|
|
||||||
for MIRROR in \
|
|
||||||
"docker.m.daocloud.io/${BUILDKIT_IMAGE}" \
|
|
||||||
"dockerproxy.com/${BUILDKIT_IMAGE}" \
|
|
||||||
"hub-mirror.c.163.com/${BUILDKIT_IMAGE}" \
|
|
||||||
"docker.nju.edu.cn/${BUILDKIT_IMAGE}" \
|
|
||||||
"docker.mirrors.ustc.edu.cn/${BUILDKIT_IMAGE}" \
|
|
||||||
"${BUILDKIT_IMAGE}"; do
|
"${BUILDKIT_IMAGE}"; do
|
||||||
if docker pull "${MIRROR}" 2>/dev/null; then
|
if docker pull "${M}" 2>/dev/null; then
|
||||||
if [ "${MIRROR}" != "${BUILDKIT_IMAGE}" ]; then
|
[ "${M}" = "${BUILDKIT_IMAGE}" ] || docker tag "${M}" "${BUILDKIT_IMAGE}" 2>/dev/null || true
|
||||||
docker tag "${MIRROR}" "${BUILDKIT_IMAGE}" 2>/dev/null || true
|
|
||||||
fi
|
|
||||||
PULLED=1
|
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
done
|
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)
|
printf '%s' "${LOGIN_TOKEN}" | docker login "${GITEA_HOST}" -u "${LOGIN_USER}" --password-stdin
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
# buildx 插件不是必须的:没有就走普通 docker build(仍是同一份 Dockerfile,
|
||||||
# 前面「初始化 Docker Buildx builder」Step 已完成两次登录:
|
# 只是没有跨 run 的 registry 构建缓存)。绝不因为装不上而让部署失败。
|
||||||
# 1. 宿主 Docker daemon(镜像体积 Step 要用);2. buildkitd.toml(buildx push 要用,Basic Auth 不走 OAuth2)
|
if docker buildx version >/dev/null 2>&1 \
|
||||||
# 这里只做一个快速断言,确保宿主登录成功
|
&& docker buildx create --help 2>/dev/null | grep -q -- '--name'; then
|
||||||
docker info 2>/dev/null | grep -E "Username|Registry" || echo "已存在登录凭据(跳过详细输出)"
|
echo "[buildx] 插件已可用:$(docker buildx version 2>&1 | head -1)"
|
||||||
echo "登录确认完成,REGISTRY_USER=${REGISTRY_USER:-${GITHUB_ACTOR,,}},GITEA_HOST=${GITEA_HOST}"
|
else
|
||||||
env:
|
echo "[buildx] 未发现可用插件,尝试安装官方 buildx"
|
||||||
REGISTRY_USER: ${{ secrets.REGISTRY_USER || github.actor }}
|
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: 构建并推送镜像
|
- name: 构建并推送镜像
|
||||||
env:
|
env:
|
||||||
SKIP_CACHE: ${{ inputs.skip_build_cache }}
|
SKIP_CACHE: ${{ inputs.skip_build_cache }}
|
||||||
IMG_FULL: ${{ steps.meta.outputs.full }}
|
IMG_FULL: ${{ steps.meta.outputs.image_full }}
|
||||||
IMG_LATEST: ${{ steps.meta.outputs.latest }}
|
IMG_LATEST: ${{ steps.meta.outputs.image_latest }}
|
||||||
|
CACHE_REF: ${{ steps.meta.outputs.cache_ref }}
|
||||||
OCS_SOURCE_RAW: ${{ github.server_url }}/${{ github.repository }}
|
OCS_SOURCE_RAW: ${{ github.server_url }}/${{ github.repository }}
|
||||||
OCS_REVISION: ${{ github.sha }}
|
OCS_REVISION: ${{ github.sha }}
|
||||||
OCS_CREATED_RAW: ${{ github.event.head_commit.timestamp }}
|
OCS_CREATED_RAW: ${{ github.event.head_commit.timestamp }}
|
||||||
run: |
|
run: |
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
# 兜底空值 & 全小写规范
|
|
||||||
OCS_SOURCE="${OCS_SOURCE_RAW,,}"
|
OCS_SOURCE="${OCS_SOURCE_RAW,,}"
|
||||||
OCS_CREATED="${OCS_CREATED_RAW:-$(date -u -Iseconds)}"
|
OCS_CREATED="${OCS_CREATED_RAW:-$(date -u -Iseconds)}"
|
||||||
# 再次保险:所有镜像标签字符串都确认无大写字母(Registry 硬性规范)
|
|
||||||
IMG_FULL="${IMG_FULL,,}"
|
IMG_FULL="${IMG_FULL,,}"
|
||||||
IMG_LATEST="${IMG_LATEST,,}"
|
IMG_LATEST="${IMG_LATEST,,}"
|
||||||
|
CACHE_REF="${CACHE_REF,,}"
|
||||||
|
|
||||||
BUILD_ARGS=(
|
BUILD_ARGS=(
|
||||||
--build-arg BUILDKIT_INLINE_CACHE=1
|
--build-arg BUILDKIT_INLINE_CACHE=1
|
||||||
@@ -415,55 +232,67 @@ jobs:
|
|||||||
--build-arg NODE_BUILDER_TAG="${NODE_BUILDER_TAG}"
|
--build-arg NODE_BUILDER_TAG="${NODE_BUILDER_TAG}"
|
||||||
--build-arg NODE_RUNTIME_TAG="${NODE_RUNTIME_TAG}"
|
--build-arg NODE_RUNTIME_TAG="${NODE_RUNTIME_TAG}"
|
||||||
)
|
)
|
||||||
# 说明:暂时关闭 registry 分层缓存(buildkitd 推 buildcache 也要认证,同样会触发 Gitea 1.27 OAuth2 空 token)
|
for V in OAUTH_REDIRECT_URI OAUTH_STATE_SECRET AGGREGATE_OAUTH_APP_ID \
|
||||||
# 改为本地 Docker daemon 缓存,已构建的层会自动复用,下次 build 速度几乎相同
|
AGGREGATE_OAUTH_APP_KEY AGGREGATE_OAUTH_LOGIN_TYPE AGGREGATE_OAUTH_ENDPOINT; do
|
||||||
BUILD_ARGS+=( --no-cache )
|
eval "VAL=\${${V}:-}"
|
||||||
echo "已启用强制重新构建(跳过 registry 分层缓存,避免 buildkitd 推 cache 层时 OAuth2 认证失败)"
|
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) ==========
|
TAGS=( --tag "${IMG_FULL}" --tag "${IMG_LATEST}" )
|
||||||
# 关键:这一步产物在宿主 Docker 的 images 列表里(docker images 能看到),宿主 docker login 过的凭据 100% 可用
|
LABELS=(
|
||||||
# 完全绕开 buildkitd 容器内部推镜像走 OAuth2 flow 的坑
|
--label "org.opencontainers.image.source=${OCS_SOURCE}"
|
||||||
docker buildx build \
|
--label "org.opencontainers.image.revision=${OCS_REVISION}"
|
||||||
--file ./Dockerfile \
|
--label "org.opencontainers.image.created=${OCS_CREATED}"
|
||||||
--platform linux/amd64 \
|
)
|
||||||
--load \
|
|
||||||
--provenance=false \
|
if [ "${BUILDX_USABLE:-no}" = "yes" ]; then
|
||||||
"${BUILD_ARGS[@]}" \
|
echo "[build] docker buildx build(含 registry 缓存)"
|
||||||
--tag "${IMG_FULL}" \
|
CACHE_ARGS=()
|
||||||
--tag "${IMG_LATEST}" \
|
if [ "${SKIP_CACHE}" = "true" ]; then
|
||||||
--label "org.opencontainers.image.source=${OCS_SOURCE}" \
|
echo "[build] skip_build_cache=true → 不使用缓存,仅更新缓存"
|
||||||
--label "org.opencontainers.image.revision=${OCS_REVISION}" \
|
else
|
||||||
--label "org.opencontainers.image.created=${OCS_CREATED}" \
|
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_FULL}" >/dev/null
|
||||||
docker image inspect "${IMG_LATEST}" >/dev/null
|
docker image inspect "${IMG_LATEST}" >/dev/null
|
||||||
echo "构建成功,已加载到本地 Docker daemon:${IMG_FULL} + ${IMG_LATEST}"
|
docker image inspect "${IMG_FULL}" --format='{{.Size}}' \
|
||||||
echo "镜像体积(Pre-push):"
|
| awk '{printf "镜像体积:%.2f MB\n", $1/1024/1024}'
|
||||||
docker image inspect "${IMG_FULL}" --format='{{.Size}}' | awk '{printf " Full image : %.2f MB\n", $1/1024/1024}'
|
|
||||||
|
|
||||||
# ========== 阶段 2:宿主 Docker daemon 原生 push(100% 走宿主登录凭据,不会走 OAuth2) ==========
|
|
||||||
echo "开始推送:${IMG_FULL}"
|
|
||||||
docker push "${IMG_FULL}"
|
docker push "${IMG_FULL}"
|
||||||
echo "开始推送:${IMG_LATEST}"
|
|
||||||
docker push "${IMG_LATEST}"
|
docker push "${IMG_LATEST}"
|
||||||
echo "推送完成:2 个 tag(date-sha + latest)"
|
echo "推送完成:${IMG_FULL} + ${IMG_LATEST}"
|
||||||
|
|
||||||
- name: 镜像体积与层信息
|
|
||||||
env:
|
|
||||||
IMG_FULL: ${{ steps.meta.outputs.full }}
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
docker pull "${IMG_FULL}" || true
|
|
||||||
docker image inspect "${IMG_FULL}" --format='{{.Size}}' 2>/dev/null \
|
|
||||||
| awk '{printf "Image size: %.2f MB\n", $1/1024/1024}' || true
|
|
||||||
docker history --no-trunc "${IMG_FULL}" 2>/dev/null | head -20 || true
|
|
||||||
|
|
||||||
deploy:
|
deploy:
|
||||||
name: 部署到生产服务器
|
name: 部署到生产服务器
|
||||||
needs: build-image
|
needs: [build-image]
|
||||||
if: ${{ !cancelled() && !failure() && (github.event_name == 'push' || inputs.deploy_target == true) }}
|
if: needs.build-image.result == 'success' && (github.event_name == 'push' || inputs.deploy_target == true)
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
timeout-minutes: 25
|
timeout-minutes: 25
|
||||||
environment:
|
environment:
|
||||||
@@ -483,7 +312,7 @@ jobs:
|
|||||||
[ -n "${DEPLOY_SSH_KEY}" ] || MISSING+=("DEPLOY_SSH_KEY")
|
[ -n "${DEPLOY_SSH_KEY}" ] || MISSING+=("DEPLOY_SSH_KEY")
|
||||||
if [ ${#MISSING[@]} -gt 0 ]; then
|
if [ ${#MISSING[@]} -gt 0 ]; then
|
||||||
echo "缺少以下 Repository Secrets:${MISSING[*]}"
|
echo "缺少以下 Repository Secrets:${MISSING[*]}"
|
||||||
echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加(注意是 Repository Secrets,不是 Deploy Keys)"
|
echo "请在 Gitea 仓库 -> 设置 -> 工作流 -> 密钥 页面添加"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
echo "部署密钥配置完整(DEPLOY_HOST=${DEPLOY_HOST},DEPLOY_USER=${DEPLOY_USER})"
|
echo "部署密钥配置完整(DEPLOY_HOST=${DEPLOY_HOST},DEPLOY_USER=${DEPLOY_USER})"
|
||||||
@@ -502,9 +331,19 @@ jobs:
|
|||||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||||
GITHUB_REPOSITORY: ${{ github.repository }}
|
GITHUB_REPOSITORY: ${{ github.repository }}
|
||||||
GITHUB_REF_NAME: ${{ github.ref_name }}
|
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: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
# 全小写规范(Docker Registry 硬性要求 + Gitea raw URL 路径大小写敏感)
|
|
||||||
IMAGE_NEW="${IMAGE_NEW,,}"
|
IMAGE_NEW="${IMAGE_NEW,,}"
|
||||||
IMAGE_LATEST="${IMAGE_LATEST,,}"
|
IMAGE_LATEST="${IMAGE_LATEST,,}"
|
||||||
GITHUB_REPOSITORY="${GITHUB_REPOSITORY,,}"
|
GITHUB_REPOSITORY="${GITHUB_REPOSITORY,,}"
|
||||||
@@ -512,7 +351,6 @@ jobs:
|
|||||||
GITEA_HOST="${GITEA_HOST,,}"
|
GITEA_HOST="${GITEA_HOST,,}"
|
||||||
IMAGE_NAME="${IMAGE_NAME,,}"
|
IMAGE_NAME="${IMAGE_NAME,,}"
|
||||||
|
|
||||||
# 写私钥到临时文件(避免 stdin 转义问题)
|
|
||||||
SSH_KEY_FILE="$(mktemp -t deploy_ssh_key.XXXXXX)"
|
SSH_KEY_FILE="$(mktemp -t deploy_ssh_key.XXXXXX)"
|
||||||
printf '%s\n' "${DEPLOY_SSH_KEY}" > "${SSH_KEY_FILE}"
|
printf '%s\n' "${DEPLOY_SSH_KEY}" > "${SSH_KEY_FILE}"
|
||||||
chmod 600 "${SSH_KEY_FILE}"
|
chmod 600 "${SSH_KEY_FILE}"
|
||||||
@@ -527,34 +365,31 @@ jobs:
|
|||||||
-p "${DEPLOY_PORT}"
|
-p "${DEPLOY_PORT}"
|
||||||
)
|
)
|
||||||
|
|
||||||
echo "=============================================="
|
|
||||||
echo "Deploy target: ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PORT}"
|
echo "Deploy target: ${DEPLOY_USER}@${DEPLOY_HOST}:${DEPLOY_PORT}"
|
||||||
echo "Deploy image : ${IMAGE_NEW}"
|
echo "Deploy image : ${IMAGE_NEW}"
|
||||||
echo "Deploy dir : ${DEPLOY_PATH}"
|
echo "Deploy dir : ${DEPLOY_PATH}"
|
||||||
echo "=============================================="
|
|
||||||
|
|
||||||
# 本地预检查:ssh 先连上跑 whoami 确认连通,避免 20 分钟后才发现连不上
|
|
||||||
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "whoami; docker --version; docker compose version"
|
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_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}" \
|
"${IMAGE_NEW}" "${IMAGE_LATEST}" "${DEPLOY_PATH}" "${GITEA_HOST}" "${GH_PROXY_PREFIX}" \
|
||||||
"${REGISTRY_PULL_USER}" "${REGISTRY_PULL_TOKEN}" "${IMAGE_NAME}" "${NUXT_PUBLIC_SITE_URL}" \
|
"${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}"
|
printf '%s' "${ENV_PAYLOAD}" | ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "cat > ${ENV_SH}"
|
||||||
echo "[Deploy] env script written to ${ENV_SH} on remote"
|
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 "${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'
|
ssh "${SSH_OPTS[@]}" "${DEPLOY_USER}@${DEPLOY_HOST}" "bash -s -- '${ENV_SH}'" <<'REMOTE_SCRIPT_MAIN'
|
||||||
set -euxo pipefail
|
set -euxo pipefail
|
||||||
ENV_SH="$1"
|
ENV_SH="$1"
|
||||||
if [ -f "$ENV_SH" ]; then
|
if [ -f "$ENV_SH" ]; then
|
||||||
# shellcheck disable=SC1090
|
|
||||||
. "$ENV_SH"
|
. "$ENV_SH"
|
||||||
rm -f "$ENV_SH"
|
rm -f "$ENV_SH"
|
||||||
fi
|
fi
|
||||||
@@ -562,80 +397,104 @@ jobs:
|
|||||||
DEPLOY_DIR="${DEPLOY_PATH}"
|
DEPLOY_DIR="${DEPLOY_PATH}"
|
||||||
COMPOSE="docker compose"
|
COMPOSE="docker compose"
|
||||||
|
|
||||||
echo "=============================================="
|
|
||||||
echo "Executor : $(whoami)"
|
echo "Executor : $(whoami)"
|
||||||
echo "IMAGE_NEW : ${IMAGE_NEW:-(empty!)}"
|
echo "IMAGE_NEW : ${IMAGE_NEW:-(empty!)}"
|
||||||
echo "DEPLOY_DIR : ${DEPLOY_DIR:-(empty!)}"
|
echo "DEPLOY_DIR : ${DEPLOY_DIR:-(empty!)}"
|
||||||
echo "=============================================="
|
|
||||||
# 防御性断言:镜像名 / 路径不能为空,否则直接 exit 9 避免起旧容器
|
|
||||||
if [ -z "${IMAGE_NEW}" ] || [ -z "${DEPLOY_DIR}" ]; then
|
if [ -z "${IMAGE_NEW}" ] || [ -z "${DEPLOY_DIR}" ]; then
|
||||||
echo "ERROR: IMAGE_NEW or DEPLOY_DIR is empty — env variables not propagated correctly."
|
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
|
exit 9
|
||||||
fi
|
fi
|
||||||
|
|
||||||
mkdir -p "${DEPLOY_DIR}" 2>/dev/null || sudo mkdir -p "${DEPLOY_DIR}" 2>/dev/null || true
|
mkdir -p "${DEPLOY_DIR}" 2>/dev/null || sudo mkdir -p "${DEPLOY_DIR}" 2>/dev/null || true
|
||||||
cd "${DEPLOY_DIR}"
|
cd "${DEPLOY_DIR}"
|
||||||
|
|
||||||
# ========== 目录可写性预检查(权限排查友好化) ==========
|
|
||||||
TEST_FILE="${DEPLOY_DIR}/.deploy_writable_test_$$"
|
TEST_FILE="${DEPLOY_DIR}/.deploy_writable_test_$$"
|
||||||
if ! (touch "${TEST_FILE}" && rm -f "${TEST_FILE}") 2>/dev/null; then
|
if ! (touch "${TEST_FILE}" && rm -f "${TEST_FILE}") 2>/dev/null; then
|
||||||
echo "================================================================="
|
|
||||||
echo "ERROR:deploy 用户对部署目录 ${DEPLOY_DIR} 没有写权限(Permission denied)"
|
echo "ERROR:deploy 用户对部署目录 ${DEPLOY_DIR} 没有写权限(Permission denied)"
|
||||||
echo "当前用户:$(whoami)"
|
echo "当前用户:$(whoami)"
|
||||||
echo "目录归属与权限:"
|
|
||||||
ls -ld "${DEPLOY_DIR}" || true
|
ls -ld "${DEPLOY_DIR}" || true
|
||||||
echo ""
|
|
||||||
echo "请在服务器上以 root 身份执行下面 3 行命令修复(复制粘贴):"
|
echo "请在服务器上以 root 身份执行下面 3 行命令修复(复制粘贴):"
|
||||||
echo " chown -R deploy:docker '${DEPLOY_DIR}'"
|
echo " chown -R deploy:docker '${DEPLOY_DIR}'"
|
||||||
echo " chmod 775 '${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 " 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 "修复后重新触发此 workflow 即可。"
|
||||||
echo "================================================================="
|
|
||||||
exit 3
|
exit 3
|
||||||
fi
|
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
|
if [ -n "${REGISTRY_PULL_TOKEN}" ] && [ -n "${REGISTRY_PULL_USER}" ]; then
|
||||||
echo "使用专用 pull 凭证登录 Gitea Container Registry"
|
|
||||||
printf '%s' "${REGISTRY_PULL_TOKEN}" \
|
printf '%s' "${REGISTRY_PULL_TOKEN}" \
|
||||||
| docker login "${GITEA_HOST}" \
|
| docker login "${GITEA_HOST}" -u "${REGISTRY_PULL_USER}" --password-stdin 2>/dev/null || true
|
||||||
-u "${REGISTRY_PULL_USER}" \
|
|
||||||
--password-stdin 2>/dev/null || true
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Pulling new image ${IMAGE_NEW} ..."
|
echo "Pulling new image ${IMAGE_NEW} ..."
|
||||||
docker pull "${IMAGE_NEW}"
|
docker pull "${IMAGE_NEW}"
|
||||||
docker tag "${IMAGE_NEW}" "${IMAGE_LATEST}"
|
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
|
printf 'services:\n voicehub:\n image: %s\n' "${IMAGE_NEW}" > docker-compose.override.yml
|
||||||
echo "docker-compose.override.yml written, image=${IMAGE_NEW}"
|
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
|
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"
|
TEMPLATE_URL="${GITHUB_SERVER_URL:-https://${GITEA_HOST}}/${GITHUB_REPOSITORY}/raw/branch/${GITHUB_REF_NAME:-main}/docker-compose.yml"
|
||||||
case "${TEMPLATE_URL}" in
|
case "${TEMPLATE_URL}" in
|
||||||
*"://github.com/"*) TEMPLATE_URL="${GH_PROXY_PREFIX}${TEMPLATE_URL}" ;;
|
*"://github.com/"*) TEMPLATE_URL="${GH_PROXY_PREFIX}${TEMPLATE_URL}" ;;
|
||||||
esac
|
esac
|
||||||
echo "URL=${TEMPLATE_URL}"
|
|
||||||
curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
|
curl -fsSL --max-time 30 "${TEMPLATE_URL}" -o docker-compose.yml \
|
||||||
|| echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env(参考仓库根目录 .env.example)"
|
|| echo "自动下载模板失败,请手动上传 docker-compose.yml 和 .env"
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [ ! -f .env ]; then
|
if [ ! -f .env ]; then
|
||||||
echo "================================================================="
|
echo "WARNING:${DEPLOY_DIR}/.env 不存在(首次部署请参考仓库根目录 .env.example 创建)"
|
||||||
echo "WARNING:${DEPLOY_DIR}/.env 不存在"
|
|
||||||
echo "首次部署请在服务器上创建 .env(至少填写 POSTGRES_PASSWORD / JWT_SECRET / NUXT_PUBLIC_SITE_URL + 5 个 cpus 数字兜底:APP_CPUS/APP_CPUS_LIMIT/APP_CPUS_RESERVATION/PG_CPUS/REDIS_CPUS)"
|
|
||||||
echo "参考仓库根目录的 .env.example"
|
|
||||||
echo "================================================================="
|
|
||||||
fi
|
fi
|
||||||
|
|
||||||
echo "Executing docker compose up -d ..."
|
echo "Executing docker compose up -d ..."
|
||||||
@@ -650,12 +509,7 @@ jobs:
|
|||||||
echo "voicehub healthcheck passed"
|
echo "voicehub healthcheck passed"
|
||||||
break
|
break
|
||||||
fi
|
fi
|
||||||
if echo "${STATUS}" | grep -q '"State":"running"'; then
|
echo "${STATUS}" | grep -q '"State":"running"' || ${COMPOSE} ps || true
|
||||||
:
|
|
||||||
else
|
|
||||||
echo "voicehub not running, current status:"
|
|
||||||
${COMPOSE} ps || true
|
|
||||||
fi
|
|
||||||
sleep 5
|
sleep 5
|
||||||
WAITED=$((WAITED + 1))
|
WAITED=$((WAITED + 1))
|
||||||
done
|
done
|
||||||
@@ -669,16 +523,8 @@ jobs:
|
|||||||
docker image prune -af --filter "until=72h" || true
|
docker image prune -af --filter "until=72h" || true
|
||||||
docker builder prune -af || true
|
docker builder prune -af || true
|
||||||
|
|
||||||
echo ""
|
|
||||||
echo "================================================================="
|
|
||||||
echo "Deploy succeeded. Visit: ${NUXT_PUBLIC_SITE_URL:-请自行查看你的站点 URL}"
|
echo "Deploy succeeded. Visit: ${NUXT_PUBLIC_SITE_URL:-请自行查看你的站点 URL}"
|
||||||
echo "Rollback instructions (on server):"
|
echo "Rollback: cd ${DEPLOY_DIR} && printf 'services:\\n voicehub:\\n image: <OLD_TAG>\\n' > docker-compose.override.yml && docker compose up -d"
|
||||||
echo " cd ${DEPLOY_DIR}"
|
|
||||||
echo " docker images | grep ${IMAGE_NAME} | head -5"
|
|
||||||
echo " printf 'services:\\n voicehub:\\n image: <OLD_TAG_FROM_ABOVE>\\n' > docker-compose.override.yml"
|
|
||||||
echo " docker compose up -d"
|
|
||||||
echo "================================================================="
|
|
||||||
REMOTE_SCRIPT_MAIN
|
REMOTE_SCRIPT_MAIN
|
||||||
|
|
||||||
# 清理私钥临时文件
|
|
||||||
rm -f "${SSH_KEY_FILE}"
|
rm -f "${SSH_KEY_FILE}"
|
||||||
|
|||||||
+17
@@ -37,6 +37,23 @@ RUN set -eux; \
|
|||||||
# 只有在 package.json 没变而源代码改变时,才会从这一步开始重新执行,跳过上面的耗时依赖安装
|
# 只有在 package.json 没变而源代码改变时,才会从这一步开始重新执行,跳过上面的耗时依赖安装
|
||||||
COPY . .
|
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总内存
|
# 构建应用,使用内存优化脚本适应4GB总内存
|
||||||
RUN chmod +x scripts/memory-build.js && \
|
RUN chmod +x scripts/memory-build.js && \
|
||||||
node scripts/memory-build.js
|
node scripts/memory-build.js
|
||||||
|
|||||||
+338
-5
@@ -5,9 +5,9 @@
|
|||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
|
|
||||||
/* 防止浏览器自动应用深色主题 */
|
/* 防止浏览器自动应用深色主题:改为跟随 data-theme 动态覆盖(见 theme-protection.css) */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 流体背景与全局样式 */
|
/* 流体背景与全局样式 */
|
||||||
@@ -82,9 +82,9 @@ body::before {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background:
|
background:
|
||||||
radial-gradient(circle at 20% 50%, rgba(120, 119, 198, 0.03) 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(255, 255, 255, 0.01) 0%, transparent 50%),
|
radial-gradient(circle at 80% 20%, rgba(14, 165, 233, 0.025) 0%, transparent 55%),
|
||||||
radial-gradient(circle at 40% 80%, rgba(120, 119, 198, 0.02) 0%, transparent 50%);
|
radial-gradient(circle at 40% 80%, rgba(79, 70, 229, 0.02) 0%, transparent 55%);
|
||||||
z-index: -1;
|
z-index: -1;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -472,3 +472,336 @@ body::before {
|
|||||||
max-width: none !important;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -97,18 +97,29 @@
|
|||||||
font-size: 14px !important;
|
font-size: 14px !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 封面 */
|
/* 封面 - 打印模式下固定尺寸,移除圆角阴影,使用 contain 防止裁切 */
|
||||||
.schedule-item-print .cover-section {
|
.schedule-item-print .cover-section {
|
||||||
width: 40px !important;
|
width: 48px !important;
|
||||||
height: 40px !important;
|
height: 48px !important;
|
||||||
|
aspect-ratio: 1 / 1 !important;
|
||||||
flex-shrink: 0 !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 {
|
.schedule-item-print .cover-placeholder {
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
height: 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 歌曲信息 */
|
/* 歌曲信息 */
|
||||||
|
|||||||
@@ -1,12 +1,27 @@
|
|||||||
/* 温和的浏览器主题保护样式 */
|
/* 温和的浏览器主题保护样式 */
|
||||||
/* 只保护关键元素,不干扰正常的CSS */
|
/* 只保护关键元素,不干扰正常的CSS,并且根据 data-theme 动态切换 */
|
||||||
|
|
||||||
/* 全局颜色方案声明 */
|
/* 全局颜色方案声明:移动端优先遵循 data-theme,其次才用系统色 */
|
||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
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='text'],
|
||||||
input[type='email'],
|
input[type='email'],
|
||||||
input[type='password'],
|
input[type='password'],
|
||||||
@@ -17,16 +32,39 @@ input[type='number'],
|
|||||||
input[type='date'],
|
input[type='date'],
|
||||||
input[type='time'],
|
input[type='time'],
|
||||||
input[type='datetime-local'],
|
input[type='datetime-local'],
|
||||||
|
input[type='month'],
|
||||||
|
input[type='week'],
|
||||||
textarea,
|
textarea,
|
||||||
select {
|
select {
|
||||||
/* 禁用浏览器默认样式 */
|
/* 禁用浏览器默认样式(保留我们自定义的背景色与文字颜色) */
|
||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
-moz-appearance: none;
|
-moz-appearance: none;
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
/* 在移动端根据主题继承 color-scheme,使原生滚动条/弹出控件正确变深色或浅色 */
|
||||||
|
color-scheme: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
/* 禁用浏览器的颜色方案自动调整 */
|
/* 深色主题下专门修复表单控件与浏览器原生 UI 的颜色方案 */
|
||||||
color-scheme: none;
|
@media (prefers-color-scheme: dark) {
|
||||||
-webkit-color-scheme: none;
|
: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;
|
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) {
|
@media (prefers-contrast: high) {
|
||||||
input,
|
input,
|
||||||
|
|||||||
+283
-96
@@ -1,69 +1,59 @@
|
|||||||
/* 全局CSS变量 - 统一配色方案 */
|
/* 全局CSS变量 - 统一配色方案 */
|
||||||
|
/* SSR-safe 默认值 = ModernLight 浅色,防止 hydration 前出现深色闪屏 */
|
||||||
:root {
|
:root {
|
||||||
/* 主色调 */
|
--primary: #4f46e5;
|
||||||
--primary: #3b82f6;
|
--primary-hover: #4338ca;
|
||||||
--primary-hover: #2563eb;
|
--primary-light: rgba(79, 70, 229, 0.1);
|
||||||
--primary-light: rgba(59, 130, 246, 0.1);
|
--primary-border: rgba(79, 70, 229, 0.2);
|
||||||
--primary-border: rgba(59, 130, 246, 0.2);
|
|
||||||
|
|
||||||
/* 成功色 */
|
--success: #16a34a;
|
||||||
--success: #10b981;
|
--success-hover: #15803d;
|
||||||
--success-hover: #059669;
|
--success-light: rgba(22, 163, 74, 0.1);
|
||||||
--success-light: rgba(16, 185, 129, 0.1);
|
--success-border: rgba(22, 163, 74, 0.2);
|
||||||
--success-border: rgba(16, 185, 129, 0.2);
|
|
||||||
|
|
||||||
/* 警告色 */
|
--warning: #ea580c;
|
||||||
--warning: #f59e0b;
|
--warning-hover: #c2410c;
|
||||||
--warning-hover: #d97706;
|
--warning-light: rgba(234, 88, 12, 0.1);
|
||||||
--warning-light: rgba(245, 158, 11, 0.1);
|
--warning-border: rgba(234, 88, 12, 0.2);
|
||||||
--warning-border: rgba(245, 158, 11, 0.2);
|
|
||||||
|
|
||||||
/* 错误色 */
|
--error: #e11d48;
|
||||||
--error: #ef4444;
|
--error-hover: #be123c;
|
||||||
--error-hover: #dc2626;
|
--error-light: rgba(225, 29, 72, 0.1);
|
||||||
--error-light: rgba(239, 68, 68, 0.1);
|
--error-border: rgba(225, 29, 72, 0.2);
|
||||||
--error-border: rgba(239, 68, 68, 0.2);
|
|
||||||
|
|
||||||
/* 信息色 */
|
--info: #0ea5e9;
|
||||||
--info: #3b82f6;
|
--info-hover: #0284c7;
|
||||||
--info-hover: #2563eb;
|
--info-light: rgba(14, 165, 233, 0.1);
|
||||||
--info-light: rgba(59, 130, 246, 0.1);
|
--info-border: rgba(14, 165, 233, 0.2);
|
||||||
--info-border: rgba(59, 130, 246, 0.2);
|
|
||||||
|
|
||||||
/* 背景色 */
|
--bg-primary: #fafafa;
|
||||||
--bg-primary: #111111;
|
--bg-secondary: #f4f4f5;
|
||||||
--bg-secondary: #1a1a1a;
|
--bg-tertiary: #e4e4e7;
|
||||||
--bg-tertiary: #2a2a2a;
|
--bg-quaternary: #d4d4d8;
|
||||||
--bg-quaternary: #3a3a3a;
|
--bg-hover: #eeeeef;
|
||||||
--bg-hover: #1f1f1f;
|
|
||||||
|
|
||||||
/* 文字颜色 */
|
--text-primary: #09090b;
|
||||||
--text-primary: #ffffff;
|
--text-secondary: #3f3f46;
|
||||||
--text-secondary: #cccccc;
|
--text-tertiary: #71717a;
|
||||||
--text-tertiary: #888888;
|
--text-quaternary: #a1a1aa;
|
||||||
--text-quaternary: #666666;
|
--text-disabled: #d4d4d8;
|
||||||
--text-disabled: #444444;
|
|
||||||
|
|
||||||
/* 边框颜色 */
|
--border-primary: #e4e4e7;
|
||||||
--border-primary: #1f1f1f;
|
--border-secondary: #d4d4d8;
|
||||||
--border-secondary: #2a2a2a;
|
--border-tertiary: #a1a1aa;
|
||||||
--border-tertiary: #3a3a3a;
|
--border-quaternary: #71717a;
|
||||||
--border-quaternary: #4a4a4a;
|
|
||||||
|
|
||||||
/* 阴影 */
|
--shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.03);
|
||||||
--shadow-sm: 0 1px 2px rgba(0, 0, 0, 0.05);
|
--shadow-md: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.03);
|
||||||
--shadow-md: 0 4px 6px rgba(0, 0, 0, 0.1);
|
--shadow-lg: 0 8px 24px -4px rgba(0, 0, 0, 0.06), 0 2px 8px -4px rgba(0, 0, 0, 0.04);
|
||||||
--shadow-lg: 0 10px 15px rgba(0, 0, 0, 0.1);
|
--shadow-xl: 0 16px 40px -8px rgba(0, 0, 0, 0.08), 0 4px 16px -8px rgba(0, 0, 0, 0.05);
|
||||||
--shadow-xl: 0 20px 25px rgba(0, 0, 0, 0.1);
|
|
||||||
|
|
||||||
/* 圆角 */
|
|
||||||
--radius-sm: 4px;
|
--radius-sm: 4px;
|
||||||
--radius-md: 6px;
|
--radius-md: 6px;
|
||||||
--radius-lg: 8px;
|
--radius-lg: 8px;
|
||||||
--radius-xl: 12px;
|
--radius-xl: 10px;
|
||||||
--radius-2xl: 16px;
|
--radius-2xl: 12px;
|
||||||
|
|
||||||
/* 间距 */
|
|
||||||
--spacing-xs: 4px;
|
--spacing-xs: 4px;
|
||||||
--spacing-sm: 8px;
|
--spacing-sm: 8px;
|
||||||
--spacing-md: 12px;
|
--spacing-md: 12px;
|
||||||
@@ -72,7 +62,6 @@
|
|||||||
--spacing-2xl: 24px;
|
--spacing-2xl: 24px;
|
||||||
--spacing-3xl: 32px;
|
--spacing-3xl: 32px;
|
||||||
|
|
||||||
/* 字体大小 */
|
|
||||||
--text-xs: 12px;
|
--text-xs: 12px;
|
||||||
--text-sm: 14px;
|
--text-sm: 14px;
|
||||||
--text-base: 16px;
|
--text-base: 16px;
|
||||||
@@ -81,18 +70,15 @@
|
|||||||
--text-2xl: 24px;
|
--text-2xl: 24px;
|
||||||
--text-3xl: 30px;
|
--text-3xl: 30px;
|
||||||
|
|
||||||
/* 字体粗细 */
|
|
||||||
--font-normal: 400;
|
--font-normal: 400;
|
||||||
--font-medium: 500;
|
--font-medium: 500;
|
||||||
--font-semibold: 600;
|
--font-semibold: 600;
|
||||||
--font-bold: 700;
|
--font-bold: 700;
|
||||||
|
|
||||||
/* 过渡动画 */
|
|
||||||
--transition-fast: 0.15s ease;
|
--transition-fast: 0.15s ease;
|
||||||
--transition-normal: 0.2s ease;
|
--transition-normal: 0.2s ease;
|
||||||
--transition-slow: 0.3s ease;
|
--transition-slow: 0.3s ease;
|
||||||
|
|
||||||
/* Z-index层级 */
|
|
||||||
--z-dropdown: 100;
|
--z-dropdown: 100;
|
||||||
--z-sticky: 200;
|
--z-sticky: 200;
|
||||||
--z-fixed: 300;
|
--z-fixed: 300;
|
||||||
@@ -102,81 +88,72 @@
|
|||||||
--z-tooltip: 700;
|
--z-tooltip: 700;
|
||||||
--z-toast: 800;
|
--z-toast: 800;
|
||||||
|
|
||||||
/* 特殊状态颜色 */
|
--status-pending: #eab308;
|
||||||
--status-pending: #fbbf24;
|
--status-pending-light: rgba(234, 179, 8, 0.12);
|
||||||
--status-pending-light: rgba(251, 191, 36, 0.1);
|
--status-approved: #22c55e;
|
||||||
--status-approved: #10b981;
|
--status-approved-light: rgba(34, 197, 94, 0.12);
|
||||||
--status-approved-light: rgba(16, 185, 129, 0.1);
|
--status-played: #71717a;
|
||||||
--status-played: #6b7280;
|
--status-played-light: rgba(113, 113, 122, 0.12);
|
||||||
--status-played-light: rgba(107, 114, 128, 0.1);
|
|
||||||
|
|
||||||
/* 投票相关颜色 */
|
|
||||||
--vote-heart: #ef4444;
|
--vote-heart: #ef4444;
|
||||||
--vote-heart-hover: #dc2626;
|
--vote-heart-hover: #dc2626;
|
||||||
--vote-count: var(--text-tertiary);
|
--vote-count: var(--text-tertiary);
|
||||||
|
|
||||||
/* 拖拽状态颜色 */
|
|
||||||
--drag-over: var(--primary-light);
|
--drag-over: var(--primary-light);
|
||||||
--drag-over-border: var(--primary);
|
--drag-over-border: var(--primary);
|
||||||
--dragging-opacity: 0.5;
|
--dragging-opacity: 0.5;
|
||||||
|
|
||||||
/* 表格颜色 */
|
--table-header-bg: #f4f4f5;
|
||||||
--table-header-bg: var(--bg-tertiary);
|
--table-row-hover: #fafafa;
|
||||||
--table-row-hover: var(--bg-hover);
|
--table-border: #e4e4e7;
|
||||||
--table-border: var(--border-secondary);
|
--table-selected: rgba(79, 70, 229, 0.08);
|
||||||
--table-selected: var(--primary-light);
|
|
||||||
|
|
||||||
/* 卡片颜色 */
|
--card-bg: #ffffff;
|
||||||
--card-bg: var(--bg-secondary);
|
--card-border: #e4e4e7;
|
||||||
--card-border: var(--border-secondary);
|
--card-hover-border: #d4d4d8;
|
||||||
--card-hover-border: var(--border-tertiary);
|
--card-shadow: 0 2px 8px -2px rgba(0, 0, 0, 0.05), 0 1px 3px 0 rgba(0, 0, 0, 0.03);
|
||||||
--card-shadow: var(--shadow-md);
|
|
||||||
|
|
||||||
/* 按钮颜色 */
|
|
||||||
--btn-primary-bg: var(--primary);
|
--btn-primary-bg: var(--primary);
|
||||||
--btn-primary-hover: var(--primary-hover);
|
--btn-primary-hover: var(--primary-hover);
|
||||||
--btn-primary-text: var(--text-primary);
|
--btn-primary-text: #ffffff;
|
||||||
--btn-primary-border: var(--primary-border);
|
--btn-primary-border: var(--primary-border);
|
||||||
|
|
||||||
--btn-secondary-bg: #3a3a3a;
|
--btn-secondary-bg: #f4f4f5;
|
||||||
--btn-secondary-hover: #4a4a4a;
|
--btn-secondary-hover: #e4e4e7;
|
||||||
--btn-secondary-text: var(--text-primary);
|
--btn-secondary-text: #09090b;
|
||||||
--btn-secondary-border: #555555;
|
--btn-secondary-border: #d4d4d8;
|
||||||
|
|
||||||
--btn-success-bg: var(--success);
|
--btn-success-bg: var(--success);
|
||||||
--btn-success-hover: var(--success-hover);
|
--btn-success-hover: var(--success-hover);
|
||||||
--btn-success-text: var(--text-primary);
|
--btn-success-text: #ffffff;
|
||||||
--btn-success-border: var(--success-border);
|
--btn-success-border: var(--success-border);
|
||||||
|
|
||||||
--btn-warning-bg: var(--warning);
|
--btn-warning-bg: var(--warning);
|
||||||
--btn-warning-hover: var(--warning-hover);
|
--btn-warning-hover: var(--warning-hover);
|
||||||
--btn-warning-text: var(--text-primary);
|
--btn-warning-text: #ffffff;
|
||||||
--btn-warning-border: var(--warning-border);
|
--btn-warning-border: var(--warning-border);
|
||||||
|
|
||||||
--btn-error-bg: var(--error);
|
--btn-error-bg: var(--error);
|
||||||
--btn-error-hover: var(--error-hover);
|
--btn-error-hover: var(--error-hover);
|
||||||
--btn-error-text: var(--text-primary);
|
--btn-error-text: #ffffff;
|
||||||
--btn-error-border: var(--error-border);
|
--btn-error-border: var(--error-border);
|
||||||
|
|
||||||
--btn-disabled-bg: var(--bg-tertiary);
|
--btn-disabled-bg: var(--bg-tertiary);
|
||||||
--btn-disabled-text: var(--text-disabled);
|
--btn-disabled-text: var(--text-disabled);
|
||||||
|
|
||||||
/* 输入框颜色 */
|
--input-bg: #ffffff;
|
||||||
--input-bg: var(--bg-tertiary);
|
--input-border: #e4e4e7;
|
||||||
--input-border: var(--border-tertiary);
|
--input-border-focus: #4f46e5;
|
||||||
--input-border-focus: var(--primary);
|
--input-text: #09090b;
|
||||||
--input-text: var(--text-primary);
|
--input-placeholder: #a1a1aa;
|
||||||
--input-placeholder: var(--text-quaternary);
|
--input-shadow-focus: 0 0 0 3px rgba(79, 70, 229, 0.12);
|
||||||
--input-shadow-focus: 0 0 0 3px var(--primary-light);
|
|
||||||
|
|
||||||
/* 模态框颜色 */
|
--modal-backdrop: rgba(0, 0, 0, 0.4);
|
||||||
--modal-backdrop: rgba(0, 0, 0, 0.7);
|
|
||||||
--modal-bg: var(--bg-secondary);
|
--modal-bg: var(--bg-secondary);
|
||||||
--modal-border: var(--border-secondary);
|
--modal-border: var(--border-secondary);
|
||||||
--modal-header-border: var(--border-secondary);
|
--modal-header-border: var(--border-secondary);
|
||||||
--modal-footer-border: var(--border-secondary);
|
--modal-footer-border: var(--border-secondary);
|
||||||
|
|
||||||
/* 通知颜色 */
|
|
||||||
--notification-success-bg: var(--success-light);
|
--notification-success-bg: var(--success-light);
|
||||||
--notification-success-border: var(--success-border);
|
--notification-success-border: var(--success-border);
|
||||||
--notification-success-text: var(--success);
|
--notification-success-text: var(--success);
|
||||||
@@ -193,17 +170,56 @@
|
|||||||
--notification-info-border: var(--info-border);
|
--notification-info-border: var(--info-border);
|
||||||
--notification-info-text: var(--info);
|
--notification-info-text: var(--info);
|
||||||
|
|
||||||
/* 加载动画颜色 */
|
|
||||||
--spinner-primary: var(--primary);
|
--spinner-primary: var(--primary);
|
||||||
--spinner-secondary: var(--bg-tertiary);
|
--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-track: var(--bg-tertiary);
|
||||||
--scrollbar-thumb: var(--bg-quaternary);
|
--scrollbar-thumb: var(--bg-quaternary);
|
||||||
--scrollbar-thumb-hover: #4a4a4a;
|
--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-primary: #f8fafc;
|
||||||
--bg-secondary: #f1f5f9;
|
--bg-secondary: #f1f5f9;
|
||||||
--bg-tertiary: #e2e8f0;
|
--bg-tertiary: #e2e8f0;
|
||||||
@@ -223,6 +239,177 @@
|
|||||||
|
|
||||||
--modal-backdrop: rgba(0, 0, 0, 0.4);
|
--modal-backdrop: rgba(0, 0, 0, 0.4);
|
||||||
--scrollbar-thumb-hover: #94a3b8;
|
--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;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 深色主题特定调整 */
|
/* 深色主题特定调整 */
|
||||||
|
|||||||
@@ -1,89 +1,129 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
|
<div class="relative bg-zinc-50/50 border border-zinc-200 rounded-xl p-1 overflow-hidden">
|
||||||
<div class="flex items-center" @touchstart.stop>
|
<!-- 简化版日期选择显示 -->
|
||||||
<button
|
<div class="flex items-center justify-between px-2.5 py-1.5" @click="toggleCalendar" style="cursor: pointer">
|
||||||
:disabled="isFirstDateVisible"
|
<div class="flex items-center gap-2.5">
|
||||||
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
|
<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">
|
||||||
@click="scrollDates('left')"
|
<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>
|
||||||
<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>
|
</div>
|
||||||
</button>
|
<div class="flex flex-col">
|
||||||
|
<span class="text-xs font-bold text-zinc-700">{{ modelValue }}</span>
|
||||||
<div
|
<span class="text-[10px] text-zinc-500 font-medium leading-tight">{{ isToday(modelValue) ? '今天' : '' }}</span>
|
||||||
ref="dateSelector"
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="flex items-center border-l border-zinc-200 pl-2">
|
||||||
:disabled="isLastDateVisible"
|
<button class="p-1.5 text-zinc-500 hover:text-emerald-700 transition-colors" title="跳转到今天" @click.stop="scrollToToday">
|
||||||
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
|
<CircleDot class="w-4 h-4" />
|
||||||
@click="scrollDates('right')"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 操作按钮组 -->
|
|
||||||
<div class="flex items-center border-l border-zinc-800 ml-1 pl-1">
|
|
||||||
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click="scrollToToday">
|
|
||||||
<CircleDot class="w-5 h-5" />
|
|
||||||
</button>
|
</button>
|
||||||
<button class="p-2 text-zinc-500 hover:text-blue-400 transition-colors" title="选择特定日期" @click="openManualDatePicker">
|
<button
|
||||||
<CalendarIcon class="w-5 h-5" />
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 手动日期选择弹窗 -->
|
<!-- 快捷选择区 + 月历网格 -->
|
||||||
<div
|
<Transition name="expand">
|
||||||
v-if="showManualDatePicker"
|
<div v-if="showCalendar" class="border-t border-zinc-200/60 mt-1 pt-2 px-2 pb-2">
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
<!-- 快捷按钮 -->
|
||||||
>
|
<div class="flex flex-wrap gap-2 mb-3">
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden" @click.stop>
|
<button
|
||||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
|
v-for="q in quickDates"
|
||||||
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">选择日期</h3>
|
:key="q.label"
|
||||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showManualDatePicker = false">
|
:class="[
|
||||||
<CloseIcon class="w-5 h-5" />
|
'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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-6 space-y-6">
|
|
||||||
<input
|
<!-- 月历网格 -->
|
||||||
v-model="manualSelectedDate"
|
<div class="bg-white/80 border border-zinc-200 rounded-xl p-3">
|
||||||
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 items-center justify-between mb-3 px-1">
|
||||||
>
|
<button
|
||||||
<div class="flex gap-3">
|
class="p-1.5 text-zinc-500 hover:text-zinc-700 rounded-lg hover:bg-zinc-100 transition-colors"
|
||||||
<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>
|
@click="calendarMonth--"
|
||||||
<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>
|
>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, nextTick, watch } from 'vue'
|
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({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -94,122 +134,119 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change'])
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
const dateSelector = ref(null)
|
const showCalendar = ref(false)
|
||||||
const isFirstDateVisible = ref(false)
|
|
||||||
const isLastDateVisible = ref(false)
|
|
||||||
const dateRange = ref({ start: -15, end: 15 })
|
|
||||||
const showManualDatePicker = ref(false)
|
|
||||||
const manualSelectedDate = ref('')
|
|
||||||
|
|
||||||
const availableDates = computed(() => {
|
const pad = (n) => n.toString().padStart(2, '0')
|
||||||
const dates = []
|
const formatDateStr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
const today = new Date()
|
const addDays = (d, n) => { const x = new Date(d); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() + n); return x }
|
||||||
for (let i = dateRange.value.start; i <= dateRange.value.end; i++) {
|
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||||
const date = new Date(today)
|
|
||||||
date.setDate(today.getDate() + i)
|
const weekdayNames = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
const dateStr = date.toISOString().split('T')[0]
|
|
||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
const getWeekday = (dateStr) => {
|
||||||
dates.push({
|
if (!dateStr) return ''
|
||||||
value: dateStr,
|
const d = new Date(dateStr)
|
||||||
day: date.getDate().toString().padStart(2, '0'),
|
return weekdayNames[d.getDay()]
|
||||||
month: (date.getMonth() + 1).toString().padStart(2, '0'),
|
}
|
||||||
weekday: weekdays[date.getDay()],
|
|
||||||
isToday: i === 0
|
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) => {
|
const selectDate = (dateValue) => {
|
||||||
emit('update:modelValue', dateValue)
|
emit('update:modelValue', dateValue)
|
||||||
emit('change', dateValue)
|
emit('change', dateValue)
|
||||||
}
|
showCalendar.value = false
|
||||||
|
|
||||||
const scrollToDateElement = (behavior = 'smooth') => {
|
|
||||||
if (!dateSelector.value || !props.modelValue) return
|
|
||||||
const el = dateSelector.value.querySelector(`[data-date="${props.modelValue}"]`)
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior, block: 'nearest', inline: 'center' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleWheel = (event) => {
|
|
||||||
event.preventDefault()
|
|
||||||
if (!dateSelector.value) return
|
|
||||||
const scrollAmount = 200
|
|
||||||
const currentScroll = dateSelector.value.scrollLeft
|
|
||||||
dateSelector.value.scrollTo({
|
|
||||||
left: event.deltaY > 0 ? currentScroll + scrollAmount : currentScroll - scrollAmount,
|
|
||||||
behavior: 'smooth'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const scrollDates = (direction) => {
|
|
||||||
if (!dateSelector.value) return
|
|
||||||
const scrollAmount = 200
|
|
||||||
const currentScroll = dateSelector.value.scrollLeft
|
|
||||||
dateSelector.value.scrollTo({
|
|
||||||
left: direction === 'right' ? currentScroll + scrollAmount : currentScroll - scrollAmount,
|
|
||||||
behavior: 'smooth'
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleScroll = () => {
|
|
||||||
if (!dateSelector.value) return
|
|
||||||
const { scrollLeft, scrollWidth, clientWidth } = dateSelector.value
|
|
||||||
if (scrollLeft < 50) {
|
|
||||||
const oldScrollWidth = scrollWidth
|
|
||||||
dateRange.value.start -= 7
|
|
||||||
nextTick(() => {
|
|
||||||
const newScrollWidth = dateSelector.value.scrollWidth
|
|
||||||
dateSelector.value.scrollLeft += newScrollWidth - oldScrollWidth
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (scrollWidth - scrollLeft - clientWidth < 50) {
|
|
||||||
dateRange.value.end += 7
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollToToday = () => {
|
const scrollToToday = () => {
|
||||||
const todayStr = new Date().toISOString().split('T')[0]
|
const todayStr = formatDateStr(today)
|
||||||
if (props.modelValue !== todayStr) {
|
if (props.modelValue !== todayStr) selectDate(todayStr)
|
||||||
selectDate(todayStr)
|
|
||||||
}
|
|
||||||
if (dateRange.value.start > 0 || dateRange.value.end < 0) {
|
|
||||||
dateRange.value.start = -15
|
|
||||||
dateRange.value.end = 15
|
|
||||||
}
|
|
||||||
nextTick(() => scrollToDateElement('smooth'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const openManualDatePicker = () => {
|
const toggleCalendar = () => {
|
||||||
manualSelectedDate.value = props.modelValue
|
showCalendar.value = !showCalendar.value
|
||||||
showManualDatePicker.value = true
|
if (showCalendar.value && props.modelValue) {
|
||||||
}
|
const d = new Date(props.modelValue)
|
||||||
|
calendarYear.value = d.getFullYear()
|
||||||
const confirmManualDate = () => {
|
calendarMonth.value = d.getMonth()
|
||||||
if (manualSelectedDate.value && manualSelectedDate.value !== props.modelValue) {
|
|
||||||
selectDate(manualSelectedDate.value)
|
|
||||||
}
|
}
|
||||||
showManualDatePicker.value = false
|
|
||||||
nextTick(() => scrollToDateElement('smooth'))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(() => props.modelValue, () => {
|
const goToday = () => {
|
||||||
nextTick(() => scrollToDateElement('smooth'))
|
calendarYear.value = today.getFullYear()
|
||||||
})
|
calendarMonth.value = today.getMonth()
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const handleQuickSelect = (dateValue) => {
|
||||||
nextTick(() => scrollToDateElement('auto'))
|
if (props.modelValue !== dateValue) selectDate(dateValue)
|
||||||
})
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||||
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 {
|
.expand-enter-from, .expand-leave-to {
|
||||||
-ms-overflow-style: none;
|
opacity: 0;
|
||||||
scrollbar-width: none;
|
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>
|
</style>
|
||||||
|
|||||||
@@ -1,89 +1,151 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="relative bg-zinc-900/50 border border-zinc-800/50 rounded-2xl p-1 overflow-hidden">
|
<div class="relative bg-zinc-50/50 border border-zinc-200 rounded-xl p-1 overflow-hidden">
|
||||||
<div class="flex items-center" @touchstart.stop>
|
<!-- 简化版多选日期显示 -->
|
||||||
<button
|
<div class="flex items-center justify-between px-2.5 py-1.5" @click="toggleCalendar" style="cursor: pointer">
|
||||||
:disabled="isFirstDateVisible"
|
<div class="flex items-center gap-2.5 min-w-0 flex-1">
|
||||||
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
|
<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">
|
||||||
@click="scrollDates('left')"
|
<span class="text-[9px] font-bold uppercase tracking-wider opacity-80 leading-tight">
|
||||||
>
|
{{ modelValue.length }}
|
||||||
<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>
|
</span>
|
||||||
</button>
|
<span class="text-[10px] font-bold leading-none opacity-80">天</span>
|
||||||
|
</div>
|
||||||
<div
|
<div class="flex flex-col min-w-0 flex-1">
|
||||||
ref="dateSelector"
|
<span class="text-xs font-bold text-zinc-700 truncate">
|
||||||
class="flex-1 flex overflow-x-auto scrollbar-hide gap-2 px-2 py-1 scroll-smooth overscroll-x-contain"
|
{{ modelValue.length ? modelValue.slice(0, 3).join('、') + (modelValue.length > 3 ? ` 等${modelValue.length}天` : '') : '未选择日期' }}
|
||||||
style="overscroll-behavior-x: contain; touch-action: pan-x"
|
</span>
|
||||||
@wheel="handleWheel"
|
<span class="text-[10px] text-zinc-500 font-medium leading-tight truncate">
|
||||||
@scroll="handleScroll"
|
{{ selectedDatesSummary }}
|
||||||
>
|
</span>
|
||||||
<button
|
</div>
|
||||||
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>
|
</div>
|
||||||
|
|
||||||
<button
|
<div class="flex items-center border-l border-zinc-200 pl-2 ml-2 shrink-0">
|
||||||
:disabled="isLastDateVisible"
|
<button class="p-1.5 text-zinc-500 hover:text-emerald-700 transition-colors" title="跳转到今天" @click.stop="scrollToToday">
|
||||||
class="p-2 text-zinc-500 hover:text-zinc-300 disabled:opacity-30 transition-colors"
|
<CircleDot class="w-4 h-4" />
|
||||||
@click="scrollDates('right')"
|
|
||||||
>
|
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><polyline points="9,18 15,12 9,6" /></svg>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<!-- 操作按钮组 -->
|
|
||||||
<div class="flex items-center border-l border-zinc-800 ml-1 pl-1">
|
|
||||||
<button class="p-2 text-zinc-500 hover:text-emerald-400 transition-colors" title="跳转到今天" @click="scrollToToday">
|
|
||||||
<CircleDot class="w-5 h-5" />
|
|
||||||
</button>
|
</button>
|
||||||
<button class="p-2 text-zinc-500 hover:text-blue-400 transition-colors" title="选择特定日期" @click="openManualDatePicker">
|
<button
|
||||||
<CalendarIcon class="w-5 h-5" />
|
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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 手动日期选择弹窗 -->
|
<!-- 扩展面板:快捷批量 + 月历 -->
|
||||||
<div
|
<Transition name="expand">
|
||||||
v-if="showManualDatePicker"
|
<div v-if="showCalendar" class="border-t border-zinc-200/60 mt-1 pt-2 px-2 pb-2 space-y-3">
|
||||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
<!-- 批量操作按钮 -->
|
||||||
>
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<div class="bg-zinc-900 border border-zinc-800 rounded-xl shadow-2xl w-full max-w-sm overflow-hidden" @click.stop>
|
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">批量选择</span>
|
||||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
|
<button
|
||||||
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">选择日期</h3>
|
v-for="b in batchActions"
|
||||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showManualDatePicker = false">
|
:key="b.label"
|
||||||
<CloseIcon class="w-5 h-5" />
|
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"
|
||||||
</button>
|
@click="applyBatchAction(b.action)"
|
||||||
</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">
|
{{ b.label }}
|
||||||
<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>
|
||||||
<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="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>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, nextTick } from 'vue'
|
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({
|
const props = defineProps({
|
||||||
modelValue: {
|
modelValue: {
|
||||||
@@ -94,133 +156,187 @@ const props = defineProps({
|
|||||||
|
|
||||||
const emit = defineEmits(['update:modelValue', 'change'])
|
const emit = defineEmits(['update:modelValue', 'change'])
|
||||||
|
|
||||||
const dateSelector = ref(null)
|
const showCalendar = ref(false)
|
||||||
const isFirstDateVisible = ref(false)
|
const lastClickedDate = ref('')
|
||||||
const isLastDateVisible = ref(false)
|
|
||||||
const dateRange = ref({ start: 0, end: 30 }) // 默认展示今天起30天
|
|
||||||
const showManualDatePicker = ref(false)
|
|
||||||
const manualSelectedDate = ref('')
|
|
||||||
|
|
||||||
const availableDates = computed(() => {
|
const pad = (n) => n.toString().padStart(2, '0')
|
||||||
const dates = []
|
const formatDateStr = (d) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
|
||||||
const today = new Date()
|
const addDays = (d, n) => { const x = new Date(d); x.setHours(0, 0, 0, 0); x.setDate(x.getDate() + n); return x }
|
||||||
for (let i = dateRange.value.start; i <= dateRange.value.end; i++) {
|
const sameDay = (a, b) => a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate()
|
||||||
const date = new Date(today)
|
const sortDates = (arr) => [...new Set(arr)].sort()
|
||||||
date.setDate(today.getDate() + i)
|
|
||||||
const dateStr = date.toISOString().split('T')[0]
|
const today = new Date(); today.setHours(0, 0, 0, 0)
|
||||||
const weekdays = ['日', '一', '二', '三', '四', '五', '六']
|
const calendarYear = ref(today.getFullYear())
|
||||||
dates.push({
|
const calendarMonth = ref(today.getMonth())
|
||||||
value: dateStr,
|
|
||||||
day: date.getDate().toString().padStart(2, '0'),
|
const weekdayNames = ['日', '一', '二', '三', '四', '五', '六']
|
||||||
month: (date.getMonth() + 1).toString().padStart(2, '0'),
|
|
||||||
weekday: weekdays[date.getDay()],
|
const selectedDatesSummary = computed(() => {
|
||||||
isToday: i === 0
|
if (!props.modelValue || props.modelValue.length === 0) return '点击右侧按钮选择日期'
|
||||||
})
|
const sorted = sortDates(props.modelValue)
|
||||||
}
|
const todayStr = formatDateStr(today)
|
||||||
return dates
|
if (sorted.includes(todayStr)) return `含今天 共 ${sorted.length} 天`
|
||||||
|
const first = sorted[0]
|
||||||
|
const last = sorted[sorted.length - 1]
|
||||||
|
return `${first} ~ ${last}`
|
||||||
})
|
})
|
||||||
|
|
||||||
const toggleDate = (dateValue) => {
|
const batchActions = [
|
||||||
const newValue = [...props.modelValue]
|
{ label: '今天', action: 'today' },
|
||||||
const index = newValue.indexOf(dateValue)
|
{ label: '明天起7天', action: 'next7' },
|
||||||
if (index > -1) {
|
{ label: '本周工作日', action: 'thisWeekWork' },
|
||||||
newValue.splice(index, 1)
|
{ label: '本周全周', action: 'thisWeekAll' },
|
||||||
} else {
|
{ label: '下周工作日', action: 'nextWeekWork' },
|
||||||
newValue.push(dateValue)
|
{ label: '下周全周', action: 'nextWeekAll' },
|
||||||
newValue.sort() // 保持日期顺序
|
{ label: '本月工作日', action: 'thisMonthWork' }
|
||||||
}
|
]
|
||||||
emit('update:modelValue', newValue)
|
|
||||||
emit('change', newValue)
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleWheel = (event) => {
|
const calendarCells = computed(() => {
|
||||||
event.preventDefault()
|
const cells = []
|
||||||
if (!dateSelector.value) return
|
const firstDay = new Date(calendarYear.value, calendarMonth.value, 1)
|
||||||
const scrollAmount = 200
|
const startWeekday = firstDay.getDay()
|
||||||
const currentScroll = dateSelector.value.scrollLeft
|
const startDate = addDays(firstDay, -startWeekday)
|
||||||
dateSelector.value.scrollTo({
|
for (let i = 0; i < 42; i++) {
|
||||||
left: event.deltaY > 0 ? currentScroll + scrollAmount : currentScroll - scrollAmount,
|
const d = addDays(startDate, i)
|
||||||
behavior: 'smooth'
|
cells.push({
|
||||||
})
|
day: d.getDate(),
|
||||||
}
|
dateStr: formatDateStr(d),
|
||||||
|
inMonth: d.getMonth() === calendarMonth.value,
|
||||||
const scrollDates = (direction) => {
|
isToday: sameDay(d, today)
|
||||||
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) {
|
return cells
|
||||||
dateRange.value.end += 7
|
})
|
||||||
|
|
||||||
|
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 = () => {
|
const scrollToToday = () => {
|
||||||
if (dateRange.value.start > 0 || dateRange.value.end < 0) {
|
const todayStr = formatDateStr(today)
|
||||||
dateRange.value.start = -15
|
if (!props.modelValue.includes(todayStr)) {
|
||||||
dateRange.value.end = 15
|
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 = () => {
|
const toggleCalendar = () => {
|
||||||
manualSelectedDate.value = new Date().toISOString().split('T')[0]
|
showCalendar.value = !showCalendar.value
|
||||||
showManualDatePicker.value = true
|
if (showCalendar.value && props.modelValue.length > 0) {
|
||||||
}
|
const d = new Date(props.modelValue[props.modelValue.length - 1])
|
||||||
|
calendarYear.value = d.getFullYear()
|
||||||
const confirmManualDate = () => {
|
calendarMonth.value = d.getMonth()
|
||||||
if (manualSelectedDate.value) {
|
|
||||||
const newValue = [...props.modelValue]
|
|
||||||
if (!newValue.includes(manualSelectedDate.value)) {
|
|
||||||
newValue.push(manualSelectedDate.value)
|
|
||||||
newValue.sort()
|
|
||||||
emit('update:modelValue', newValue)
|
|
||||||
emit('change', newValue)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
showManualDatePicker.value = false
|
|
||||||
nextTick(() => {
|
|
||||||
if (!dateSelector.value) return
|
|
||||||
const el = dateSelector.value.querySelector(`[data-date="${manualSelectedDate.value}"]`)
|
|
||||||
if (el) {
|
|
||||||
el.scrollIntoView({ behavior: 'smooth', block: 'nearest', inline: 'center' })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
const goToday = () => {
|
||||||
nextTick(() => scrollToToday())
|
calendarYear.value = today.getFullYear()
|
||||||
})
|
calendarMonth.value = today.getMonth()
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.scrollbar-hide::-webkit-scrollbar {
|
.scrollbar-hide::-webkit-scrollbar { display: none; }
|
||||||
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 {
|
.expand-enter-from, .expand-leave-to {
|
||||||
-ms-overflow-style: none;
|
opacity: 0;
|
||||||
scrollbar-width: none;
|
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>
|
</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"
|
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
|
<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>
|
<div>
|
||||||
<p class="text-[10px] uppercase tracking-[0.2em] text-zinc-500 font-black">后台引导</p>
|
<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>
|
</div>
|
||||||
<button
|
<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"
|
@click="handleClose"
|
||||||
>
|
>
|
||||||
<X :size="16" class="mx-auto" />
|
<X :size="16" class="mx-auto" />
|
||||||
@@ -21,29 +21,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="px-5 py-5 space-y-4">
|
<div class="px-5 py-5 space-y-4">
|
||||||
<p class="text-sm leading-relaxed text-zinc-300">{{ currentStep.description }}</p>
|
<p class="text-sm leading-relaxed text-zinc-600">{{ currentStep.description }}</p>
|
||||||
<div class="rounded-xl border border-blue-500/20 bg-blue-500/5 p-3">
|
<div class="rounded-xl border border-blue-200 bg-blue-500/5 p-3">
|
||||||
<p class="text-xs text-zinc-400">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<p class="text-xs text-zinc-500 font-semibold">{{ stepIndex + 1 }} / {{ steps.length }}</p>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="stepIndex > 0"
|
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"
|
@click="prevStep"
|
||||||
>
|
>
|
||||||
上一步
|
上一步
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="goCurrentStep"
|
||||||
>
|
>
|
||||||
前往此功能
|
前往此功能
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- 头部区域 -->
|
<!-- 头部区域 -->
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||||
<div>
|
<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>
|
<p class="text-xs text-zinc-500 mt-1">管理开放API的访问密钥,控制第三方应用的访问权限</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<!-- 过滤器栏 -->
|
<!-- 过滤器栏 -->
|
||||||
<div
|
<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">
|
<div class="relative flex-1 w-full">
|
||||||
<Search class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-700" :size="16" />
|
<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"
|
v-model="filters.search"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索API密钥名称或描述..."
|
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"
|
@input="debouncedSearch"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -42,12 +42,12 @@
|
|||||||
v-model="filters.createdBy"
|
v-model="filters.createdBy"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="创建者用户名"
|
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"
|
@input="debouncedSearch"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
@click="loadApiKeys"
|
||||||
>
|
>
|
||||||
<RefreshCw :size="14" :class="{ 'animate-spin': loading }" />
|
<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"
|
class="flex flex-col items-center justify-center py-20 px-4 text-center"
|
||||||
>
|
>
|
||||||
<div
|
<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" />
|
<Key :size="32" :stroke-width="1.5" />
|
||||||
</div>
|
</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">
|
<p class="text-xs text-zinc-500 mt-2 max-w-xs leading-relaxed">
|
||||||
您还没有创建任何访问密钥。创建密钥后,您可以安全地将 VoiceHub 集成到第三方应用程序中。
|
您还没有创建任何访问密钥。创建密钥后,您可以安全地将宝一外校园点歌系统集成到第三方应用程序中。
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
@click="openCreateModal"
|
||||||
>
|
>
|
||||||
<Plus :size="16" /> 创建您的第一个密钥
|
<Plus :size="16" /> 创建您的第一个密钥
|
||||||
@@ -89,12 +89,12 @@
|
|||||||
<div
|
<div
|
||||||
v-for="apiKey in apiKeys"
|
v-for="apiKey in apiKeys"
|
||||||
:key="apiKey.id"
|
: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="flex items-start justify-between relative z-10">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<div class="flex items-center gap-3">
|
<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 }}
|
{{ apiKey.name }}
|
||||||
</h4>
|
</h4>
|
||||||
<span
|
<span
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-else-if="apiKey.status === 'inactive'"
|
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
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -119,13 +119,13 @@
|
|||||||
class="flex items-center gap-1 opacity-100 lg:opacity-0 group-hover:opacity-100 transition-all"
|
class="flex items-center gap-1 opacity-100 lg:opacity-0 group-hover:opacity-100 transition-all"
|
||||||
>
|
>
|
||||||
<button
|
<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)"
|
@click="viewApiKey(apiKey)"
|
||||||
>
|
>
|
||||||
<Eye :size="14" />
|
<Eye :size="14" />
|
||||||
</button>
|
</button>
|
||||||
<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)"
|
@click="editApiKey(apiKey)"
|
||||||
>
|
>
|
||||||
<Edit2 :size="14" />
|
<Edit2 :size="14" />
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="space-y-0.5">
|
||||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
||||||
>创建者</span
|
>创建者</span
|
||||||
@@ -162,7 +162,7 @@
|
|||||||
<span
|
<span
|
||||||
v-for="perm in apiKey.permissions"
|
v-for="perm in apiKey.permissions"
|
||||||
:key="perm"
|
: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 }}
|
{{ perm }}
|
||||||
</span>
|
</span>
|
||||||
@@ -197,14 +197,14 @@
|
|||||||
<!-- 创建/编辑模态框 -->
|
<!-- 创建/编辑模态框 -->
|
||||||
<div
|
<div
|
||||||
v-if="showCreateModal || showEditModal"
|
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">
|
<div class="p-6 border-b border-zinc-200 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-black text-zinc-100 uppercase tracking-widest">
|
<h3 class="text-lg font-black text-zinc-900 uppercase tracking-widest">
|
||||||
{{ showCreateModal ? '创建API密钥' : '编辑API密钥' }}
|
{{ showCreateModal ? '创建API密钥' : '编辑API密钥' }}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
class="text-zinc-500 hover:text-zinc-200 transition-colors"
|
class="text-zinc-500 hover:text-zinc-700 transition-colors"
|
||||||
@click="closeModals"
|
@click="closeModals"
|
||||||
>
|
>
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
@@ -220,7 +220,7 @@
|
|||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入API密钥名称"
|
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>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -230,7 +230,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="form.description"
|
v-model="form.description"
|
||||||
placeholder="输入API密钥描述 (可选)"
|
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>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -259,22 +259,22 @@
|
|||||||
<label
|
<label
|
||||||
v-for="perm in availablePermissions"
|
v-for="perm in availablePermissions"
|
||||||
:key="perm.value"
|
: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="{
|
: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
|
<input
|
||||||
v-model="form.permissions"
|
v-model="form.permissions"
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:value="perm.value"
|
: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>
|
<div>
|
||||||
<p
|
<p
|
||||||
class="text-xs font-bold"
|
class="text-xs font-bold"
|
||||||
:class="
|
: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 }}
|
{{ perm.label }}
|
||||||
@@ -291,18 +291,18 @@
|
|||||||
<input
|
<input
|
||||||
v-model="form.isActive"
|
v-model="form.isActive"
|
||||||
type="checkbox"
|
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
|
<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
|
>启用此API密钥</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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
|
<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"
|
@click="closeModals"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -320,14 +320,14 @@
|
|||||||
<!-- 成功模态框 -->
|
<!-- 成功模态框 -->
|
||||||
<div
|
<div
|
||||||
v-if="showSuccessModal"
|
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">
|
<div class="p-6 border-b border-zinc-200 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-black text-zinc-100 uppercase tracking-widest">
|
<h3 class="text-lg font-black text-zinc-900 uppercase tracking-widest">
|
||||||
API密钥创建成功
|
API密钥创建成功
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
class="text-zinc-500 hover:text-zinc-200 transition-colors"
|
class="text-zinc-500 hover:text-zinc-700 transition-colors"
|
||||||
@click="closeModals"
|
@click="closeModals"
|
||||||
>
|
>
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
@@ -343,7 +343,7 @@
|
|||||||
>
|
>
|
||||||
<Check :size="24" :stroke-width="3" />
|
<Check :size="24" :stroke-width="3" />
|
||||||
</div>
|
</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>
|
<p class="text-xs text-zinc-500 mt-2">请妥善保管以下API密钥,它只会显示这一次。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -353,7 +353,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<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 }}
|
{{ newApiKey?.apiKey }}
|
||||||
</div>
|
</div>
|
||||||
@@ -362,7 +362,7 @@
|
|||||||
:class="
|
:class="
|
||||||
copied
|
copied
|
||||||
? 'bg-emerald-600 text-white'
|
? '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)"
|
@click="copyToClipboard(newApiKey?.apiKey)"
|
||||||
>
|
>
|
||||||
@@ -381,9 +381,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-6 border-t border-zinc-800">
|
<div class="p-6 border-t border-zinc-200">
|
||||||
<button
|
<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"
|
@click="closeModals"
|
||||||
>
|
>
|
||||||
我已保存,关闭窗口
|
我已保存,关闭窗口
|
||||||
@@ -394,12 +394,12 @@
|
|||||||
<!-- 详情模态框 -->
|
<!-- 详情模态框 -->
|
||||||
<div
|
<div
|
||||||
v-if="showViewModal && selectedApiKey"
|
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">
|
<div class="p-6 border-b border-zinc-200 flex items-center justify-between">
|
||||||
<h3 class="text-lg font-black text-zinc-100 uppercase tracking-widest">API密钥详情</h3>
|
<h3 class="text-lg font-black text-zinc-900 uppercase tracking-widest">API密钥详情</h3>
|
||||||
<button
|
<button
|
||||||
class="text-zinc-500 hover:text-zinc-200 transition-colors"
|
class="text-zinc-500 hover:text-zinc-700 transition-colors"
|
||||||
@click="closeModals"
|
@click="closeModals"
|
||||||
>
|
>
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
@@ -409,14 +409,14 @@
|
|||||||
<div class="p-6 space-y-8 max-h-[80vh] overflow-y-auto custom-scrollbar">
|
<div class="p-6 space-y-8 max-h-[80vh] overflow-y-auto custom-scrollbar">
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h5
|
<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>
|
</h5>
|
||||||
<div class="grid grid-cols-2 gap-6">
|
<div class="grid grid-cols-2 gap-6">
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<span class="text-[10px] text-zinc-600 font-bold">名称:</span>
|
<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>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<span class="text-[10px] text-zinc-600 font-bold">状态:</span>
|
<span class="text-[10px] text-zinc-600 font-bold">状态:</span>
|
||||||
@@ -428,7 +428,7 @@
|
|||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
v-else-if="selectedApiKey.status === 'inactive'"
|
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
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -446,13 +446,13 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<span class="text-[10px] text-zinc-600 font-bold">创建者:</span>
|
<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 || '未知' }}
|
{{ selectedApiKey.creatorName || '未知' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<div class="space-y-1">
|
||||||
<span class="text-[10px] text-zinc-600 font-bold">创建时间:</span>
|
<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) }}
|
{{ formatDate(selectedApiKey.createdAt) }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
<span class="text-[10px] text-zinc-600 font-bold">过期时间:</span>
|
<span class="text-[10px] text-zinc-600 font-bold">过期时间:</span>
|
||||||
<p
|
<p
|
||||||
class="text-xs font-bold"
|
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) }}
|
{{ formatDate(selectedApiKey.expiresAt) }}
|
||||||
</p>
|
</p>
|
||||||
@@ -470,19 +470,19 @@
|
|||||||
|
|
||||||
<section class="space-y-4">
|
<section class="space-y-4">
|
||||||
<h5
|
<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>
|
</h5>
|
||||||
<div class="grid grid-cols-2 gap-4">
|
<div class="grid grid-cols-2 gap-4">
|
||||||
<div
|
<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">
|
<div class="space-y-0.5">
|
||||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
||||||
>总调用次数</span
|
>总调用次数</span
|
||||||
>
|
>
|
||||||
<p class="text-xl font-black text-zinc-100">
|
<p class="text-xl font-black text-zinc-900">
|
||||||
{{ selectedApiKey.usageCount || 0 }}
|
{{ selectedApiKey.usageCount || 0 }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -493,13 +493,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="space-y-0.5">
|
||||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
||||||
>最后使用时间</span
|
>最后使用时间</span
|
||||||
>
|
>
|
||||||
<p class="text-xs font-bold text-zinc-100">
|
<p class="text-xs font-bold text-zinc-900">
|
||||||
{{
|
{{
|
||||||
selectedApiKey.lastUsedAt
|
selectedApiKey.lastUsedAt
|
||||||
? formatDate(selectedApiKey.lastUsedAt)
|
? formatDate(selectedApiKey.lastUsedAt)
|
||||||
@@ -517,7 +517,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section class="space-y-4">
|
<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 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
|
||||||
最近调用日志
|
最近调用日志
|
||||||
</h5>
|
</h5>
|
||||||
@@ -538,7 +538,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-else-if="apiLogs.length === 0"
|
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" />
|
<History :size="24" class="text-zinc-800 mb-2" />
|
||||||
<span class="text-[10px] font-bold text-zinc-700 uppercase tracking-widest"
|
<span class="text-[10px] font-bold text-zinc-700 uppercase tracking-widest"
|
||||||
@@ -549,7 +549,7 @@
|
|||||||
v-for="log in apiLogs"
|
v-for="log in apiLogs"
|
||||||
v-else
|
v-else
|
||||||
:key="log.id"
|
: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 class="flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
@@ -1002,11 +1002,11 @@ const copyToClipboard = async (text) => {
|
|||||||
const getMethodClass = (method) => {
|
const getMethodClass = (method) => {
|
||||||
const map = {
|
const map = {
|
||||||
GET: 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20',
|
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',
|
PUT: 'bg-amber-500/10 text-amber-500 border-amber-500/20',
|
||||||
DELETE: 'bg-red-500/10 text-red-500 border-red-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) => {
|
const getStatusColorClass = (code) => {
|
||||||
@@ -1025,8 +1025,8 @@ onMounted(() => {
|
|||||||
.loading-spinner {
|
.loading-spinner {
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
border: 2px solid rgba(255, 255, 255, 0.1);
|
border: 2px solid rgba(0, 0, 0, 0.08);
|
||||||
border-top-color: #3b82f6;
|
border-top-color: var(--border-secondary, #3b82f6);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
@@ -1044,7 +1044,7 @@ onMounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--bg-tertiary, #27272a);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -520,8 +520,8 @@ const formatFileSize = (bytes) => {
|
|||||||
/* 主容器 */
|
/* 主容器 */
|
||||||
.backup-manager {
|
.backup-manager {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #1a1a1a;
|
background: var(--bg-primary, #ffffff);
|
||||||
color: #e5e5e5;
|
color: var(--text-primary, #e5e5e5);
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
@@ -535,13 +535,13 @@ const formatFileSize = (bytes) => {
|
|||||||
.header h3 {
|
.header h3 {
|
||||||
font-size: 2.5rem;
|
font-size: 2.5rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header .description {
|
.header .description {
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
color: #a0a0a0;
|
color: var(--text-secondary, #a0a0a0);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,8 +556,8 @@ const formatFileSize = (bytes) => {
|
|||||||
|
|
||||||
/* 操作卡片 */
|
/* 操作卡片 */
|
||||||
.action-card {
|
.action-card {
|
||||||
background: #2a2a2a;
|
background: var(--bg-tertiary, #2a2a2a);
|
||||||
border: 1px solid #404040;
|
border: 1px solid var(--border-secondary, #404040);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -566,7 +566,7 @@ const formatFileSize = (bytes) => {
|
|||||||
|
|
||||||
.action-card:hover {
|
.action-card:hover {
|
||||||
border-color: #606060;
|
border-color: #606060;
|
||||||
background: #2f2f2f;
|
background: var(--bg-tertiary, #2f2f2f);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card .card-content {
|
.action-card .card-content {
|
||||||
@@ -587,7 +587,7 @@ const formatFileSize = (bytes) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-icon svg {
|
.action-icon svg {
|
||||||
@@ -599,7 +599,7 @@ const formatFileSize = (bytes) => {
|
|||||||
.action-card h4 {
|
.action-card h4 {
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
margin: 0 0 0.75rem 0;
|
margin: 0 0 0.75rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -623,7 +623,7 @@ const formatFileSize = (bytes) => {
|
|||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: block;
|
display: block;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: white;
|
color: var(--text-primary, #09090b);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -672,8 +672,8 @@ const formatFileSize = (bytes) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal {
|
.modal {
|
||||||
background: #2a2a2a;
|
background: var(--bg-tertiary, #2a2a2a);
|
||||||
border: 1px solid #404040;
|
border: 1px solid var(--border-secondary, #404040);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
max-width: 600px;
|
max-width: 600px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -724,7 +724,7 @@ const formatFileSize = (bytes) => {
|
|||||||
.progress-details h5,
|
.progress-details h5,
|
||||||
.progress-errors h5 {
|
.progress-errors h5 {
|
||||||
margin: 0 0 10px 0;
|
margin: 0 0 10px 0;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -736,14 +736,14 @@ const formatFileSize = (bytes) => {
|
|||||||
border: 1px solid #505050;
|
border: 1px solid #505050;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 10px;
|
padding: 10px;
|
||||||
background: #333333;
|
background: var(--bg-quaternary, #333333);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item {
|
.detail-item {
|
||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
color: #a0a0a0;
|
color: #a0a0a0;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border-bottom: 1px solid #404040;
|
border-bottom: 1px solid var(--border-secondary, #404040);
|
||||||
}
|
}
|
||||||
|
|
||||||
.detail-item:last-child {
|
.detail-item:last-child {
|
||||||
@@ -754,7 +754,7 @@ const formatFileSize = (bytes) => {
|
|||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
color: #ff6b6b;
|
color: #ff6b6b;
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
border-bottom: 1px solid #4a2a2a;
|
border-bottom: 1px solid var(--border-secondary, #4a2a2a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error-item:last-child {
|
.error-item:last-child {
|
||||||
@@ -772,7 +772,7 @@ const formatFileSize = (bytes) => {
|
|||||||
.modal-title {
|
.modal-title {
|
||||||
font-size: 1.75rem;
|
font-size: 1.75rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -788,7 +788,7 @@ const formatFileSize = (bytes) => {
|
|||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
background: #505050;
|
background: #505050;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn svg {
|
.close-btn svg {
|
||||||
@@ -802,11 +802,11 @@ const formatFileSize = (bytes) => {
|
|||||||
|
|
||||||
.modal-footer {
|
.modal-footer {
|
||||||
padding: 1.5rem 2rem;
|
padding: 1.5rem 2rem;
|
||||||
border-top: 1px solid #404040;
|
border-top: 1px solid var(--border-secondary, #404040);
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
background: #252525;
|
background: var(--bg-tertiary, #252525);
|
||||||
}
|
}
|
||||||
|
|
||||||
.modal-footer button {
|
.modal-footer button {
|
||||||
@@ -822,7 +822,7 @@ const formatFileSize = (bytes) => {
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -838,7 +838,7 @@ const formatFileSize = (bytes) => {
|
|||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
background: #333333;
|
background: var(--bg-quaternary, #333333);
|
||||||
border: 1px solid #505050;
|
border: 1px solid #505050;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -870,7 +870,7 @@ const formatFileSize = (bytes) => {
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -907,12 +907,12 @@ const formatFileSize = (bytes) => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
background: #333333;
|
background: var(--bg-quaternary, #333333);
|
||||||
}
|
}
|
||||||
|
|
||||||
.upload-area:hover,
|
.upload-area:hover,
|
||||||
.upload-area.drag-over {
|
.upload-area.drag-over {
|
||||||
border-color: #4a90e2;
|
border-color: var(--border-secondary, #4a90e2);
|
||||||
background: #383838;
|
background: #383838;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -926,7 +926,7 @@ const formatFileSize = (bytes) => {
|
|||||||
.upload-area h4 {
|
.upload-area h4 {
|
||||||
font-size: 1.25rem;
|
font-size: 1.25rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -941,7 +941,7 @@ const formatFileSize = (bytes) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
background: #333333;
|
background: var(--bg-quaternary, #333333);
|
||||||
border: 1px solid #505050;
|
border: 1px solid #505050;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
margin-top: 1rem;
|
margin-top: 1rem;
|
||||||
@@ -969,7 +969,7 @@ const formatFileSize = (bytes) => {
|
|||||||
display: block;
|
display: block;
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1043,7 +1043,7 @@ const formatFileSize = (bytes) => {
|
|||||||
right: 1.5rem;
|
right: 1.5rem;
|
||||||
padding: 1rem 1.5rem;
|
padding: 1rem 1.5rem;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: white;
|
color: var(--text-primary, #09090b);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
z-index: 1001;
|
z-index: 1001;
|
||||||
@@ -1056,8 +1056,8 @@ const formatFileSize = (bytes) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification.success {
|
.notification.success {
|
||||||
background: #2e7d32;
|
background: var(--bg-tertiary, #2e7d32);
|
||||||
border: 1px solid #4caf50;
|
border: 1px solid var(--border-secondary, #4caf50);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification.error {
|
.notification.error {
|
||||||
@@ -1071,8 +1071,8 @@ const formatFileSize = (bytes) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification.info {
|
.notification.info {
|
||||||
background: #1976d2;
|
background: var(--bg-secondary, #1976d2);
|
||||||
border: 1px solid #2196f3;
|
border: 1px solid var(--border-secondary, #2196f3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes slideInRight {
|
@keyframes slideInRight {
|
||||||
|
|||||||
@@ -13,13 +13,13 @@
|
|||||||
@click="$emit('close')"
|
@click="$emit('close')"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
@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>
|
<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
|
<div
|
||||||
class="w-10 h-10 rounded-xl bg-purple-600/10 flex items-center justify-center text-purple-500"
|
class="w-10 h-10 rounded-xl bg-purple-600/10 flex items-center justify-center text-purple-500"
|
||||||
>
|
>
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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')"
|
@click="$emit('close')"
|
||||||
>
|
>
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
|
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
|
||||||
updateType === 'grade-only'
|
updateType === 'grade-only'
|
||||||
? 'bg-purple-500/5 border-purple-500/50 ring-4 ring-purple-500/10'
|
? '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" >
|
<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',
|
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
|
||||||
updateType === 'grade-only'
|
updateType === 'grade-only'
|
||||||
? 'bg-purple-500 text-white'
|
? '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" />
|
<Calendar :size="18" />
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
<Check :size="12" class="text-white" />
|
<Check :size="12" class="text-white" />
|
||||||
</div>
|
</div>
|
||||||
</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
|
||||||
>
|
>
|
||||||
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
|
<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',
|
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
|
||||||
updateType === 'excel-batch'
|
updateType === 'excel-batch'
|
||||||
? 'bg-emerald-500/5 border-emerald-500/50 ring-4 ring-emerald-500/10'
|
? '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" >
|
<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',
|
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
|
||||||
updateType === 'excel-batch'
|
updateType === 'excel-batch'
|
||||||
? 'bg-emerald-500 text-white'
|
? '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" />
|
<FileSpreadsheet :size="18" />
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
<Check :size="12" class="text-white" />
|
<Check :size="12" class="text-white" />
|
||||||
</div>
|
</div>
|
||||||
</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
|
>Excel 批量更新</span
|
||||||
>
|
>
|
||||||
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
|
<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',
|
'relative flex flex-col p-5 rounded-xl border-2 transition-all cursor-pointer group',
|
||||||
updateType === 'status-batch'
|
updateType === 'status-batch'
|
||||||
? 'bg-amber-500/5 border-amber-500/50 ring-4 ring-amber-500/10'
|
? '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" >
|
<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',
|
'w-10 h-10 rounded-lg flex items-center justify-center transition-colors',
|
||||||
updateType === 'status-batch'
|
updateType === 'status-batch'
|
||||||
? 'bg-amber-500 text-white'
|
? '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" />
|
<ShieldAlert :size="18" />
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
<Check :size="12" class="text-white" />
|
<Check :size="12" class="text-white" />
|
||||||
</div>
|
</div>
|
||||||
</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
|
||||||
>
|
>
|
||||||
<span class="text-[10px] text-zinc-500 mt-1 font-medium leading-relaxed"
|
<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)"
|
v-if="['grade-only', 'status-batch'].includes(updateType)"
|
||||||
class="space-y-6 animate-in fade-in slide-in-from-bottom-2 duration-300"
|
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
|
<div
|
||||||
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest"
|
class="flex items-center gap-2 text-xs font-black text-zinc-400 uppercase tracking-widest"
|
||||||
>
|
>
|
||||||
@@ -203,7 +203,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<div
|
||||||
v-if="filteredStudents.length === 0"
|
v-if="filteredStudents.length === 0"
|
||||||
@@ -215,17 +215,17 @@
|
|||||||
<label
|
<label
|
||||||
v-for="student in filteredStudents"
|
v-for="student in filteredStudents"
|
||||||
:key="student.id"
|
: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
|
<input
|
||||||
v-model="selectedUserIds"
|
v-model="selectedUserIds"
|
||||||
:value="student.id"
|
:value="student.id"
|
||||||
type="checkbox"
|
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">
|
<div class="flex flex-col">
|
||||||
<span
|
<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
|
>{{ student.name }}</span
|
||||||
>
|
>
|
||||||
<span class="text-[10px] text-zinc-600 font-mono">{{
|
<span class="text-[10px] text-zinc-600 font-mono">{{
|
||||||
@@ -261,21 +261,21 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<label
|
<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
|
<input
|
||||||
v-model="keepClass"
|
v-model="keepClass"
|
||||||
type="checkbox"
|
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>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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 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" />
|
<Save :size="14" />
|
||||||
目标状态设置
|
目标状态设置
|
||||||
</div>
|
</div>
|
||||||
@@ -299,7 +299,7 @@
|
|||||||
v-model="statusReason"
|
v-model="statusReason"
|
||||||
rows="2"
|
rows="2"
|
||||||
placeholder="例如: 2025届学生统一毕业"
|
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>
|
></textarea>
|
||||||
</div>
|
</div>
|
||||||
</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',
|
'w-full py-12 border-2 border-dashed rounded-xl transition-all flex flex-col items-center justify-center gap-4',
|
||||||
isDragOver
|
isDragOver
|
||||||
? 'border-emerald-500 bg-emerald-500/10'
|
? '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
|
<div
|
||||||
:class="[
|
: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'
|
isDragOver ? 'text-emerald-500' : 'text-zinc-600 group-hover:text-emerald-500'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<Upload :size="32" />
|
<Upload :size="32" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<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 文件到此处
|
拖拽 Excel 文件到此处
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs text-zinc-500 mt-1">
|
<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="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||||
<div
|
<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
|
<div
|
||||||
class="flex items-center gap-2 text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
class="flex items-center gap-2 text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||||
@@ -372,15 +372,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<ul class="text-[10px] text-zinc-600 space-y-1.5 font-medium leading-relaxed">
|
<ul class="text-[10px] text-zinc-600 space-y-1.5 font-medium leading-relaxed">
|
||||||
<li class="flex items-start gap-2">
|
<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>
|
||||||
<li class="flex items-start gap-2">
|
<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> 列用于精确匹配现有账户
|
<span class="text-zinc-400 font-bold">用户名</span> 列用于精确匹配现有账户
|
||||||
</li>
|
</li>
|
||||||
<li class="flex items-start gap-2">
|
<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>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -417,11 +417,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="overflow-x-auto custom-scrollbar">
|
||||||
<table class="w-full text-left border-collapse">
|
<table class="w-full text-left border-collapse">
|
||||||
<thead
|
<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>
|
<tr>
|
||||||
<th class="px-5 py-4 whitespace-nowrap">匹配用户</th>
|
<th class="px-5 py-4 whitespace-nowrap">匹配用户</th>
|
||||||
@@ -435,7 +435,7 @@
|
|||||||
v-for="(row, index) in excelPreviewData.slice(0, 10)"
|
v-for="(row, index) in excelPreviewData.slice(0, 10)"
|
||||||
:key="index"
|
:key="index"
|
||||||
:class="[
|
: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">
|
<td class="px-5 py-4">
|
||||||
@@ -443,7 +443,7 @@
|
|||||||
<span
|
<span
|
||||||
:class="[
|
:class="[
|
||||||
'text-xs font-bold',
|
'text-xs font-bold',
|
||||||
row.error ? 'text-red-400' : 'text-zinc-200'
|
row.error ? 'text-red-400' : 'text-zinc-700'
|
||||||
]"
|
]"
|
||||||
>{{ row.username }}</span
|
>{{ row.username }}</span
|
||||||
>
|
>
|
||||||
@@ -459,11 +459,11 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="px-5 py-4">
|
<td class="px-5 py-4">
|
||||||
<div class="flex items-center gap-2">
|
<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 || '-'
|
row.newGrade || row.currentGrade || '-'
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="text-[10px] text-zinc-700">/</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 || '-'
|
row.newClass || row.currentClass || '-'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -488,7 +488,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
v-if="excelPreviewData.length > 10"
|
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 }} 条记录已在队列中
|
以及另外 {{ excelPreviewData.length - 10 }} 条记录已在队列中
|
||||||
</div>
|
</div>
|
||||||
@@ -507,9 +507,9 @@
|
|||||||
</div>
|
</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
|
<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')"
|
@click="$emit('close')"
|
||||||
>
|
>
|
||||||
取消操作
|
取消操作
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||||
<div>
|
<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>
|
</h2>
|
||||||
<p class="text-xs text-zinc-500 mt-1 font-medium">
|
<p class="text-xs text-zinc-500 mt-1 font-medium">
|
||||||
@@ -15,47 +15,71 @@
|
|||||||
</div>
|
</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 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] mb-6 px-1">
|
||||||
添加黑名单项
|
添加黑名单项
|
||||||
</h3>
|
</h3>
|
||||||
<div class="grid grid-cols-1 xl:grid-cols-12 gap-6 items-end">
|
<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 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||||
>封禁类型</label
|
>封禁类型</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
|
<button
|
||||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
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'"
|
@click="newItem.type = 'SONG'"
|
||||||
>
|
>
|
||||||
<Music :size="12" /> 具体歌曲
|
<Music :size="12" /> 歌曲
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="flex-1 flex items-center justify-center gap-2 py-2 rounded-md text-[11px] font-bold transition-all"
|
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'"
|
@click="newItem.type = 'KEYWORD'"
|
||||||
>
|
>
|
||||||
<Type :size="12" /> 关键词
|
<Type :size="12" /> 关键词
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="xl:col-span-4 space-y-2">
|
<div class="xl:col-span-4 space-y-2">
|
||||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
|
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1">
|
||||||
{{ newItem.type === 'SONG' ? '歌曲名称 - 艺术家' : '关键词内容' }}
|
{{ typeValueLabel }}
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
v-model="newItem.value"
|
v-model="newItem.value"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="
|
:placeholder="typePlaceholder"
|
||||||
newItem.type === 'SONG' ? '例如: 歌曲名 - 艺术家' : '例如: 敏感词、特定风格'
|
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"
|
||||||
"
|
|
||||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-lg px-5 py-3 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30 placeholder:text-zinc-700 transition-all"
|
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<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">
|
<div class="xl:col-span-4 space-y-2">
|
||||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||||
>封禁原因 (可选)</label
|
>封禁原因 (可选)</label
|
||||||
@@ -64,7 +88,7 @@
|
|||||||
v-model="newItem.reason"
|
v-model="newItem.reason"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="简述加入黑名单的原因"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -87,7 +111,7 @@
|
|||||||
|
|
||||||
<!-- 筛选和搜索 -->
|
<!-- 筛选和搜索 -->
|
||||||
<div
|
<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">
|
<div class="flex-1 relative w-full">
|
||||||
<Search class="absolute left-4 top-1/2 -translate-y-1/2 text-zinc-700" :size="18" />
|
<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"
|
v-model="filters.search"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="在黑名单中搜索..."
|
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"
|
@input="debounceSearch"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,12 +127,12 @@
|
|||||||
<CustomSelect
|
<CustomSelect
|
||||||
label="类型筛选"
|
label="类型筛选"
|
||||||
:value="typeFilterLabel"
|
:value="typeFilterLabel"
|
||||||
:options="['全部类型', '具体歌曲', '关键词']"
|
:options="['全部类型', '具体歌曲', '关键词', '语种', '曲风']"
|
||||||
class="flex-1 md:w-48"
|
class="flex-1 md:w-48"
|
||||||
@change="handleTypeFilterChange"
|
@change="handleTypeFilterChange"
|
||||||
/>
|
/>
|
||||||
<button
|
<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"
|
@click="loadBlacklist"
|
||||||
>
|
>
|
||||||
<Filter :size="18" />
|
<Filter :size="18" />
|
||||||
@@ -136,35 +160,29 @@
|
|||||||
<div
|
<div
|
||||||
v-for="item in blacklist"
|
v-for="item in blacklist"
|
||||||
:key="item.id"
|
: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="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-800' : 'border-zinc-800/40 opacity-60']"
|
:class="[item.isActive ? 'border-zinc-200' : 'border-zinc-200 opacity-60']"
|
||||||
>
|
>
|
||||||
<div class="flex-1 flex items-start gap-5">
|
<div class="flex-1 flex items-start gap-5">
|
||||||
<div
|
<div
|
||||||
class="shrink-0 w-12 h-12 rounded-lg flex items-center justify-center border transition-all"
|
class="shrink-0 w-12 h-12 rounded-lg flex items-center justify-center border transition-all"
|
||||||
:class="[
|
:class="getTypeIconClass(item.type)"
|
||||||
item.type === 'SONG'
|
|
||||||
? 'bg-blue-600/10 text-blue-500 border-blue-500/20 shadow-lg shadow-blue-900/5'
|
|
||||||
: 'bg-purple-600/10 text-purple-500 border-purple-500/20 shadow-lg shadow-purple-900/5'
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
<Music v-if="item.type === 'SONG'" :size="22" />
|
<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>
|
||||||
|
|
||||||
<div class="space-y-1.5 min-w-0">
|
<div class="space-y-1.5 min-w-0">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<span
|
<span
|
||||||
class="text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded border"
|
class="text-[9px] font-black uppercase tracking-widest px-2 py-0.5 rounded border"
|
||||||
:class="[
|
:class="getTypeBadgeClass(item.type)"
|
||||||
item.type === 'SONG'
|
|
||||||
? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
|
|
||||||
: 'bg-purple-600/10 text-purple-400 border-purple-500/20'
|
|
||||||
]"
|
|
||||||
>
|
>
|
||||||
{{ item.type === 'SONG' ? '具体歌曲' : '关键词' }}
|
{{ getTypeLabel(item.type) }}
|
||||||
</span>
|
</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 }}
|
{{ item.value }}
|
||||||
</h5>
|
</h5>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,14 +203,14 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="flex items-center justify-end gap-3 pt-4 lg:pt-0 border-t lg:border-t-0 border-zinc-800/50"
|
class="flex items-center justify-end gap-3 pt-4 lg:pt-0 border-t lg:border-t-0 border-zinc-200"
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:disabled="loading"
|
: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="flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all"
|
||||||
:class="[
|
:class="[
|
||||||
item.isActive
|
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'
|
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
|
||||||
]"
|
]"
|
||||||
@click="toggleItemStatus(item)"
|
@click="toggleItemStatus(item)"
|
||||||
@@ -202,7 +220,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="loading"
|
: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)"
|
@click="deleteItem(item)"
|
||||||
>
|
>
|
||||||
<Trash2 :size="16" />
|
<Trash2 :size="16" />
|
||||||
@@ -217,7 +235,7 @@
|
|||||||
class="py-20 flex flex-col items-center justify-center space-y-4"
|
class="py-20 flex flex-col items-center justify-center space-y-4"
|
||||||
>
|
>
|
||||||
<div
|
<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>
|
<p class="text-zinc-500 text-xs font-bold uppercase tracking-widest">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,10 +243,10 @@
|
|||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="blacklist.length === 0"
|
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
|
<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" />
|
<Ban :size="32" />
|
||||||
</div>
|
</div>
|
||||||
@@ -245,19 +263,19 @@
|
|||||||
<div v-if="pagination.pages > 1" class="flex items-center justify-center gap-4 mt-8">
|
<div v-if="pagination.pages > 1" class="flex items-center justify-center gap-4 mt-8">
|
||||||
<button
|
<button
|
||||||
:disabled="pagination.page <= 1 || loading"
|
: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)"
|
@click="changePage(pagination.page - 1)"
|
||||||
>
|
>
|
||||||
<ChevronLeft :size="20" />
|
<ChevronLeft :size="20" />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<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 }} 页
|
第 {{ pagination.page }} 页 / 共 {{ pagination.pages }} 页
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
:disabled="pagination.page >= pagination.pages || loading"
|
: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)"
|
@click="changePage(pagination.page + 1)"
|
||||||
>
|
>
|
||||||
<ChevronRight :size="20" />
|
<ChevronRight :size="20" />
|
||||||
@@ -276,10 +294,10 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="showDeleteDialog"
|
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
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="flex flex-col items-center py-8 space-y-6 text-center px-8">
|
<div class="flex flex-col items-center py-8 space-y-6 text-center px-8">
|
||||||
@@ -289,16 +307,16 @@
|
|||||||
<Trash2 :size="28" />
|
<Trash2 :size="28" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-2">
|
<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">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 w-full pt-4">
|
<div class="flex gap-3 w-full pt-4">
|
||||||
<button
|
<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"
|
@click="showDeleteDialog = false"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -318,7 +336,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { onMounted, reactive, ref, computed } from 'vue'
|
import { onMounted, reactive, ref, computed } from 'vue'
|
||||||
import {
|
import {
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
@@ -334,7 +352,9 @@ import {
|
|||||||
Ban,
|
Ban,
|
||||||
ChevronLeft,
|
ChevronLeft,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
ChevronDown
|
ChevronDown,
|
||||||
|
Languages,
|
||||||
|
Disc3
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import { useToast } from '~/composables/useToast'
|
import { useToast } from '~/composables/useToast'
|
||||||
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
import CustomSelect from '~/components/UI/Common/CustomSelect.vue'
|
||||||
@@ -371,9 +391,65 @@ const pagination = reactive({
|
|||||||
const typeFilterLabel = computed(() => {
|
const typeFilterLabel = computed(() => {
|
||||||
if (filters.type === 'SONG') return '具体歌曲'
|
if (filters.type === 'SONG') return '具体歌曲'
|
||||||
if (filters.type === 'KEYWORD') return '关键词'
|
if (filters.type === 'KEYWORD') return '关键词'
|
||||||
|
if (filters.type === 'LANGUAGE') return '语种'
|
||||||
|
if (filters.type === 'GENRE') return '曲风'
|
||||||
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
|
let searchTimeout = null
|
||||||
|
|
||||||
// 加载黑名单
|
// 加载黑名单
|
||||||
@@ -404,10 +480,11 @@ const loadBlacklist = async () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 处理类型筛选变更
|
|
||||||
const handleTypeFilterChange = (label) => {
|
const handleTypeFilterChange = (label) => {
|
||||||
if (label === '具体歌曲') filters.type = 'SONG'
|
if (label === '具体歌曲') filters.type = 'SONG'
|
||||||
else if (label === '关键词') filters.type = 'KEYWORD'
|
else if (label === '关键词') filters.type = 'KEYWORD'
|
||||||
|
else if (label === '语种') filters.type = 'LANGUAGE'
|
||||||
|
else if (label === '曲风') filters.type = 'GENRE'
|
||||||
else filters.type = ''
|
else filters.type = ''
|
||||||
|
|
||||||
pagination.page = 1
|
pagination.page = 1
|
||||||
@@ -569,7 +646,7 @@ onMounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--bg-tertiary, #27272a);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,12 +2,12 @@
|
|||||||
<div class="max-w-[1200px] mx-auto space-y-6 pb-24 px-2">
|
<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 class="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<div>
|
<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>
|
<p class="text-xs text-zinc-500 mt-1 font-medium">管理前台“更新日志”页面的发布内容</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<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"
|
:disabled="loading"
|
||||||
@click="load"
|
@click="load"
|
||||||
>
|
>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
|
<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>
|
<p class="text-zinc-500 text-sm">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -31,20 +31,20 @@
|
|||||||
<div
|
<div
|
||||||
v-for="item in entries"
|
v-for="item in entries"
|
||||||
:key="item.id"
|
: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="min-w-0">
|
||||||
<div class="flex items-center gap-2 flex-wrap">
|
<div class="flex items-center gap-2 flex-wrap">
|
||||||
<span
|
<span
|
||||||
class="px-2.5 py-1 rounded-lg text-[10px] font-black uppercase tracking-widest"
|
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 ? '已发布' : '草稿' }}
|
{{ item.published ? '已发布' : '草稿' }}
|
||||||
</span>
|
</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 }}
|
v{{ item.version }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
<p class="text-[10px] text-zinc-500 mt-2">
|
<p class="text-[10px] text-zinc-500 mt-2">
|
||||||
{{ formatDate(item.publishedAt || item.createdAt) }}
|
{{ formatDate(item.publishedAt || item.createdAt) }}
|
||||||
@@ -53,13 +53,13 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<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"
|
||||||
@click="openEdit(item)"
|
@click="openEdit(item)"
|
||||||
>
|
>
|
||||||
<Pencil :size="14" class="inline-block mr-1" /> 编辑
|
<Pencil :size="14" class="inline-block mr-1" /> 编辑
|
||||||
</button>
|
</button>
|
||||||
<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"
|
:disabled="savingId === item.id"
|
||||||
@click="togglePublish(item)"
|
@click="togglePublish(item)"
|
||||||
>
|
>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
{{ item.published ? '取消发布' : '发布' }}
|
{{ item.published ? '取消发布' : '发布' }}
|
||||||
</button>
|
</button>
|
||||||
<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"
|
:disabled="savingId === item.id"
|
||||||
@click="remove(item)"
|
@click="remove(item)"
|
||||||
>
|
>
|
||||||
@@ -83,11 +83,11 @@
|
|||||||
|
|
||||||
<Teleport to="body">
|
<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 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="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-800 flex items-center justify-between">
|
<div class="px-5 py-4 border-b border-zinc-200 flex items-center justify-between">
|
||||||
<p class="text-sm font-black text-zinc-100">{{ editingId ? '编辑更新日志' : '新建更新日志' }}</p>
|
<p class="text-sm font-black text-zinc-900">{{ editingId ? '编辑更新日志' : '新建更新日志' }}</p>
|
||||||
<button
|
<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"
|
@click="closeModal"
|
||||||
>
|
>
|
||||||
<X :size="16" class="mx-auto" />
|
<X :size="16" class="mx-auto" />
|
||||||
@@ -98,11 +98,11 @@
|
|||||||
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">版本号</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1 block mb-2">标题</label>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -111,23 +111,23 @@
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="form.content"
|
v-model="form.content"
|
||||||
:rows="10"
|
: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="支持纯文本(建议用分点)。"
|
placeholder="支持纯文本(建议用分点)。"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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>
|
<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>
|
<p class="text-[10px] text-zinc-500 mt-0.5">发布后前台更新日志页面可见</p>
|
||||||
</div>
|
</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>
|
</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
|
<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"
|
@click="closeModal"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
class="flex flex-col items-center justify-center min-h-[60vh] space-y-6"
|
class="flex flex-col items-center justify-center min-h-[60vh] space-y-6"
|
||||||
>
|
>
|
||||||
<div class="relative w-24 h-24">
|
<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 border-4 border-t-blue-500 rounded-full animate-spin" />
|
||||||
<div class="absolute inset-0 flex items-center justify-center">
|
<div class="absolute inset-0 flex items-center justify-center">
|
||||||
<Activity class="text-blue-500 animate-pulse" :size="32" />
|
<Activity class="text-blue-500 animate-pulse" :size="32" />
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
<h3 class="text-xl font-black text-white tracking-tight">数据加载失败</h3>
|
<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>
|
<p class="text-sm text-zinc-500 mt-2 max-w-md">{{ error }}</p>
|
||||||
<button
|
<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"
|
@click="refreshAllData"
|
||||||
>
|
>
|
||||||
<RefreshCw :size="16" />
|
<RefreshCw :size="16" />
|
||||||
@@ -52,16 +52,16 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
:disabled="isLoading"
|
: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"
|
@click="refreshAllData"
|
||||||
>
|
>
|
||||||
<RefreshCw :size="18" :class="{ 'animate-spin': isLoading }" />
|
<RefreshCw :size="18" :class="{ 'animate-spin': isLoading }" />
|
||||||
</button>
|
</button>
|
||||||
<div
|
<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" />
|
<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
|
>实时模式</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,11 +82,11 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(stat, i) in kpiStats"
|
v-for="(stat, i) in kpiStats"
|
||||||
:key="i"
|
: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="flex justify-between items-start">
|
||||||
<div
|
<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" />
|
<component :is="stat.icon" :size="20" />
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-4">
|
<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) }}
|
{{ formatNumber(stat.value) }}
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] mt-1">
|
<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="lg:col-span-12 grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<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 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"
|
@mouseenter="handleMouseEnter"
|
||||||
@mouseleave="handleMouseLeave"
|
@mouseleave="handleMouseLeave"
|
||||||
>
|
>
|
||||||
@@ -142,7 +142,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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">
|
<div class="relative z-10">
|
||||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||||
@@ -166,7 +166,7 @@
|
|||||||
|
|
||||||
<!-- 趋势分析图表卡片 -->
|
<!-- 趋势分析图表卡片 -->
|
||||||
<div
|
<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 class="flex items-center justify-between mb-10">
|
||||||
<div>
|
<div>
|
||||||
@@ -196,7 +196,7 @@
|
|||||||
class="flex-1 flex flex-col items-center justify-center space-y-4"
|
class="flex-1 flex flex-col items-center justify-center space-y-4"
|
||||||
>
|
>
|
||||||
<div
|
<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">
|
<p class="text-xs font-black text-zinc-600 uppercase tracking-widest">
|
||||||
正在加载趋势数据...
|
正在加载趋势数据...
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
{{ panelStates.trends.error }}
|
{{ panelStates.trends.error }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
@click="loadTrends"
|
||||||
>
|
>
|
||||||
立即重试
|
立即重试
|
||||||
@@ -233,10 +233,10 @@
|
|||||||
:style="{
|
:style="{
|
||||||
height: `${(item.count / Math.max(...trendData.map((d) => d.count), 1)) * 100}%`
|
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
|
<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="{
|
:style="{
|
||||||
bottom: `${(item.count / Math.max(...trendData.map((d) => d.count), 1)) * 100}%`
|
bottom: `${(item.count / Math.max(...trendData.map((d) => d.count), 1)) * 100}%`
|
||||||
}"
|
}"
|
||||||
@@ -259,7 +259,7 @@
|
|||||||
|
|
||||||
<!-- 热门歌曲排行榜 -->
|
<!-- 热门歌曲排行榜 -->
|
||||||
<div
|
<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">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<h3 class="text-xl font-bold flex items-center gap-3 text-white">
|
<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"
|
v-if="panelStates.topSongs.loading && topSongs.length === 0"
|
||||||
class="flex-1 space-y-4"
|
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>
|
||||||
<div
|
<div
|
||||||
v-else-if="panelStates.topSongs.error && topSongs.length === 0"
|
v-else-if="panelStates.topSongs.error && topSongs.length === 0"
|
||||||
@@ -303,7 +303,7 @@
|
|||||||
{{ panelStates.topSongs.error }}
|
{{ panelStates.topSongs.error }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
@click="loadTopSongs"
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
@@ -316,7 +316,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(song, i) in topSongs.slice(0, 5)"
|
v-for="(song, i) in topSongs.slice(0, 5)"
|
||||||
:key="i"
|
: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
|
<div
|
||||||
:class="`w-8 h-8 rounded-lg flex items-center justify-center font-black text-xs ${
|
: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'
|
? 'bg-zinc-300 text-black'
|
||||||
: i === 2
|
: i === 2
|
||||||
? 'bg-amber-800 text-white'
|
? 'bg-amber-800 text-white'
|
||||||
: 'text-zinc-600 border border-zinc-800'
|
: 'text-zinc-600 border border-zinc-200'
|
||||||
}`"
|
}`"
|
||||||
>
|
>
|
||||||
{{ i + 1 }}
|
{{ i + 1 }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h4
|
<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 }}
|
{{ song.title }}
|
||||||
</h4>
|
</h4>
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||||||
<!-- 活跃用户排行榜 -->
|
<!-- 活跃用户排行榜 -->
|
||||||
<div
|
<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">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<h3 class="text-xl font-bold flex items-center gap-3 text-white">
|
<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"
|
v-if="panelStates.activeUsers.loading && activeUsers.length === 0"
|
||||||
class="flex-1 space-y-4"
|
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>
|
||||||
<div
|
<div
|
||||||
v-else-if="panelStates.activeUsers.error && activeUsers.length === 0"
|
v-else-if="panelStates.activeUsers.error && activeUsers.length === 0"
|
||||||
@@ -387,7 +387,7 @@
|
|||||||
{{ panelStates.activeUsers.error }}
|
{{ panelStates.activeUsers.error }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
@click="loadActiveUsers"
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
@@ -401,31 +401,31 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(user, i) in activeUsers.slice(0, 5)"
|
v-for="(user, i) in activeUsers.slice(0, 5)"
|
||||||
:key="i"
|
: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">
|
<div class="flex items-center gap-4 relative z-10">
|
||||||
<img
|
<img
|
||||||
v-if="user.avatarUrl && !failedAvatars[user.id]"
|
v-if="user.avatarUrl && !failedAvatars[user.id]"
|
||||||
:src="user.avatarUrl"
|
:src="user.avatarUrl"
|
||||||
:alt="user.name"
|
: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)"
|
@error="handleAvatarError($event, user)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-else
|
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) }}
|
{{ user.name.charAt(0) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<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">
|
<p class="text-xs text-zinc-600 font-medium mt-1 truncate">
|
||||||
{{ user.contributions }}首投稿 · {{ user.likes }}次点赞
|
{{ user.contributions }}首投稿 · {{ user.likes }}次点赞
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-right shrink-0">
|
<div class="text-right shrink-0">
|
||||||
<span
|
<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
|
>{{ user.activityScore }}</span
|
||||||
>
|
>
|
||||||
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
|
<p class="text-[10px] font-black text-zinc-700 uppercase tracking-widest">
|
||||||
@@ -451,7 +451,7 @@
|
|||||||
|
|
||||||
<!-- 学期对比分析 -->
|
<!-- 学期对比分析 -->
|
||||||
<div
|
<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">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<h3 class="text-xl font-bold flex items-center gap-3 text-white">
|
<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"
|
v-if="panelStates.semesterComparison.loading && semesterComparison.length === 0"
|
||||||
class="flex-1 space-y-4"
|
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>
|
||||||
<div
|
<div
|
||||||
v-else-if="panelStates.semesterComparison.error && semesterComparison.length === 0"
|
v-else-if="panelStates.semesterComparison.error && semesterComparison.length === 0"
|
||||||
@@ -481,7 +481,7 @@
|
|||||||
{{ panelStates.semesterComparison.error }}
|
{{ panelStates.semesterComparison.error }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<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"
|
@click="loadSemesterComparison"
|
||||||
>
|
>
|
||||||
重试
|
重试
|
||||||
@@ -494,10 +494,10 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(sem, i) in semesterComparison"
|
v-for="(sem, i) in semesterComparison"
|
||||||
:key="i"
|
: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">
|
<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
|
sem.semester
|
||||||
}}</span>
|
}}</span>
|
||||||
<span
|
<span
|
||||||
@@ -509,19 +509,19 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-3 gap-4">
|
<div class="grid grid-cols-3 gap-4">
|
||||||
<div>
|
<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 class="text-[9px] font-black text-zinc-600 uppercase tracking-tighter mt-1">
|
||||||
歌曲总量
|
歌曲总量
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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 class="text-[9px] font-black text-zinc-600 uppercase tracking-tighter mt-1">
|
||||||
排期总数
|
排期总数
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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 class="text-[9px] font-black text-zinc-600 uppercase tracking-tighter mt-1">
|
||||||
获赞总数
|
获赞总数
|
||||||
</p>
|
</p>
|
||||||
@@ -547,16 +547,16 @@
|
|||||||
@mouseleave="handleTooltipMouseLeave"
|
@mouseleave="handleTooltipMouseLeave"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<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">
|
<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>
|
<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="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" />
|
<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
|
>{{ realtimeStats.activeUsers }} 在线</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -569,28 +569,28 @@
|
|||||||
<div
|
<div
|
||||||
v-for="user in realtimeStats.activeUsersList.slice(0, 5)"
|
v-for="user in realtimeStats.activeUsersList.slice(0, 5)"
|
||||||
:key="user.id"
|
: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
|
<img
|
||||||
v-if="user.avatarUrl && !failedAvatars[user.id]"
|
v-if="user.avatarUrl && !failedAvatars[user.id]"
|
||||||
:src="user.avatarUrl"
|
:src="user.avatarUrl"
|
||||||
:alt="user.name"
|
: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)"
|
@error="handleAvatarError($event, user)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-else
|
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) }}
|
{{ user.name.charAt(0) }}
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<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">
|
<div class="text-[10px] text-zinc-500 font-medium truncate max-h-[14px] overflow-hidden">
|
||||||
@{{ user.username }}
|
@{{ user.username }}
|
||||||
</div>
|
</div>
|
||||||
</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 }}
|
{{ user.lastActive }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -605,7 +605,7 @@
|
|||||||
<p class="text-xs font-black uppercase tracking-widest truncate max-h-[16px] overflow-hidden">暂无在线用户</p>
|
<p class="text-xs font-black uppercase tracking-widest truncate max-h-[16px] overflow-hidden">暂无在线用户</p>
|
||||||
</div>
|
</div>
|
||||||
</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 class="text-[10px] font-black text-zinc-600 uppercase tracking-widest truncate max-h-[14px] overflow-hidden"
|
||||||
>实时数据同步中</span
|
>实时数据同步中</span
|
||||||
>
|
>
|
||||||
@@ -1147,7 +1147,7 @@ const formatDateShort = (dateStr) => {
|
|||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-track {
|
.custom-scrollbar::-webkit-scrollbar-track {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(0, 0, 0, 0.04);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="max-w-[1200px] mx-auto space-y-8 pb-20 px-2">
|
<div class="max-w-[1200px] mx-auto space-y-8 pb-20 px-2">
|
||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="space-y-1">
|
<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 class="text-xs text-zinc-500 font-medium">
|
||||||
执行系统底层维护任务,包括备份、恢复及全局数据重置
|
执行系统底层维护任务,包括备份、恢复及全局数据重置
|
||||||
</p>
|
</p>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
v-for="card in cards"
|
v-for="card in cards"
|
||||||
:key="card.id"
|
:key="card.id"
|
||||||
:class="[
|
: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' : ''
|
card.isDanger ? 'hover:border-rose-500/20' : ''
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div
|
<div
|
||||||
:class="[
|
: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
|
card.isDanger
|
||||||
? 'text-rose-500 border-rose-500/10'
|
? 'text-rose-500 border-rose-500/10'
|
||||||
: `text-${card.color}-500 border-${card.color}-500/10 shadow-lg`
|
: `text-${card.color}-500 border-${card.color}-500/10 shadow-lg`
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex-1 space-y-2">
|
<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 }}
|
{{ card.title }}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs text-zinc-500 leading-relaxed font-medium">
|
<p class="text-xs text-zinc-500 leading-relaxed font-medium">
|
||||||
@@ -48,10 +48,10 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
:disabled="isLoading(card.id)"
|
: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="
|
:class="
|
||||||
card.isDanger
|
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)"
|
@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">
|
<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]" />
|
<AlertCircle class="text-blue-500 shrink-0 mt-0.5 w-[18px] h-[18px]" />
|
||||||
<div class="space-y-1">
|
<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 class="text-[10px] text-zinc-500 leading-relaxed">
|
||||||
建议每周进行一次全量备份。在执行“恢复备份”或“重置数据库”前,请务必先创建一份当前的数据备份,以免误操作造成不可挽回的损失。
|
建议每周进行一次全量备份。在执行“恢复备份”或“重置数据库”前,请务必先创建一份当前的数据备份,以免误操作造成不可挽回的损失。
|
||||||
</p>
|
</p>
|
||||||
@@ -89,12 +89,12 @@
|
|||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||||
<div
|
<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-zinc-100 tracking-tight">创建数据库备份</h3>
|
<h3 class="text-xl font-black text-zinc-900 tracking-tight">创建数据库备份</h3>
|
||||||
<button
|
<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'"
|
@click="activeModal = 'none'"
|
||||||
>
|
>
|
||||||
<X class="w-5 h-5" />
|
<X class="w-5 h-5" />
|
||||||
@@ -109,18 +109,18 @@
|
|||||||
<label
|
<label
|
||||||
v-for="(item, i) in backupOptions"
|
v-for="(item, i) in backupOptions"
|
||||||
:key="i"
|
: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">
|
<div class="shrink-0 mt-0.5">
|
||||||
<input
|
<input
|
||||||
v-model="createForm[item.key]"
|
v-model="createForm[item.key]"
|
||||||
type="checkbox"
|
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>
|
||||||
<div>
|
<div>
|
||||||
<p
|
<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 }}
|
{{ item.label }}
|
||||||
</p>
|
</p>
|
||||||
@@ -135,9 +135,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<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'"
|
@click="activeModal = 'none'"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -160,12 +160,12 @@
|
|||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||||
<div
|
<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-zinc-100 tracking-tight">恢复数据库备份</h3>
|
<h3 class="text-xl font-black text-zinc-900 tracking-tight">恢复数据库备份</h3>
|
||||||
<button
|
<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'"
|
@click="activeModal = 'none'"
|
||||||
>
|
>
|
||||||
<X class="w-5 h-5" />
|
<X class="w-5 h-5" />
|
||||||
@@ -173,7 +173,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="p-8 space-y-6">
|
<div class="p-8 space-y-6">
|
||||||
<div
|
<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()"
|
@click="$refs.fileInput.click()"
|
||||||
@dragover.prevent
|
@dragover.prevent
|
||||||
@drop.prevent="handleFileDrop"
|
@drop.prevent="handleFileDrop"
|
||||||
@@ -181,11 +181,11 @@
|
|||||||
<Upload
|
<Upload
|
||||||
class="w-8 h-8 text-zinc-700 mb-4 group-hover:text-emerald-500 transition-colors"
|
class="w-8 h-8 text-zinc-700 mb-4 group-hover:text-emerald-500 transition-colors"
|
||||||
/>
|
/>
|
||||||
<h5 class="text-sm font-bold text-zinc-300">
|
<h5 class="text-sm font-bold text-zinc-600">
|
||||||
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
|
{{ selectedFile ? selectedFile.name : '点击选择或拖拽备份文件' }}
|
||||||
</h5>
|
</h5>
|
||||||
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
|
<p class="text-[10px] text-zinc-600 font-bold uppercase mt-1 tracking-widest">
|
||||||
仅支持 VoiceHub 导出的 .json 格式
|
仅支持宝一外校园点歌系统导出的 .json 格式
|
||||||
</p>
|
</p>
|
||||||
<input
|
<input
|
||||||
ref="fileInput"
|
ref="fileInput"
|
||||||
@@ -205,15 +205,15 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'p-4 border rounded-xl text-left transition-all',
|
'p-4 border rounded-xl text-left transition-all',
|
||||||
restoreForm.mode === 'merge'
|
restoreForm.mode === 'merge'
|
||||||
? 'bg-zinc-950 border-emerald-500/30'
|
? 'bg-white border-emerald-200'
|
||||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
: 'bg-white border-zinc-200 hover:border-zinc-200'
|
||||||
]"
|
]"
|
||||||
@click="restoreForm.mode = 'merge'"
|
@click="restoreForm.mode = 'merge'"
|
||||||
>
|
>
|
||||||
<h6
|
<h6
|
||||||
:class="[
|
:class="[
|
||||||
'text-xs font-bold',
|
'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="[
|
:class="[
|
||||||
'p-4 border rounded-xl text-left transition-all',
|
'p-4 border rounded-xl text-left transition-all',
|
||||||
restoreForm.mode === 'replace'
|
restoreForm.mode === 'replace'
|
||||||
? 'bg-zinc-950 border-emerald-500/30'
|
? 'bg-white border-emerald-200'
|
||||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
: 'bg-white border-zinc-200 hover:border-zinc-200'
|
||||||
]"
|
]"
|
||||||
@click="restoreForm.mode = 'replace'"
|
@click="restoreForm.mode = 'replace'"
|
||||||
>
|
>
|
||||||
<h6
|
<h6
|
||||||
:class="[
|
:class="[
|
||||||
'text-xs font-bold',
|
'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
|
<div
|
||||||
v-if="restoreForm.mode === 'replace' && hasSuperAdminInBackup"
|
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">
|
<label class="flex items-start gap-3 cursor-pointer">
|
||||||
<input
|
<input
|
||||||
@@ -253,7 +253,7 @@
|
|||||||
class="mt-0.5 accent-emerald-500"
|
class="mt-0.5 accent-emerald-500"
|
||||||
>
|
>
|
||||||
<div>
|
<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">
|
<p class="text-[10px] text-zinc-500 mt-1">
|
||||||
关闭时将保留当前超级管理员账号及其第三方绑定、2FA等关联数据
|
关闭时将保留当前超级管理员账号及其第三方绑定、2FA等关联数据
|
||||||
</p>
|
</p>
|
||||||
@@ -270,9 +270,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<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'"
|
@click="activeModal = 'none'"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -295,12 +295,12 @@
|
|||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||||
<div
|
<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-zinc-100 tracking-tight">重置数据表序列</h3>
|
<h3 class="text-xl font-black text-zinc-900 tracking-tight">重置数据表序列</h3>
|
||||||
<button
|
<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'"
|
@click="activeModal = 'none'"
|
||||||
>
|
>
|
||||||
<X class="w-5 h-5" />
|
<X class="w-5 h-5" />
|
||||||
@@ -318,14 +318,14 @@
|
|||||||
/>
|
/>
|
||||||
</div>
|
</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="flex items-center gap-3">
|
||||||
<div
|
<div
|
||||||
class="w-8 h-8 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center"
|
class="w-8 h-8 rounded-lg bg-amber-500/10 text-amber-500 flex items-center justify-center"
|
||||||
>
|
>
|
||||||
<AlertCircle class="w-4 h-4" />
|
<AlertCircle class="w-4 h-4" />
|
||||||
</div>
|
</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>
|
</h6>
|
||||||
</div>
|
</div>
|
||||||
@@ -336,9 +336,9 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<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'"
|
@click="activeModal = 'none'"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -361,12 +361,12 @@
|
|||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="activeModal = 'none'" />
|
||||||
<div
|
<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>
|
<h3 class="text-xl font-black text-rose-500 tracking-tight">危险操作:重置数据库</h3>
|
||||||
<button
|
<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'"
|
@click="activeModal = 'none'"
|
||||||
>
|
>
|
||||||
<X class="w-5 h-5" />
|
<X class="w-5 h-5" />
|
||||||
@@ -382,7 +382,7 @@
|
|||||||
</h4>
|
</h4>
|
||||||
<p class="text-xs text-zinc-500 mt-2 font-medium leading-relaxed">
|
<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
|
>歌曲、投稿记录、排期文件、通知、日志及除您以外的用户账号</span
|
||||||
>。
|
>。
|
||||||
</p>
|
</p>
|
||||||
@@ -395,7 +395,7 @@
|
|||||||
请输入以下代码以确认操作
|
请输入以下代码以确认操作
|
||||||
</label>
|
</label>
|
||||||
<div
|
<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 }}
|
{{ CONFIRM_CODE }}
|
||||||
</div>
|
</div>
|
||||||
@@ -403,20 +403,20 @@
|
|||||||
v-model="resetConfirmText"
|
v-model="resetConfirmText"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="在此输入上述代码..."
|
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>
|
||||||
|
|
||||||
<div class="grid grid-cols-2 gap-3 pt-2">
|
<div class="grid grid-cols-2 gap-3 pt-2">
|
||||||
<button
|
<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'"
|
@click="activeModal = 'none'"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="resetConfirmText !== CONFIRM_CODE || resetLoading"
|
: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="
|
:class="
|
||||||
resetConfirmText === CONFIRM_CODE
|
resetConfirmText === CONFIRM_CODE
|
||||||
? 'bg-rose-600 hover:bg-rose-500 text-white shadow-rose-900/20 active:scale-95'
|
? 'bg-rose-600 hover:bg-rose-500 text-white shadow-rose-900/20 active:scale-95'
|
||||||
|
|||||||
@@ -1,22 +1,22 @@
|
|||||||
<template>
|
<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="flex items-center gap-3">
|
||||||
<div
|
<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" />
|
<Mail :size="20" class="text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-[10px] text-zinc-500 mt-0.5">自定义系统邮件的主题与内容,支持变量替换</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
v-if="selected"
|
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"
|
:disabled="saving"
|
||||||
@click="doPreview"
|
@click="doPreview"
|
||||||
>
|
>
|
||||||
@@ -35,14 +35,14 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-12 min-h-[650px]">
|
<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="lg:col-span-3 border-r border-zinc-200 bg-zinc-50/10 flex flex-col">
|
||||||
<div class="p-4 border-b border-zinc-800/50">
|
<div class="p-4 border-b border-zinc-200">
|
||||||
<div class="relative">
|
<div class="relative">
|
||||||
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600" />
|
<Search :size="14" class="absolute left-3 top-1/2 -translate-y-1/2 text-zinc-600" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索模板..."
|
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>
|
||||||
</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="w-full group flex flex-col gap-1 p-3 rounded-2xl transition-all text-left border"
|
||||||
:class="[
|
:class="[
|
||||||
selectedKey === t.key
|
selectedKey === t.key
|
||||||
? 'bg-blue-600/10 border-blue-500/20'
|
? 'bg-blue-50 border-blue-200'
|
||||||
: 'bg-transparent border-transparent hover:bg-zinc-800/40'
|
: 'bg-transparent border-transparent hover:bg-zinc-100/40'
|
||||||
]"
|
]"
|
||||||
@click="select(t)"
|
@click="select(t)"
|
||||||
>
|
>
|
||||||
@@ -63,8 +63,8 @@
|
|||||||
class="text-[11px] font-black tracking-tight transition-colors"
|
class="text-[11px] font-black tracking-tight transition-colors"
|
||||||
:class="
|
:class="
|
||||||
selectedKey === t.key
|
selectedKey === t.key
|
||||||
? 'text-blue-400'
|
? 'text-blue-700'
|
||||||
: 'text-zinc-300 group-hover:text-zinc-100'
|
: 'text-zinc-600 group-hover:text-zinc-900'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ t.name }}
|
{{ t.name }}
|
||||||
@@ -72,7 +72,7 @@
|
|||||||
<div class="flex gap-1">
|
<div class="flex gap-1">
|
||||||
<span
|
<span
|
||||||
v-if="t.isBuiltin && !t.isOverridden"
|
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>
|
</span>
|
||||||
@@ -90,7 +90,7 @@
|
|||||||
</div>
|
</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 v-if="selected" class="flex-1 overflow-y-auto custom-scrollbar p-8">
|
||||||
<div class="max-w-4xl mx-auto space-y-8">
|
<div class="max-w-4xl mx-auto space-y-8">
|
||||||
<!-- 基础信息与主题 -->
|
<!-- 基础信息与主题 -->
|
||||||
@@ -102,7 +102,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="form.name"
|
v-model="form.name"
|
||||||
type="text"
|
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>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -116,7 +116,7 @@
|
|||||||
v-model="form.subject"
|
v-model="form.subject"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="请输入邮件主题..."
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
<Info :size="16" class="text-blue-500" />
|
<Info :size="16" class="text-blue-500" />
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-1">
|
<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>
|
</h4>
|
||||||
<p class="text-[11px] text-zinc-500 leading-relaxed font-mono">
|
<p class="text-[11px] text-zinc-500 leading-relaxed font-mono">
|
||||||
@@ -151,13 +151,13 @@
|
|||||||
>HTML 内容</label
|
>HTML 内容</label
|
||||||
>
|
>
|
||||||
<span
|
<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
|
>Handlebars</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="selected.isOverridden"
|
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"
|
@click="restore"
|
||||||
>
|
>
|
||||||
<RotateCcw :size="12" /> 恢复默认配置
|
<RotateCcw :size="12" /> 恢复默认配置
|
||||||
@@ -165,14 +165,14 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="relative group">
|
<div class="relative group">
|
||||||
<div
|
<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" />
|
<Code :size="14" class="text-zinc-500" />
|
||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
v-model="form.html"
|
v-model="form.html"
|
||||||
rows="16"
|
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>..."
|
placeholder="<html>..."
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -184,7 +184,7 @@
|
|||||||
enter-from-class="transform translate-y-4 opacity-0"
|
enter-from-class="transform translate-y-4 opacity-0"
|
||||||
enter-to-class="transform translate-y-0 opacity-100"
|
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">
|
<div class="flex items-center justify-between px-1">
|
||||||
<label
|
<label
|
||||||
class="text-[10px] font-black text-zinc-600 uppercase tracking-widest flex items-center gap-2"
|
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>
|
<span class="text-zinc-400 font-normal normal-case">{{ previewSubject }}</span>
|
||||||
</label>
|
</label>
|
||||||
<button
|
<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 = ''"
|
@click="previewHtml = ''"
|
||||||
>
|
>
|
||||||
隐藏预览
|
隐藏预览
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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" />
|
<iframe :srcdoc="previewHtml" class="w-full h-[500px] border-none" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -213,7 +213,7 @@
|
|||||||
class="flex-1 flex flex-col items-center justify-center text-zinc-700 space-y-4"
|
class="flex-1 flex flex-col items-center justify-center text-zinc-700 space-y-4"
|
||||||
>
|
>
|
||||||
<div
|
<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" />
|
<Mail :size="32" class="text-zinc-800" />
|
||||||
</div>
|
</div>
|
||||||
@@ -336,7 +336,7 @@ onMounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--bg-tertiary, #27272a);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex justify-between items-center">
|
<div class="flex justify-between items-center">
|
||||||
<h3 class="text-lg font-bold text-zinc-100 flex items-center gap-2">
|
<h3 class="text-lg font-bold text-zinc-900 flex items-center gap-2">
|
||||||
<CalendarDays class="w-5 h-5 text-blue-400" />
|
<CalendarDays class="w-5 h-5 text-blue-700" />
|
||||||
期望播出时段设置
|
期望播出时段设置
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
@@ -17,39 +17,39 @@
|
|||||||
<Loader2 class="w-6 h-6 animate-spin mx-auto text-zinc-500" />
|
<Loader2 class="w-6 h-6 animate-spin mx-auto text-zinc-500" />
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div v-else class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<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="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 }}
|
{{ 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>
|
</div>
|
||||||
<button @click="openDeleteDialog(date)" class="text-red-400 hover:text-red-300 p-1 transition-colors" title="删除">
|
<button @click="openDeleteDialog(date)" class="text-red-400 hover:text-red-300 p-1 transition-colors" title="删除">
|
||||||
<Trash2 class="w-4 h-4" />
|
<Trash2 class="w-4 h-4" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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="flex items-center justify-between">
|
||||||
<div class="text-xs text-zinc-500">开放范围</div>
|
<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>
|
||||||
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="text-xs text-zinc-500">序号状态</div>
|
<div class="text-xs text-zinc-500">序号状态</div>
|
||||||
<div class="text-xs flex gap-2">
|
<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-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>
|
||||||
</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">
|
<div class="text-xs font-bold text-zinc-500 mb-2 flex items-center justify-between">
|
||||||
<span>已占用明细</span>
|
<span>已占用明细</span>
|
||||||
<span class="text-red-400">{{ date.takenSequences.length }} 个</span>
|
<span class="text-red-400">{{ date.takenSequences.length }} 个</span>
|
||||||
@@ -61,12 +61,12 @@
|
|||||||
class="flex items-center gap-2 text-[11px]"
|
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="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>
|
<span class="text-zinc-500 truncate" :title="date.takenDetails?.[seq]?.artist">- {{ date.takenDetails?.[seq]?.artist || '未知歌手' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</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 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">
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Tabs -->
|
<!-- 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
|
<button
|
||||||
@click="activeTab = 'auto'"
|
@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>
|
||||||
<button
|
<button
|
||||||
@click="activeTab = 'manual'; nextTick(() => { setTimeout(() => { setStartSeq(manualFormData.startSeq); setEndSeq(manualFormData.endSeq) }, 100) })"
|
@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>
|
</button>
|
||||||
@@ -104,22 +104,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-bold text-zinc-500 mb-1">总序号数量</label>
|
<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">
|
<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 = 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-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">28</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-800 hover:bg-zinc-700 text-zinc-300 rounded transition-colors">35</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>
|
</div>
|
||||||
<div class="p-3 bg-blue-500/10 border border-blue-500/20 rounded-lg">
|
<div class="p-3 bg-blue-500/10 border border-blue-200 rounded-lg">
|
||||||
<p class="text-xs text-blue-400 font-bold mb-1">自动分配预览:</p>
|
<p class="text-xs text-blue-700 font-bold mb-1">自动分配预览:</p>
|
||||||
<p class="text-[10px] text-blue-300/80">
|
<p class="text-[10px] text-blue-300/80">
|
||||||
<span v-if="autoFormData.dates.length === 0">请先选择日期</span>
|
<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>
|
<span v-else>将会把 1 - {{ autoFormData.totalSequences }} 号平均分配到选中的 {{ autoFormData.dates.length }} 天中。每天约 {{ Math.ceil(autoFormData.totalSequences / autoFormData.dates.length) }} 个序号。</span>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 pt-4">
|
<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>
|
<button @click="saveAuto" :disabled="saving || autoFormData.dates.length === 0" class="flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-50">批量保存</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -133,24 +133,24 @@
|
|||||||
<div>
|
<div>
|
||||||
<label class="block text-xs font-bold text-zinc-500 mb-1">开放的空闲序号</label>
|
<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 滚轮 (这里用简化的网格选择代替复杂的滚轮组件以保证稳定性和易用性) -->
|
<!-- 模拟 iOS 滚轮 (这里用简化的网格选择代替复杂的滚轮组件以保证稳定性和易用性) -->
|
||||||
<div class="text-sm font-bold text-zinc-400 mb-3 w-full flex justify-between items-center">
|
<div class="text-sm font-bold text-zinc-400 mb-3 w-full flex justify-between items-center">
|
||||||
<span>选择开放范围</span>
|
<span>选择开放范围</span>
|
||||||
<span class="text-blue-400">{{ manualFormData.startSeq }} - {{ manualFormData.endSeq }}</span>
|
<span class="text-blue-700">{{ manualFormData.startSeq }} - {{ manualFormData.endSeq }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex gap-4 items-center w-full justify-center py-4">
|
<div class="flex gap-4 items-center w-full justify-center py-4">
|
||||||
<div class="flex flex-col items-center flex-1">
|
<div class="flex flex-col items-center flex-1">
|
||||||
<span class="text-[10px] text-zinc-500 mb-2">起始序号</span>
|
<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 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-500/30 pointer-events-none z-10"></div>
|
<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 class="h-[44px]"></div> <!-- Padding top -->
|
||||||
<div
|
<div
|
||||||
v-for="n in 100"
|
v-for="n in 100"
|
||||||
:key="'start-'+n"
|
:key="'start-'+n"
|
||||||
class="h-10 flex items-center justify-center text-lg font-bold snap-center cursor-pointer transition-colors"
|
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)"
|
@click="setStartSeq(n)"
|
||||||
:data-val="n"
|
:data-val="n"
|
||||||
>
|
>
|
||||||
@@ -164,14 +164,14 @@
|
|||||||
|
|
||||||
<div class="flex flex-col items-center flex-1">
|
<div class="flex flex-col items-center flex-1">
|
||||||
<span class="text-[10px] text-zinc-500 mb-2">结束序号</span>
|
<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 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-500/30 pointer-events-none z-10"></div>
|
<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 class="h-[44px]"></div> <!-- Padding top -->
|
||||||
<div
|
<div
|
||||||
v-for="n in 100"
|
v-for="n in 100"
|
||||||
:key="'end-'+n"
|
:key="'end-'+n"
|
||||||
class="h-10 flex items-center justify-center text-lg font-bold snap-center cursor-pointer transition-colors"
|
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)"
|
@click="setEndSeq(n)"
|
||||||
:data-val="n"
|
:data-val="n"
|
||||||
>
|
>
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
<!-- 如果有特定跳过的序号,保留文本框以供进阶编辑 -->
|
<!-- 如果有特定跳过的序号,保留文本框以供进阶编辑 -->
|
||||||
<div class="w-full mt-4">
|
<div class="w-full mt-4">
|
||||||
<div
|
<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"
|
@click="showAdvancedManual = !showAdvancedManual"
|
||||||
>
|
>
|
||||||
<span>进阶:自定义跳过某些序号</span>
|
<span>进阶:自定义跳过某些序号</span>
|
||||||
@@ -193,7 +193,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showAdvancedManual" class="mt-2">
|
<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>
|
<p class="text-[10px] text-zinc-600 mt-1">手动编辑后将以此处文本框内容为准</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -202,7 +202,7 @@
|
|||||||
<p class="text-[10px] text-zinc-600 mt-2">注意:手动配置会覆盖该日期原有的设置</p>
|
<p class="text-[10px] text-zinc-600 mt-2">注意:手动配置会覆盖该日期原有的设置</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 pt-4">
|
<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>
|
<button @click="saveManual" :disabled="saving || !manualFormData.date" class="flex-1 py-2 bg-blue-600 text-white rounded-lg text-sm font-bold disabled:opacity-50">保存</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="max-w-[1400px] mx-auto space-y-8 pb-20">
|
<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 class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||||
<div>
|
<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>
|
<p class="text-xs text-zinc-500 mt-1">即时推送系统公告、活动信息或重要提醒至指定群体</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -10,7 +10,7 @@
|
|||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8 items-start">
|
<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="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-6">
|
||||||
<!-- 标题 -->
|
<!-- 标题 -->
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
v-model="form.title"
|
v-model="form.title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="请输入通知标题"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -33,12 +33,12 @@
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="form.content"
|
v-model="form.content"
|
||||||
placeholder="请输入通知详情内容..."
|
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>
|
||||||
|
|
||||||
<!-- 范围选择 -->
|
<!-- 范围选择 -->
|
||||||
<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 class="text-[10px] font-black text-zinc-600 uppercase tracking-[0.2em] px-1"
|
||||||
>通知范围</label
|
>通知范围</label
|
||||||
>
|
>
|
||||||
@@ -50,7 +50,7 @@
|
|||||||
'flex items-center gap-2 px-4 py-2.5 rounded-xl text-[11px] font-black transition-all border',
|
'flex items-center gap-2 px-4 py-2.5 rounded-xl text-[11px] font-black transition-all border',
|
||||||
form.scope === opt.id
|
form.scope === opt.id
|
||||||
? 'bg-blue-600 border-blue-500 text-white shadow-lg shadow-blue-900/20'
|
? '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"
|
@click="form.scope = opt.id"
|
||||||
>
|
>
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="form.scope === 'MULTI_CLASS'" key="multi" class="space-y-4">
|
<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">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3 mb-4">
|
||||||
<CustomSelect
|
<CustomSelect
|
||||||
v-model="multiClassForm.grade"
|
v-model="multiClassForm.grade"
|
||||||
@@ -139,7 +139,7 @@
|
|||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
:disabled="!canAddClass"
|
: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"
|
@click="addClassToSelection"
|
||||||
>
|
>
|
||||||
添加
|
添加
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(cls, index) in form.selectedClasses"
|
v-for="(cls, index) in form.selectedClasses"
|
||||||
:key="index"
|
: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"
|
<span class="text-[10px] font-bold text-zinc-400"
|
||||||
>{{ cls.grade }} {{ cls.class }}</span
|
>{{ cls.grade }} {{ cls.class }}</span
|
||||||
@@ -188,7 +188,7 @@
|
|||||||
v-model="userSearchQuery"
|
v-model="userSearchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索并选择用户 (姓名、用户名或ID)..."
|
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"
|
@input="onUserSearchInput"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -196,9 +196,9 @@
|
|||||||
<!-- 搜索结果 -->
|
<!-- 搜索结果 -->
|
||||||
<div
|
<div
|
||||||
v-if="showUserSearchResults && userSearchResults.length > 0"
|
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"
|
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-widest"
|
||||||
>搜索结果 ({{ userSearchResults.length }})</span
|
>搜索结果 ({{ userSearchResults.length }})</span
|
||||||
>
|
>
|
||||||
@@ -206,10 +206,10 @@
|
|||||||
<div
|
<div
|
||||||
v-for="user in userSearchResults"
|
v-for="user in userSearchResults"
|
||||||
:key="user.id"
|
: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>
|
<div>
|
||||||
<h5 class="text-sm font-bold text-zinc-200">
|
<h5 class="text-sm font-bold text-zinc-700">
|
||||||
{{ user.name || user.username }}
|
{{ user.name || user.username }}
|
||||||
</h5>
|
</h5>
|
||||||
<div class="flex items-center gap-2 mt-0.5">
|
<div class="flex items-center gap-2 mt-0.5">
|
||||||
@@ -228,7 +228,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
:disabled="isUserSelected(user.id)"
|
: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)"
|
@click="addUserToSelection(user)"
|
||||||
>
|
>
|
||||||
{{ isUserSelected(user.id) ? '已选择' : '选择' }}
|
{{ isUserSelected(user.id) ? '已选择' : '选择' }}
|
||||||
@@ -253,10 +253,10 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(user, index) in form.selectedUsers"
|
v-for="(user, index) in form.selectedUsers"
|
||||||
:key="user.id"
|
: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">
|
<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
|
user.name || user.username
|
||||||
}}</span>
|
}}</span>
|
||||||
<span class="text-[8px] text-zinc-600">@{{ user.username }}</span>
|
<span class="text-[8px] text-zinc-600">@{{ user.username }}</span>
|
||||||
@@ -302,10 +302,10 @@
|
|||||||
<button
|
<button
|
||||||
:disabled="loading || !isFormValid"
|
:disabled="loading || !isFormValid"
|
||||||
:class="[
|
: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
|
loading || !isFormValid
|
||||||
? 'text-zinc-800 cursor-not-allowed'
|
? '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"
|
@click="sendNotification"
|
||||||
>
|
>
|
||||||
@@ -320,17 +320,17 @@
|
|||||||
<!-- 右侧:预览区 -->
|
<!-- 右侧:预览区 -->
|
||||||
<div class="lg:col-span-5 space-y-6 lg:sticky lg:top-8">
|
<div class="lg:col-span-5 space-y-6 lg:sticky lg:top-8">
|
||||||
<div
|
<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
|
<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" /> 通知预览
|
<Eye :size="16" class="text-blue-500" /> 通知预览
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<div class="flex-1 flex flex-col items-center justify-center p-4">
|
<div class="flex-1 flex flex-col items-center justify-center p-4">
|
||||||
<div
|
<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" />
|
<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 justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<div
|
<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" />
|
<Bell :size="14" />
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||||
>VoiceHub 系统</span
|
>宝一外校园点歌系统</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="text-[9px] text-zinc-700 font-bold uppercase tracking-wider"
|
<span class="text-[9px] text-zinc-700 font-bold uppercase tracking-wider"
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
<h4
|
<h4
|
||||||
:class="[
|
:class="[
|
||||||
'text-sm font-black transition-colors',
|
'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 || '通知标题' }}
|
{{ form.title || '通知标题' }}
|
||||||
@@ -371,7 +371,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="flex items-center gap-1.5">
|
||||||
<Users :size="12" class="text-zinc-700" />
|
<Users :size="12" class="text-zinc-700" />
|
||||||
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-wider">
|
<span class="text-[9px] font-black text-zinc-600 uppercase tracking-wider">
|
||||||
@@ -379,7 +379,7 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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" />
|
<MessageSquare :size="14" />
|
||||||
</button>
|
</button>
|
||||||
@@ -742,7 +742,7 @@ onUnmounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--bg-tertiary, #27272a);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -5,19 +5,19 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(stat, i) in statCards"
|
v-for="(stat, i) in statCards"
|
||||||
:key="i"
|
: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="flex justify-between items-start mb-4">
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'p-3 rounded-xl border',
|
'p-3 rounded-xl border',
|
||||||
stat.color === 'blue'
|
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'
|
: stat.color === 'emerald'
|
||||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||||
: stat.color === 'pink'
|
: stat.color === 'pink'
|
||||||
? 'bg-pink-500/10 text-pink-500 border-pink-500/20'
|
? '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" />
|
<component :is="stat.icon" :size="24" />
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
v-if="stat.trend"
|
v-if="stat.trend"
|
||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-1 text-[11px] font-bold',
|
'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" />
|
<TrendingDown v-if="stat.trendDown" :size="12" />
|
||||||
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<p class="text-zinc-500 text-sm font-medium">{{ stat.label }}</p>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,14 +44,14 @@
|
|||||||
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
<div class="grid grid-cols-1 lg:grid-cols-12 gap-8">
|
||||||
<!-- 最近活动 -->
|
<!-- 最近活动 -->
|
||||||
<div
|
<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">
|
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||||
<Activity :size="18" class="text-blue-500" /> 最近活动
|
<Activity :size="18" class="text-blue-500" /> 最近活动
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<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 }"
|
:class="{ 'animate-spin': loadingActivities }"
|
||||||
@click="refreshActivities"
|
@click="refreshActivities"
|
||||||
>
|
>
|
||||||
@@ -79,7 +79,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(activity, idx) in recentActivities"
|
v-for="(activity, idx) in recentActivities"
|
||||||
:key="idx"
|
: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
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex-1 min-w-0">
|
<div class="flex-1 min-w-0">
|
||||||
<h5
|
<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 }}
|
{{ activity.title }}
|
||||||
</h5>
|
</h5>
|
||||||
@@ -110,9 +110,9 @@
|
|||||||
|
|
||||||
<!-- 系统状态 -->
|
<!-- 系统状态 -->
|
||||||
<div
|
<div
|
||||||
class="lg:col-span-4 bg-zinc-900/40 border border-zinc-800 rounded-xl overflow-hidden flex flex-col shadow-lg shadow-black/20"
|
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">
|
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||||
<ShieldCheck :size="18" class="text-emerald-500" /> 系统状态
|
<ShieldCheck :size="18" class="text-emerald-500" /> 系统状态
|
||||||
</h3>
|
</h3>
|
||||||
@@ -144,16 +144,16 @@
|
|||||||
/>
|
/>
|
||||||
<span class="text-xs font-semibold text-zinc-400">{{ status.label }}</span>
|
<span class="text-xs font-semibold text-zinc-400">{{ status.label }}</span>
|
||||||
</div>
|
</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>
|
||||||
</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">
|
<h3 class="text-lg font-bold flex items-center gap-2">
|
||||||
<Zap :size="18" class="text-yellow-500" /> 快速操作
|
<Zap :size="18" class="text-yellow-500" /> 快速操作
|
||||||
</h3>
|
</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',
|
'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
|
action.primary
|
||||||
? 'bg-blue-600 border-blue-500 text-white shadow-xl shadow-blue-900/20 hover:bg-blue-500'
|
? '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)"
|
@click="navigateTo(action.id)"
|
||||||
>
|
>
|
||||||
@@ -302,9 +302,9 @@ const getActivityStyle = (type) => {
|
|||||||
const styles = {
|
const styles = {
|
||||||
song: { icon: Music, bg: 'bg-purple-500/10 text-purple-500 border-purple-500/20' },
|
song: { icon: Music, bg: 'bg-purple-500/10 text-purple-500 border-purple-500/20' },
|
||||||
user: { icon: Users, bg: 'bg-pink-500/10 text-pink-500 border-pink-500/20' },
|
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 () => {
|
const loadStats = async () => {
|
||||||
@@ -366,7 +366,7 @@ onMounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--bg-tertiary, #27272a);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
<!-- 页面标题 -->
|
<!-- 页面标题 -->
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||||
<div>
|
<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 class="text-xs text-zinc-500 mt-1">
|
||||||
定义校园广播的常规播放窗口,用户仅可在这些时间段内进行点歌或查看排期
|
定义校园广播的常规播放窗口,用户仅可在这些时间段内进行点歌或查看排期
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-wrap items-center gap-3">
|
||||||
<div
|
<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 class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||||
>启用播出时段选择</span
|
>启用播出时段选择</span
|
||||||
@@ -18,7 +18,7 @@
|
|||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'relative w-10 h-5 rounded-full transition-colors',
|
'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"
|
@click="toggleGlobalEnabled"
|
||||||
>
|
>
|
||||||
@@ -30,6 +30,21 @@
|
|||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<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"
|
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"
|
@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">
|
<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" />
|
<Info class="text-blue-500 shrink-0 mt-0.5" :size="18" />
|
||||||
<div class="space-y-1">
|
<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 class="text-[11px] text-zinc-500 leading-relaxed">
|
||||||
启用全局时段选择后,系统将仅在定义的时段内开放点歌功能。如果未设置开始或结束时间,系统将视为该边界不设限。
|
启用全局时段选择后,系统将仅在定义的时段内开放点歌功能。如果未设置开始或结束时间,系统将视为该边界不设限。
|
||||||
</p>
|
</p>
|
||||||
@@ -53,7 +68,7 @@
|
|||||||
<!-- 加载状态 -->
|
<!-- 加载状态 -->
|
||||||
<div
|
<div
|
||||||
v-if="loading"
|
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
|
<div
|
||||||
class="w-10 h-10 border-4 border-blue-600/20 border-t-blue-600 rounded-full animate-spin"
|
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" />
|
<AlertCircle class="text-red-500 shrink-0 mt-0.5" :size="18" />
|
||||||
<div class="space-y-1">
|
<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>
|
<p class="text-[11px] text-zinc-500 leading-relaxed">{{ error }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -76,13 +91,13 @@
|
|||||||
<!-- 空状态 -->
|
<!-- 空状态 -->
|
||||||
<div
|
<div
|
||||||
v-else-if="playTimes.length === 0"
|
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" />
|
<Clock :size="48" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center space-y-2">
|
<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>
|
<p class="text-xs text-zinc-500">点击“添加播出时段”按钮创建第一个播出时段</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -94,15 +109,15 @@
|
|||||||
v-for="playTime in playTimes"
|
v-for="playTime in playTimes"
|
||||||
:key="playTime.id"
|
:key="playTime.id"
|
||||||
:class="[
|
:class="[
|
||||||
'group relative bg-zinc-900/30 border rounded-xl p-8 transition-all hover:shadow-2xl hover:shadow-black/40',
|
'group relative bg-zinc-50/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'
|
playTime.enabled ? 'border-zinc-200/80' : 'border-zinc-200 opacity-60'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between mb-6">
|
<div class="flex items-start justify-between mb-6">
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'p-3 rounded-xl bg-zinc-950 border border-zinc-800 transition-all',
|
'p-3 rounded-xl bg-white border border-zinc-200 transition-all',
|
||||||
playTime.enabled ? 'text-blue-500 border-blue-500/20' : 'text-zinc-700'
|
playTime.enabled ? 'text-blue-500 border-blue-200' : 'text-zinc-700'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<Clock :size="20" />
|
<Clock :size="20" />
|
||||||
@@ -113,7 +128,7 @@
|
|||||||
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
|
'px-2 py-0.5 rounded text-[9px] font-black uppercase tracking-widest border transition-all',
|
||||||
playTime.enabled
|
playTime.enabled
|
||||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
? '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 ? '已启用' : '已禁用' }}
|
{{ playTime.enabled ? '已启用' : '已禁用' }}
|
||||||
@@ -127,7 +142,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h4
|
<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 }}
|
{{ playTime.name }}
|
||||||
</h4>
|
</h4>
|
||||||
@@ -135,7 +150,7 @@
|
|||||||
class="flex items-center gap-2 mt-1.5 text-blue-500/80 font-black tracking-tighter"
|
class="flex items-center gap-2 mt-1.5 text-blue-500/80 font-black tracking-tighter"
|
||||||
>
|
>
|
||||||
<span class="text-xl">{{ playTime.startTime || '不限' }}</span>
|
<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>
|
<span class="text-xl">{{ playTime.endTime || '不限' }}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -145,16 +160,23 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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">
|
<div class="flex gap-2">
|
||||||
<button
|
<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)"
|
@click="editPlayTime(playTime)"
|
||||||
>
|
>
|
||||||
<Edit2 :size="14" />
|
<Edit2 :size="14" />
|
||||||
</button>
|
</button>
|
||||||
<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)"
|
@click="confirmDelete(playTime)"
|
||||||
>
|
>
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
@@ -164,7 +186,7 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-2 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
'flex items-center gap-2 px-4 py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||||
playTime.enabled
|
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'
|
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
|
||||||
]"
|
]"
|
||||||
@click="togglePlayTimeStatus(playTime)"
|
@click="togglePlayTimeStatus(playTime)"
|
||||||
@@ -185,15 +207,15 @@
|
|||||||
>
|
>
|
||||||
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="cancelForm" />
|
<div class="absolute inset-0 bg-black/80 backdrop-blur-sm" @click="cancelForm" />
|
||||||
<div
|
<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="p-8">
|
||||||
<div class="flex items-center justify-between mb-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 ? '编辑播出时段' : '添加播出时段' }}
|
{{ editingPlayTime ? '编辑播出时段' : '添加播出时段' }}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<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"
|
@click="cancelForm"
|
||||||
>
|
>
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
@@ -201,6 +223,23 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6">
|
<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">
|
<div class="space-y-2">
|
||||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||||
>时段名称</label
|
>时段名称</label
|
||||||
@@ -209,7 +248,7 @@
|
|||||||
v-model="formData.name"
|
v-model="formData.name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="例如: 午间广播"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -222,7 +261,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="formData.startTime"
|
v-model="formData.startTime"
|
||||||
type="time"
|
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
|
<Clock
|
||||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
|
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
|
||||||
@@ -239,7 +278,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="formData.endTime"
|
v-model="formData.endTime"
|
||||||
type="time"
|
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
|
<Clock
|
||||||
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
|
class="absolute right-4 top-1/2 -translate-y-1/2 text-zinc-700 pointer-events-none"
|
||||||
@@ -259,7 +298,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="formData.description"
|
v-model="formData.description"
|
||||||
placeholder="请输入时段描述信息..."
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -267,10 +306,10 @@
|
|||||||
<input
|
<input
|
||||||
v-model="formData.enabled"
|
v-model="formData.enabled"
|
||||||
type="checkbox"
|
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
|
<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
|
>启用此播出时段</span
|
||||||
>
|
>
|
||||||
</label>
|
</label>
|
||||||
@@ -284,9 +323,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<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"
|
@click="cancelForm"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -311,7 +350,7 @@
|
|||||||
@click="showDeleteConfirm = false"
|
@click="showDeleteConfirm = false"
|
||||||
/>
|
/>
|
||||||
<div
|
<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="p-8">
|
||||||
<div class="flex flex-col items-center py-4 space-y-6">
|
<div class="flex flex-col items-center py-4 space-y-6">
|
||||||
@@ -321,7 +360,7 @@
|
|||||||
<AlertCircle :size="32" />
|
<AlertCircle :size="32" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center space-y-2 px-4">
|
<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 }}" 吗?
|
确定要删除播出时段 "{{ playTimeToDelete?.name }}" 吗?
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-xs text-zinc-500 leading-relaxed">
|
<p class="text-xs text-zinc-500 leading-relaxed">
|
||||||
@@ -330,7 +369,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 w-full pt-4">
|
<div class="flex gap-3 w-full pt-4">
|
||||||
<button
|
<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"
|
@click="showDeleteConfirm = false"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -366,9 +405,19 @@ import {
|
|||||||
Info,
|
Info,
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
X,
|
X,
|
||||||
AlignLeft
|
AlignLeft,
|
||||||
|
Copy
|
||||||
} from '@lucide/vue'
|
} 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 { getAuthConfig, isAdmin } = useAuth()
|
||||||
const { showToast: showNotification } = useToast()
|
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 = () => {
|
const cancelForm = () => {
|
||||||
showAddForm.value = false
|
showAddForm.value = false
|
||||||
|
|||||||
@@ -8,13 +8,13 @@
|
|||||||
<Transition name="scale">
|
<Transition name="scale">
|
||||||
<div
|
<div
|
||||||
v-if="show"
|
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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
|
<div class="flex items-center justify-between p-4 border-b border-zinc-200">
|
||||||
<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>
|
||||||
<button
|
<button
|
||||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
class="text-zinc-500 hover:text-zinc-600 transition-colors"
|
||||||
@click="$emit('close')"
|
@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>
|
<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"
|
v-model="cardTitle"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="例如: 2026-03-21 校园午间广播"
|
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"
|
@input="drawCanvas"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Canvas 预览 -->
|
<!-- 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>
|
<canvas ref="canvasRef" width="400" :height="canvasHeight" class="w-full h-full object-contain bg-white"></canvas>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="text-[10px] text-zinc-500 text-center">宽度 400 像素,高度自适应,适合在手机端查看和分享</p>
|
<p class="text-[10px] text-zinc-500 text-center">宽度 400 像素,高度自适应,适合在手机端查看和分享</p>
|
||||||
</div>
|
</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
|
<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')"
|
@click="$emit('close')"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -86,13 +86,87 @@ const aiPrompt = ref('anime style school campus sunset beautiful sky highly deta
|
|||||||
const isGeneratingAi = ref(false)
|
const isGeneratingAi = ref(false)
|
||||||
const bgImageObj = ref(null)
|
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 canvasHeight = computed(() => {
|
||||||
const count = Math.min(props.songs.length, maxDisplaySongs)
|
const count = Math.min(props.songs.length, maxDisplaySongs)
|
||||||
if (count <= 10) return 600
|
if (!count) return 600
|
||||||
// 每多一首增加 40px 高度
|
const ctx = getSharedCtx()
|
||||||
return 600 + (count - 10) * 40
|
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.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)
|
ctx.fillText(new Date().toLocaleDateString('zh-CN'), 200, 80)
|
||||||
|
|
||||||
// 绘制列表背景
|
// 绘制列表背景
|
||||||
@@ -155,33 +229,44 @@ const drawCanvas = () => {
|
|||||||
|
|
||||||
// 绘制歌曲列表
|
// 绘制歌曲列表
|
||||||
ctx.textAlign = 'left'
|
ctx.textAlign = 'left'
|
||||||
|
ctx.textBaseline = 'top'
|
||||||
let y = 160
|
let y = 160
|
||||||
|
|
||||||
const displaySongs = props.songs.slice(0, maxDisplaySongs) // 最多显示 35 首
|
const displaySongs = props.songs.slice(0, maxDisplaySongs)
|
||||||
displaySongs.forEach((schedule, index) => {
|
displaySongs.forEach((schedule, index) => {
|
||||||
const song = schedule.song || schedule
|
const song = schedule.song || schedule
|
||||||
|
|
||||||
// 序号
|
// 序号
|
||||||
ctx.fillStyle = '#2563eb'
|
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.fillText(`${index + 1}.`, 40, y)
|
||||||
|
|
||||||
// 歌名
|
const maxWidthTitle = songAreaWidth
|
||||||
ctx.fillStyle = '#0F172A' // Neutral Dark
|
const maxWidthArtist = Math.max(120, songAreaWidth - 10)
|
||||||
ctx.font = 'bold 16px "Inter", "system-ui", sans-serif'
|
const title = song.title || '未知歌曲'
|
||||||
// 限制歌名长度
|
const artist = song.artist || '未知歌手'
|
||||||
let title = song.title || '未知歌曲'
|
|
||||||
if (title.length > 12) title = title.substring(0, 12) + '...'
|
|
||||||
ctx.fillText(title, 70, y)
|
|
||||||
|
|
||||||
// 歌手
|
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.fillStyle = '#64748B'
|
||||||
ctx.font = '14px "Inter", "system-ui", sans-serif'
|
ctx.font = `13px "Inter", "system-ui", "PingFang SC", "Microsoft YaHei", sans-serif`
|
||||||
let artist = song.artist || '未知歌手'
|
let ay = ty + 4
|
||||||
if (artist.length > 10) artist = artist.substring(0, 10) + '...'
|
for (const line of t2.lines) {
|
||||||
ctx.fillText(`- ${artist}`, 240, y)
|
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) {
|
if (props.songs.length > maxDisplaySongs) {
|
||||||
@@ -193,13 +278,13 @@ const drawCanvas = () => {
|
|||||||
|
|
||||||
// 绘制底部水印
|
// 绘制底部水印
|
||||||
if (bgImageObj.value) {
|
if (bgImageObj.value) {
|
||||||
ctx.fillStyle = 'rgba(255, 255, 255, 0.6)'
|
ctx.fillStyle = 'rgba(0, 0, 0, 0.55)'
|
||||||
} else {
|
} else {
|
||||||
ctx.fillStyle = '#CBD5E1'
|
ctx.fillStyle = '#CBD5E1'
|
||||||
}
|
}
|
||||||
ctx.font = '12px "Inter", "system-ui", sans-serif'
|
ctx.font = '12px "Inter", "system-ui", sans-serif'
|
||||||
ctx.textAlign = 'center'
|
ctx.textAlign = 'center'
|
||||||
ctx.fillText('Generated by VoiceHub', 200, height - 20)
|
ctx.fillText('Generated by 宝一外校园点歌系统', 200, height - 20)
|
||||||
}
|
}
|
||||||
|
|
||||||
const downloadCard = () => {
|
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.
@@ -3,14 +3,14 @@
|
|||||||
<!-- 顶部标题和全局开关 -->
|
<!-- 顶部标题和全局开关 -->
|
||||||
<div class="flex flex-col xl:flex-row xl:items-end justify-between gap-6">
|
<div class="flex flex-col xl:flex-row xl:items-end justify-between gap-6">
|
||||||
<div class="space-y-1">
|
<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>
|
<p class="text-xs text-zinc-500">管理全校师生点歌投稿的开放时间、限制数量及全局状态控制</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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 class="text-[10px] font-black text-zinc-500 uppercase tracking-widest"
|
||||||
>当前状态</span
|
>当前状态</span
|
||||||
>
|
>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'relative w-10 h-5 rounded-full transition-colors',
|
'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"
|
@click="toggleGlobalRequest"
|
||||||
>
|
>
|
||||||
@@ -44,14 +44,14 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 class="text-[10px] font-black text-zinc-400 uppercase tracking-widest"
|
||||||
>启用时段限制</span
|
>启用时段限制</span
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'relative w-10 h-5 rounded-full transition-colors',
|
'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"
|
@click="toggleTimeLimitation"
|
||||||
>
|
>
|
||||||
@@ -70,17 +70,17 @@
|
|||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
<!-- 添加时段按钮卡片 -->
|
<!-- 添加时段按钮卡片 -->
|
||||||
<button
|
<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"
|
@click="showAddForm = true"
|
||||||
>
|
>
|
||||||
<div
|
<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" />
|
<Plus :size="28" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h4
|
<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>
|
</h4>
|
||||||
@@ -101,16 +101,16 @@
|
|||||||
v-for="slot in requestTimes"
|
v-for="slot in requestTimes"
|
||||||
:key="slot.id"
|
:key="slot.id"
|
||||||
:class="[
|
:class="[
|
||||||
'group bg-zinc-900/40 border rounded-3xl p-8 space-y-6 transition-all hover:shadow-2xl hover:shadow-black/40',
|
'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-800' : 'border-zinc-800/40 opacity-60'
|
slot.enabled && !slot.past ? 'border-zinc-200' : 'border-zinc-200 opacity-60'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
<div
|
<div
|
||||||
:class="[
|
: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
|
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'
|
? 'bg-red-500/10 text-red-500 border-red-500/20'
|
||||||
: slot.enabled
|
: slot.enabled
|
||||||
? 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
? '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 ? '已启用' : '已禁用' }}
|
{{ slot.past ? '已过期' : slot.enabled ? '已启用' : '已禁用' }}
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<h4
|
<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 }}
|
{{ slot.name }}
|
||||||
<span class="text-xs font-bold text-zinc-600 tracking-tight"
|
<span class="text-xs font-bold text-zinc-600 tracking-tight"
|
||||||
@@ -150,7 +150,7 @@
|
|||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2 text-zinc-500 font-bold">
|
<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">{{
|
<span class="text-[10px] uppercase tracking-tighter truncate">{{
|
||||||
slot.endTime || '不限'
|
slot.endTime || '不限'
|
||||||
}}</span>
|
}}</span>
|
||||||
@@ -179,7 +179,7 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<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
|
<div
|
||||||
class="h-full transition-all duration-500"
|
class="h-full transition-all duration-500"
|
||||||
@@ -196,17 +196,17 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
v-show="!slot.past"
|
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)"
|
@click="editRequestTime(slot)"
|
||||||
>
|
>
|
||||||
<Edit2 :size="14" />
|
<Edit2 :size="14" />
|
||||||
</button>
|
</button>
|
||||||
<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)"
|
@click="confirmDelete(slot)"
|
||||||
>
|
>
|
||||||
<Trash2 :size="14" />
|
<Trash2 :size="14" />
|
||||||
@@ -217,7 +217,7 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all',
|
'flex items-center gap-2 px-5 py-2.5 rounded-xl text-[10px] font-black uppercase tracking-widest transition-all',
|
||||||
slot.enabled
|
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'
|
: 'bg-emerald-600 text-white shadow-lg shadow-emerald-900/20'
|
||||||
]"
|
]"
|
||||||
@click="toggleRequestTimeStatus(slot)"
|
@click="toggleRequestTimeStatus(slot)"
|
||||||
@@ -232,12 +232,12 @@
|
|||||||
|
|
||||||
<!-- 统计概览 -->
|
<!-- 统计概览 -->
|
||||||
<div
|
<div
|
||||||
class="bg-zinc-900/20 border border-zinc-800 rounded-3xl p-8 grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-8"
|
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 v-for="(stat, i) in stats" :key="i" class="flex items-center gap-4 group">
|
||||||
<div
|
<div
|
||||||
:class="[
|
: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`
|
`group-hover:text-${stat.color}-500`
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -247,7 +247,7 @@
|
|||||||
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
|
<p class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">
|
||||||
{{ stat.label }}
|
{{ stat.label }}
|
||||||
</p>
|
</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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -263,17 +263,17 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="showAddForm || editingRequestTime"
|
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"
|
@click.self="cancelForm"
|
||||||
>
|
>
|
||||||
<div
|
<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">
|
<div class="px-8 py-6 border-b border-zinc-200 flex items-center justify-between">
|
||||||
<h3 class="text-xl font-black text-zinc-100">
|
<h3 class="text-xl font-black text-zinc-900">
|
||||||
{{ editingRequestTime ? '编辑投稿开放时段' : '添加投稿开放时段' }}
|
{{ editingRequestTime ? '编辑投稿开放时段' : '添加投稿开放时段' }}
|
||||||
</h3>
|
</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" />
|
<X :size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -287,30 +287,68 @@
|
|||||||
v-model="formData.name"
|
v-model="formData.name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="例如: 2025冬季常规点歌"
|
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>
|
||||||
|
|
||||||
|
<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="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||||
>开始日期时间</label
|
>开始时间</label
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="formData.startTime"
|
|
||||||
type="datetime-local"
|
|
||||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
|
||||||
>
|
>
|
||||||
|
<div 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>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||||
>结束日期时间</label
|
>结束时间</label
|
||||||
>
|
|
||||||
<input
|
|
||||||
v-model="formData.endTime"
|
|
||||||
type="datetime-local"
|
|
||||||
class="w-full bg-zinc-950 border border-zinc-800 rounded-2xl px-5 py-3.5 text-sm text-zinc-200 focus:outline-none focus:border-blue-500/30"
|
|
||||||
>
|
>
|
||||||
|
<div 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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -323,7 +361,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="formData.expected"
|
v-model="formData.expected"
|
||||||
type="number"
|
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
|
<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]"
|
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
|
<input
|
||||||
v-model="formData.enabled"
|
v-model="formData.enabled"
|
||||||
type="checkbox"
|
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>
|
<div>
|
||||||
<span
|
<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
|
>启用此投稿时段</span
|
||||||
>
|
>
|
||||||
<p class="text-[9px] text-zinc-600 font-medium">
|
<p class="text-[9px] text-zinc-600 font-medium">
|
||||||
@@ -361,7 +399,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="formData.description"
|
v-model="formData.description"
|
||||||
placeholder="请输入针对此开放周期的特殊规则或介绍信息..."
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -374,9 +412,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<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"
|
@click="cancelForm"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -404,11 +442,11 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="showDeleteConfirm"
|
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"
|
@click.self="showDeleteConfirm = false"
|
||||||
>
|
>
|
||||||
<div
|
<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 class="flex flex-col items-center space-y-6">
|
||||||
<div
|
<div
|
||||||
@@ -417,17 +455,17 @@
|
|||||||
<Trash2 :size="28" />
|
<Trash2 :size="28" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center space-y-2 px-4">
|
<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 }}" 吗?
|
确定要彻底移除 "{{ RequestTimeToDelete?.name }}" 吗?
|
||||||
</h4>
|
</h4>
|
||||||
<p class="text-xs text-zinc-500 leading-relaxed">
|
<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>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex gap-3 w-full pt-4">
|
<div class="flex gap-3 w-full pt-4">
|
||||||
<button
|
<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"
|
@click="showDeleteConfirm = false"
|
||||||
>
|
>
|
||||||
保留时段
|
保留时段
|
||||||
@@ -447,9 +485,9 @@
|
|||||||
|
|
||||||
<!-- 说明部分 -->
|
<!-- 说明部分 -->
|
||||||
<div class="flex flex-col md:flex-row gap-6">
|
<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
|
<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" /> 逻辑优先级说明
|
<CheckCircle2 :size="14" class="text-emerald-500" /> 逻辑优先级说明
|
||||||
</h4>
|
</h4>
|
||||||
@@ -457,16 +495,16 @@
|
|||||||
<li class="flex gap-2">
|
<li class="flex gap-2">
|
||||||
<span class="text-zinc-700 font-black">1.</span>
|
<span class="text-zinc-700 font-black">1.</span>
|
||||||
<span
|
<span
|
||||||
><span class="text-zinc-300">全局开关</span>
|
><span class="text-zinc-600">全局开关</span>
|
||||||
具有最高优先级,关闭后所有投稿均不可用。</span
|
具有最高优先级,关闭后所有投稿均不可用。</span
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex gap-2">
|
<li class="flex gap-2">
|
||||||
<span class="text-zinc-700 font-black">2.</span>
|
<span class="text-zinc-700 font-black">2.</span>
|
||||||
<span
|
<span
|
||||||
>开启 <span class="text-zinc-300">时段限制</span> 后,系统会检查当前时间是否处于任一
|
>开启 <span class="text-zinc-600">时段限制</span> 后,系统会检查当前时间是否处于任一
|
||||||
<span class="text-zinc-300">已启用</span> 且未达到
|
<span class="text-zinc-600">已启用</span> 且未达到
|
||||||
<span class="text-zinc-300">计划数</span> 的时段内。</span
|
<span class="text-zinc-600">计划数</span> 的时段内。</span
|
||||||
>
|
>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex gap-2">
|
<li class="flex gap-2">
|
||||||
@@ -476,9 +514,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</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
|
<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" /> 注意事项
|
<XCircle :size="14" class="text-rose-500" /> 注意事项
|
||||||
</h4>
|
</h4>
|
||||||
@@ -502,7 +540,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<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 { useAuth } from '~/composables/useAuth'
|
||||||
import type { RequestTime } from '~~/types'
|
import type { RequestTime } from '~~/types'
|
||||||
import {
|
import {
|
||||||
@@ -521,6 +559,57 @@ import {
|
|||||||
Filter
|
Filter
|
||||||
} from '@lucide/vue'
|
} 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 { getAuthConfig, isAdmin } = useAuth()
|
||||||
|
|
||||||
const requestTimes = ref<RequestTime[]>([])
|
const requestTimes = ref<RequestTime[]>([])
|
||||||
@@ -544,11 +633,51 @@ const formData = reactive({
|
|||||||
name: '',
|
name: '',
|
||||||
startTime: '',
|
startTime: '',
|
||||||
endTime: '',
|
endTime: '',
|
||||||
|
startDate: '',
|
||||||
|
startTimeOfDay: '',
|
||||||
|
endDate: '',
|
||||||
|
endTimeOfDay: '',
|
||||||
description: '',
|
description: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
expected: 0
|
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(() => [
|
const stats = computed(() => [
|
||||||
{
|
{
|
||||||
@@ -711,6 +840,7 @@ const editRequestTime = (RequestTime: RequestTime) => {
|
|||||||
enabled: RequestTime.enabled,
|
enabled: RequestTime.enabled,
|
||||||
expected: RequestTime.expected || 0
|
expected: RequestTime.expected || 0
|
||||||
})
|
})
|
||||||
|
splitFormDateTime()
|
||||||
}
|
}
|
||||||
|
|
||||||
const toggleRequestTimeStatus = async (RequestTime: RequestTime) => {
|
const toggleRequestTimeStatus = async (RequestTime: RequestTime) => {
|
||||||
@@ -827,6 +957,10 @@ const cancelForm = () => {
|
|||||||
name: '',
|
name: '',
|
||||||
startTime: '',
|
startTime: '',
|
||||||
endTime: '',
|
endTime: '',
|
||||||
|
startDate: '',
|
||||||
|
startTimeOfDay: '',
|
||||||
|
endDate: '',
|
||||||
|
endTimeOfDay: '',
|
||||||
description: '',
|
description: '',
|
||||||
enabled: true,
|
enabled: true,
|
||||||
expected: 0
|
expected: 0
|
||||||
@@ -842,7 +976,7 @@ const cancelForm = () => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb {
|
::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--bg-tertiary, #27272a);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
::-webkit-scrollbar-thumb:hover {
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="roles-manager">
|
<div class="roles-manager">
|
||||||
<div class="admin-section-header">
|
<div class="admin-section-header">
|
||||||
<div>
|
<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" />
|
<Shield class="w-6 h-6 text-indigo-400" />
|
||||||
权限组管理
|
权限组管理
|
||||||
</h2>
|
</h2>
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
<div v-else-if="error" class="py-12 text-center text-red-400">
|
<div v-else-if="error" class="py-12 text-center text-red-400">
|
||||||
{{ error }}
|
{{ 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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -37,15 +37,15 @@
|
|||||||
<div
|
<div
|
||||||
v-for="(roleConfig, roleKey) in roles"
|
v-for="(roleConfig, roleKey) in roles"
|
||||||
:key="roleKey"
|
: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 justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<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" />
|
<Shield class="w-5 h-5" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-xs text-zinc-500 font-mono mt-0.5">{{ roleKey }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
class="hidden"
|
class="hidden"
|
||||||
:disabled="roleKey === 'SUPER_ADMIN'"
|
: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 class="absolute w-4 h-4 bg-white rounded-full top-1 transition-transform" :class="roleConfig.canAccessAdmin ? 'left-5' : 'left-1'" />
|
||||||
</div>
|
</div>
|
||||||
<span class="text-xs font-bold text-zinc-400">允许进后台</span>
|
<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 justify-between mb-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<h4 class="text-xs font-bold text-zinc-500 uppercase tracking-wider">可访问页面</h4>
|
<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 }} 项
|
已启用 {{ roleConfig.pages.length }} 项
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<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'"
|
:disabled="roleKey === 'SUPER_ADMIN'"
|
||||||
@click="selectAll(roleKey)"
|
@click="selectAll(roleKey)"
|
||||||
>
|
>
|
||||||
全选
|
全选
|
||||||
</button>
|
</button>
|
||||||
<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'"
|
:disabled="roleKey === 'SUPER_ADMIN'"
|
||||||
@click="clearAll(roleKey)"
|
@click="clearAll(roleKey)"
|
||||||
>
|
>
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
v-for="page in section.pages"
|
v-for="page in section.pages"
|
||||||
:key="page.id"
|
:key="page.id"
|
||||||
class="flex items-center gap-2 p-2 rounded-xl border cursor-pointer transition-all"
|
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
|
<input
|
||||||
v-model="roleConfig.pages"
|
v-model="roleConfig.pages"
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
<p class="text-sm text-zinc-600">该角色不允许进入后台</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
<template>
|
<template>
|
||||||
<div
|
<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 }}" 创建排期
|
为歌曲 "{{ song?.title }}" 创建排期
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
<form @submit.prevent="handleSubmit">
|
<form @submit.prevent="handleSubmit">
|
||||||
<div class="mb-4">
|
<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
|
<input
|
||||||
id="playDate"
|
id="playDate"
|
||||||
v-model="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
|
required
|
||||||
type="date"
|
type="date"
|
||||||
>
|
>
|
||||||
@@ -51,7 +51,7 @@
|
|||||||
|
|
||||||
<div class="flex justify-between gap-4 mt-6">
|
<div class="flex justify-between gap-4 mt-6">
|
||||||
<button
|
<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"
|
type="button"
|
||||||
@click="$emit('cancel')"
|
@click="$emit('cancel')"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -7,16 +7,16 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 歌曲封面 -->
|
<!-- 歌曲封面 -->
|
||||||
<div v-if="settings.showCover" class="cover-section">
|
<div v-if="settings.showCover" class="cover-section aspect-square">
|
||||||
<img
|
<img
|
||||||
v-if="schedule.song.cover"
|
v-if="schedule.song.cover"
|
||||||
:alt="schedule.song.title"
|
:alt="schedule.song.title"
|
||||||
:src="convertToHttps(schedule.song.cover)"
|
:src="convertToHttps(schedule.song.cover)"
|
||||||
class="song-cover"
|
class="song-cover object-cover"
|
||||||
referrerpolicy="no-referrer"
|
referrerpolicy="no-referrer"
|
||||||
@error="handleImageError"
|
@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">
|
<svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
|
||||||
<circle cx="12" cy="12" r="3" />
|
<circle cx="12" cy="12" r="3" />
|
||||||
<path d="M12 1v6m0 6v6" />
|
<path d="M12 1v6m0 6v6" />
|
||||||
@@ -112,13 +112,14 @@ const handleImageError = (event) => {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #333;
|
color: #333333 !important;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover-section {
|
.cover-section {
|
||||||
width: 40px;
|
width: 40px;
|
||||||
height: 40px;
|
height: 40px;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,17 +128,20 @@ const handleImageError = (event) => {
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover-placeholder {
|
.cover-placeholder {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
aspect-ratio: 1 / 1;
|
||||||
|
object-fit: contain;
|
||||||
background: #f5f5f5;
|
background: #f5f5f5;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
display: none;
|
display: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: #999;
|
color: var(--text-tertiary, #999999);
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover-placeholder.show {
|
.cover-placeholder.show {
|
||||||
@@ -157,7 +161,7 @@ const handleImageError = (event) => {
|
|||||||
.song-title {
|
.song-title {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #333;
|
color: #333333 !important;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -182,7 +186,7 @@ const handleImageError = (event) => {
|
|||||||
|
|
||||||
.song-artist {
|
.song-artist {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666;
|
color: #666666 !important;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
@@ -193,7 +197,7 @@ const handleImageError = (event) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #666;
|
color: #666666 !important;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -203,14 +207,14 @@ const handleImageError = (event) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.value {
|
.value {
|
||||||
color: #333;
|
color: #333333 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 打印样式 */
|
/* 打印样式 */
|
||||||
@media print {
|
@media print {
|
||||||
.schedule-item-print {
|
.schedule-item-print {
|
||||||
color: #000 !important;
|
color: #000000 !important;
|
||||||
background: #fff !important;
|
background: #ffffff !important;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
max-width: none !important;
|
max-width: none !important;
|
||||||
box-sizing: border-box !important;
|
box-sizing: border-box !important;
|
||||||
@@ -226,25 +230,41 @@ const handleImageError = (event) => {
|
|||||||
|
|
||||||
.sequence-number {
|
.sequence-number {
|
||||||
background: #f0f0f0 !important;
|
background: #f0f0f0 !important;
|
||||||
color: #000 !important;
|
color: #000000 !important;
|
||||||
width: 30px !important;
|
width: 30px !important;
|
||||||
height: 30px !important;
|
height: 30px !important;
|
||||||
flex-shrink: 0 !important;
|
flex-shrink: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cover-section {
|
.cover-section {
|
||||||
width: 40px !important;
|
width: 48px !important;
|
||||||
height: 40px !important;
|
height: 48px !important;
|
||||||
|
aspect-ratio: 1 / 1 !important;
|
||||||
flex-shrink: 0 !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 {
|
.song-info {
|
||||||
flex: 1 !important;
|
flex: 1 !important;
|
||||||
min-width: 0 !important;
|
min-width: 0 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-title {
|
.song-title {
|
||||||
color: #000 !important;
|
color: #000000 !important;
|
||||||
font-size: 16px !important;
|
font-size: 16px !important;
|
||||||
white-space: nowrap !important;
|
white-space: nowrap !important;
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
@@ -254,11 +274,11 @@ const handleImageError = (event) => {
|
|||||||
.song-artist,
|
.song-artist,
|
||||||
.label,
|
.label,
|
||||||
.value {
|
.value {
|
||||||
color: #333 !important;
|
color: #333333 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-range {
|
.time-range {
|
||||||
color: #666 !important;
|
color: var(--text-quaternary, #666666) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.requester-info,
|
.requester-info,
|
||||||
|
|||||||
@@ -7,27 +7,107 @@
|
|||||||
@update:modelValue="handleDateSelect"
|
@update:modelValue="handleDateSelect"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<!-- 播出时段与自动排期配置 -->
|
<!-- 播出时段标签组 + 自动排期策略 -->
|
||||||
<div
|
<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
|
<div v-if="playTimeEnabled && playTimes.length > 0" class="space-y-2">
|
||||||
:model-value="selectedPlayTime"
|
<div class="flex items-center justify-between px-1">
|
||||||
label="播出时段"
|
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">播出时段</span>
|
||||||
:options="playTimeOptions"
|
<span class="text-[10px] font-bold text-zinc-500">
|
||||||
class-name="w-full"
|
{{ selectedPlayTime ? '已按时段过滤' : '显示全天排期' }}
|
||||||
@update:model-value="handlePlayTimeSelect"
|
</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>
|
||||||
<div :class="playTimeEnabled ? '' : 'md:col-span-2'">
|
|
||||||
<CustomSelect
|
<!-- 自动排期策略 -->
|
||||||
:model-value="autoScheduleStrategy"
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
label="自动排期策略"
|
<div :class="playTimeEnabled ? '' : 'md:col-span-2'">
|
||||||
:options="scheduleStrategyOptions"
|
<CustomSelect
|
||||||
class-name="w-full"
|
:model-value="autoScheduleStrategy"
|
||||||
@update:model-value="handleStrategyChange"
|
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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -39,7 +119,7 @@
|
|||||||
|
|
||||||
<div v-else>
|
<div v-else>
|
||||||
<div
|
<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
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
@@ -51,7 +131,7 @@
|
|||||||
<ListMusic class="w-4 h-4" />
|
<ListMusic class="w-4 h-4" />
|
||||||
<span class="flex items-center gap-1.5"
|
<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
|
filteredUnscheduledSongs.length
|
||||||
}}</span></span
|
}}</span></span
|
||||||
>
|
>
|
||||||
@@ -66,7 +146,7 @@
|
|||||||
<PlaySquare class="w-4 h-4" />
|
<PlaySquare class="w-4 h-4" />
|
||||||
<span class="flex items-center gap-1.5"
|
<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
|
localScheduledSongs.length
|
||||||
}}</span></span
|
}}</span></span
|
||||||
>
|
>
|
||||||
@@ -86,11 +166,11 @@
|
|||||||
@drop.stop.prevent="handleReturnToDraggable"
|
@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]">
|
<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>
|
</h3>
|
||||||
<div
|
<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
|
<button
|
||||||
v-for="tab in [
|
v-for="tab in [
|
||||||
@@ -102,7 +182,7 @@
|
|||||||
:class="[
|
: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',
|
'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
|
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'
|
: 'text-zinc-600 hover:text-zinc-400'
|
||||||
]"
|
]"
|
||||||
@click="activeTab = tab.id"
|
@click="activeTab = tab.id"
|
||||||
@@ -113,9 +193,9 @@
|
|||||||
</div>
|
</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
|
<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"
|
@click="mobileFiltersOpen = !mobileFiltersOpen"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-zinc-400">
|
<div class="flex items-center gap-2 text-zinc-400">
|
||||||
@@ -142,7 +222,7 @@
|
|||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索歌曲、艺术家..."
|
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
|
<button
|
||||||
v-if="searchQuery"
|
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',
|
'draggable-songs flex-1 border-2 border-dashed rounded-[2rem] p-2 md:p-3 min-h-[400px] transition-colors duration-200',
|
||||||
isDraggableOver
|
isDraggableOver
|
||||||
? 'border-blue-500 bg-blue-500/5'
|
? '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 class="space-y-2">
|
||||||
<div
|
<div
|
||||||
v-for="song in filteredUnscheduledSongs"
|
v-for="song in filteredUnscheduledSongs"
|
||||||
:key="song.id"
|
: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"
|
draggable="true"
|
||||||
@dragend="dragEnd"
|
@dragend="dragEnd"
|
||||||
@dragstart="dragStart($event, song)"
|
@dragstart="dragStart($event, song)"
|
||||||
@@ -193,7 +273,7 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<!-- 封面图片 -->
|
<!-- 封面图片 -->
|
||||||
<div
|
<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)"
|
@click.stop="playSong(song)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -214,10 +294,10 @@
|
|||||||
|
|
||||||
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<span
|
||||||
v-if="false"
|
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 class="truncate">{{ song.title }}</span>
|
||||||
</span>
|
</span>
|
||||||
@@ -225,7 +305,7 @@
|
|||||||
</h4>
|
</h4>
|
||||||
<button
|
<button
|
||||||
v-if="song.hasSubmissionNote && song.submissionNote"
|
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="查看备注留言"
|
title="查看备注留言"
|
||||||
@click.stop="openSubmissionRemark(song)"
|
@click.stop="openSubmissionRemark(song)"
|
||||||
>
|
>
|
||||||
@@ -262,7 +342,7 @@
|
|||||||
<!-- 普通模式:投票数 -->
|
<!-- 普通模式:投票数 -->
|
||||||
<div
|
<div
|
||||||
v-if="activeTab !== 'replay'"
|
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" />
|
<Heart class="w-3 h-3 text-red-500/50" />
|
||||||
{{ song.voteCount || 0 }}
|
{{ song.voteCount || 0 }}
|
||||||
@@ -271,7 +351,7 @@
|
|||||||
<!-- 重播模式:查看按钮 -->
|
<!-- 重播模式:查看按钮 -->
|
||||||
<button
|
<button
|
||||||
v-if="activeTab === 'replay'"
|
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)"
|
@click.stop="openReplayModal(song)"
|
||||||
>
|
>
|
||||||
查看
|
查看
|
||||||
@@ -289,7 +369,7 @@
|
|||||||
|
|
||||||
<!-- 移动端添加按钮 -->
|
<!-- 移动端添加按钮 -->
|
||||||
<button
|
<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)"
|
@click.stop="addSongToSchedule(song)"
|
||||||
>
|
>
|
||||||
<Plus class="w-5 h-5" />
|
<Plus class="w-5 h-5" />
|
||||||
@@ -297,7 +377,7 @@
|
|||||||
|
|
||||||
<!-- 菜单按钮 -->
|
<!-- 菜单按钮 -->
|
||||||
<div
|
<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" />
|
<MoreVertical class="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
@@ -342,81 +422,81 @@
|
|||||||
<div
|
<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]"
|
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
|
<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">
|
<div class="flex gap-1 md:gap-1.5">
|
||||||
<button
|
<button
|
||||||
:disabled="
|
:disabled="
|
||||||
!hasChanges && localScheduledSongs.length === 0 && !hasUnpublishedDrafts
|
!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"
|
@click="saveDraft"
|
||||||
>
|
>
|
||||||
<Save class="w-3.5 h-3.5" />
|
<Save class="w-3.5 h-3.5" />
|
||||||
<span
|
<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
|
>保存草稿</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="localScheduledSongs.length === 0"
|
: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"
|
@click="openDownloadDialog"
|
||||||
>
|
>
|
||||||
<Download class="w-3.5 h-3.5" />
|
<Download class="w-3.5 h-3.5" />
|
||||||
<span
|
<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
|
>下载歌曲</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="localScheduledSongs.length === 0"
|
: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"
|
@click="showCardGenerator = true"
|
||||||
>
|
>
|
||||||
<ImageIcon class="w-3.5 h-3.5" />
|
<ImageIcon class="w-3.5 h-3.5" />
|
||||||
<span
|
<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
|
>生成图片</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="localScheduledSongs.length === 0"
|
: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"
|
@click="markAllAsPlayed"
|
||||||
>
|
>
|
||||||
<CheckCircle2 class="w-3.5 h-3.5" />
|
<CheckCircle2 class="w-3.5 h-3.5" />
|
||||||
<span
|
<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
|
>全部已播放</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="localScheduledSongs.length === 0"
|
: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="构建播放队列"
|
title="构建播放队列"
|
||||||
@click="confirmBuildPlayQueue"
|
@click="confirmBuildPlayQueue"
|
||||||
>
|
>
|
||||||
<Music2 class="w-3.5 h-3.5" />
|
<Music2 class="w-3.5 h-3.5" />
|
||||||
<span
|
<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
|
>构建播放队列</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="openMoveDateDialog"
|
||||||
>
|
>
|
||||||
<ArrowRight class="w-3.5 h-3.5" />
|
<ArrowRight class="w-3.5 h-3.5" />
|
||||||
<span
|
<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
|
>迁移日期</span
|
||||||
>
|
>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="h-6 w-[1px] bg-zinc-800 mx-1" />
|
<div class="h-6 w-[1px] bg-zinc-100 mx-1" />
|
||||||
<button
|
<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"
|
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="一键将当前待排歌曲自动排入"
|
title="一键将当前待排歌曲自动排入"
|
||||||
@@ -445,7 +525,7 @@
|
|||||||
ref="sequenceList"
|
ref="sequenceList"
|
||||||
:class="[
|
:class="[
|
||||||
'sequence-list flex-1 border-2 border-dashed rounded-[2rem] p-2 md:p-3 min-h-[400px] transition-colors duration-200',
|
'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"
|
@dragleave="handleSequenceDragLeave"
|
||||||
@dragover.prevent="handleDragOver"
|
@dragover.prevent="handleDragOver"
|
||||||
@@ -465,9 +545,9 @@
|
|||||||
v-for="(schedule, index) in localScheduledSongs"
|
v-for="(schedule, index) in localScheduledSongs"
|
||||||
:key="schedule.id"
|
:key="schedule.id"
|
||||||
:class="[
|
: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' : '',
|
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"
|
:data-schedule-id="schedule.id"
|
||||||
draggable="true"
|
draggable="true"
|
||||||
@@ -483,17 +563,17 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<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-[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
|
index + 1 < 10 ? '0' + (index + 1) : index + 1
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 封面图片 -->
|
<!-- 封面图片 -->
|
||||||
<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)"
|
@click.stop="playSong(schedule.song)"
|
||||||
>
|
>
|
||||||
<img
|
<img
|
||||||
@@ -514,12 +594,12 @@
|
|||||||
|
|
||||||
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
<div class="flex-1 min-w-0 flex flex-col gap-0.5">
|
||||||
<div class="flex items-center gap-2">
|
<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 }}
|
{{ schedule.song.title }}
|
||||||
</h4>
|
</h4>
|
||||||
<button
|
<button
|
||||||
v-if="schedule.song.hasSubmissionNote && schedule.song.submissionNote"
|
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="查看备注留言"
|
title="查看备注留言"
|
||||||
@click.stop="openSubmissionRemark(schedule.song)"
|
@click.stop="openSubmissionRemark(schedule.song)"
|
||||||
>
|
>
|
||||||
@@ -528,7 +608,7 @@
|
|||||||
<!-- 重播标识 -->
|
<!-- 重播标识 -->
|
||||||
<span
|
<span
|
||||||
v-if="schedule.song.replayRequestCount > 0"
|
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="重播歌曲"
|
title="重播歌曲"
|
||||||
>
|
>
|
||||||
<Icon name="repeat" :size="10" />
|
<Icon name="repeat" :size="10" />
|
||||||
@@ -602,7 +682,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div
|
<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" />
|
<MoreVertical class="w-4 h-4" />
|
||||||
</div>
|
</div>
|
||||||
@@ -616,42 +696,42 @@
|
|||||||
|
|
||||||
<!-- 移动端底部操作栏 -->
|
<!-- 移动端底部操作栏 -->
|
||||||
<div
|
<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="w-[240px] overflow-x-auto scrollbar-hide">
|
||||||
<div class="flex items-center gap-2 w-max">
|
<div class="flex items-center gap-2 w-max">
|
||||||
<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="openDownloadDialog"
|
@click="openDownloadDialog"
|
||||||
>
|
>
|
||||||
<Download class="w-5 h-5" />
|
<Download class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="showCardGenerator = true"
|
||||||
>
|
>
|
||||||
<ImageIcon class="w-5 h-5" />
|
<ImageIcon class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="saveDraft"
|
||||||
>
|
>
|
||||||
<Save class="w-5 h-5" />
|
<Save class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="markAllAsPlayed"
|
||||||
>
|
>
|
||||||
<CheckCircle2 class="w-5 h-5" />
|
<CheckCircle2 class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="openMoveDateDialog"
|
||||||
>
|
>
|
||||||
<ArrowRight class="w-5 h-5" />
|
<ArrowRight class="w-5 h-5" />
|
||||||
</button>
|
</button>
|
||||||
<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="仅发布排期"
|
title="仅发布排期"
|
||||||
@click="publishSchedule"
|
@click="publishSchedule"
|
||||||
>
|
>
|
||||||
@@ -661,6 +741,15 @@
|
|||||||
</div>
|
</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
|
<button
|
||||||
:disabled="!hasChanges"
|
: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"
|
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"
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/60 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
|
<div class="flex items-center justify-between p-4 border-b border-zinc-200">
|
||||||
<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>
|
||||||
<button
|
<button
|
||||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
class="text-zinc-500 hover:text-zinc-600 transition-colors"
|
||||||
@click="showMoveDateDialog = false"
|
@click="showMoveDateDialog = false"
|
||||||
>
|
>
|
||||||
<CloseIcon class="w-5 h-5" />
|
<CloseIcon class="w-5 h-5" />
|
||||||
@@ -721,12 +810,12 @@
|
|||||||
<div class="text-xs text-zinc-500">当前日期:{{ selectedDate }}</div>
|
<div class="text-xs text-zinc-500">当前日期:{{ selectedDate }}</div>
|
||||||
<input
|
<input
|
||||||
v-model="moveTargetDate"
|
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"
|
type="date"
|
||||||
>
|
>
|
||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<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"
|
@click="showMoveDateDialog = false"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -749,11 +838,11 @@
|
|||||||
@click="closeReplayModal"
|
@click="closeReplayModal"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800">
|
<div class="flex items-center justify-between p-4 border-b border-zinc-200">
|
||||||
<h3 class="text-sm font-black text-zinc-100 uppercase tracking-widest">
|
<h3 class="text-sm font-black text-zinc-900 uppercase tracking-widest">
|
||||||
{{ replayModalTitle }} - 重播申请详情
|
{{ replayModalTitle }} - 重播申请详情
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -767,7 +856,7 @@
|
|||||||
拒绝申请
|
拒绝申请
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
class="text-zinc-500 hover:text-zinc-600 transition-colors"
|
||||||
@click="closeReplayModal"
|
@click="closeReplayModal"
|
||||||
>
|
>
|
||||||
<CloseIcon class="w-5 h-5" />
|
<CloseIcon class="w-5 h-5" />
|
||||||
@@ -783,12 +872,12 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div
|
<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" />
|
<User class="w-3.5 h-3.5" />
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-col">
|
<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"
|
<span v-if="req.grade" class="text-[10px] text-zinc-500"
|
||||||
>{{ req.grade }}{{ req.class ? ` ${req.class}` : '' }}</span
|
>{{ req.grade }}{{ req.class ? ` ${req.class}` : '' }}</span
|
||||||
>
|
>
|
||||||
@@ -819,7 +908,7 @@
|
|||||||
/>
|
/>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch, inject } from 'vue'
|
import { computed, nextTick, onMounted, onUnmounted, reactive, ref, watch, inject } from 'vue'
|
||||||
import {
|
import {
|
||||||
Search,
|
Search,
|
||||||
@@ -851,7 +940,10 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
Image,
|
Image,
|
||||||
CalendarDays,
|
CalendarDays,
|
||||||
Image as ImageIcon
|
Image as ImageIcon,
|
||||||
|
Layers,
|
||||||
|
Inbox,
|
||||||
|
Trash2
|
||||||
} from '@lucide/vue'
|
} from '@lucide/vue'
|
||||||
import AdminDatePicker from './AdminDatePicker.vue'
|
import AdminDatePicker from './AdminDatePicker.vue'
|
||||||
import PlaylistCardGenerator from './PlaylistCardGenerator.vue'
|
import PlaylistCardGenerator from './PlaylistCardGenerator.vue'
|
||||||
@@ -892,7 +984,9 @@ const defaultAutoConfig: AutoConfig = {
|
|||||||
|
|
||||||
// 响应式数据
|
// 响应式数据
|
||||||
const showCardGenerator = ref(false)
|
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 loading = ref(false)
|
||||||
const songSortOption = ref<AutoConfig['songSortOption']>('votes-desc')
|
const songSortOption = ref<AutoConfig['songSortOption']>('votes-desc')
|
||||||
const hasChanges = ref(false)
|
const hasChanges = ref(false)
|
||||||
@@ -999,6 +1093,9 @@ const localScheduledSongs = ref([])
|
|||||||
const replayRequests = ref([])
|
const replayRequests = ref([])
|
||||||
const scheduledSongIds = ref(new Set())
|
const scheduledSongIds = ref(new Set())
|
||||||
|
|
||||||
|
// 跨日期本地暂存池:保存未保存但分配到其他日期的排期,避免切换日期时丢失
|
||||||
|
const crossDateLocalPool = ref([])
|
||||||
|
|
||||||
// 计算是否有未发布的草稿
|
// 计算是否有未发布的草稿
|
||||||
const hasUnpublishedDrafts = computed(() => {
|
const hasUnpublishedDrafts = computed(() => {
|
||||||
return localScheduledSongs.value.some((schedule) => schedule.isDraft)
|
return localScheduledSongs.value.some((schedule) => schedule.isDraft)
|
||||||
@@ -1033,6 +1130,20 @@ const playTimeOptions = computed(() => {
|
|||||||
return options
|
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 = [
|
const sortOptions = [
|
||||||
{ label: '最新投稿', value: 'time-desc' },
|
{ label: '最新投稿', value: 'time-desc' },
|
||||||
@@ -1595,45 +1706,95 @@ const onSemesterChange = async () => {
|
|||||||
|
|
||||||
// 更新本地排期数据(包括草稿)
|
// 更新本地排期数据(包括草稿)
|
||||||
const updateLocalScheduledSongs = () => {
|
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('更新本地排期数据 - 当前日期:', selectedDate.value)
|
||||||
console.log('公开排期数量:', publicSchedules.value.length)
|
console.log('公开排期数量:', publicSchedules.value.length)
|
||||||
console.log('草稿数量:', drafts.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) => {
|
const todaySchedules = publicSchedules.value.filter((s) => {
|
||||||
if (!s.playDate) return false
|
if (!s.playDate) return false
|
||||||
const scheduleDateStr = new Date(s.playDate).toISOString().split('T')[0]
|
const scheduleDateStr = _fmtLocal(s.playDate)
|
||||||
return scheduleDateStr === selectedDate.value
|
return scheduleDateStr === selectedDate.value
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取草稿排期
|
// 获取草稿排期
|
||||||
const todayDrafts = drafts.value.filter((draft) => {
|
const todayDrafts = drafts.value.filter((draft) => {
|
||||||
if (!draft.playDate) return false
|
if (!draft.playDate) return false
|
||||||
const draftDateStr = new Date(draft.playDate).toISOString().split('T')[0]
|
const draftDateStr = _fmtLocal(draft.playDate)
|
||||||
return draftDateStr === selectedDate.value
|
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('当天已发布排期:', todaySchedules.length)
|
||||||
console.log('当天草稿排期:', todayDrafts.length)
|
console.log('当天草稿排期:', todayDrafts.length)
|
||||||
|
console.log('当天本地暂存排期:', todayLocalOnly.length)
|
||||||
|
|
||||||
// 合并已发布和草稿排期
|
// 合并已发布、草稿、本地暂存
|
||||||
let allSchedules = [...todaySchedules, ...todayDrafts]
|
let allSchedules = [...todaySchedules, ...todayDrafts, ...todayLocalOnly]
|
||||||
|
|
||||||
// 如果选择了特定播出时段,进行过滤
|
// 如果选择了特定播出时段,进行过滤
|
||||||
if (selectedPlayTime.value) {
|
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 字段排序
|
// 按 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 }))
|
localScheduledSongs.value = allSchedules.map((s) => ({ ...s }))
|
||||||
|
|
||||||
console.log('最终显示排期数量:', localScheduledSongs.value.length)
|
console.log('最终显示排期数量:', localScheduledSongs.value.length)
|
||||||
|
|
||||||
// 更新已排期歌曲ID集合(包括草稿)
|
// 更新已排期歌曲ID集合(包括草稿、跨日期暂存、公开排期)
|
||||||
|
const allScheduledSongs = [...publicSchedules.value, ...drafts.value, ...crossDateLocalPool.value, ...localScheduledSongs.value]
|
||||||
scheduledSongIds.value = new Set(
|
scheduledSongIds.value = new Set(
|
||||||
[...publicSchedules.value, ...drafts.value]
|
allScheduledSongs
|
||||||
.filter((s) => s.song && s.song.id)
|
.filter((s) => s.song && s.song.id)
|
||||||
.map((s) => s.song.id)
|
.map((s) => s.song.id)
|
||||||
)
|
)
|
||||||
@@ -1952,15 +2113,18 @@ const saveSequence = async () => {
|
|||||||
// 一键自动排入期望播出的歌曲
|
// 一键自动排入期望播出的歌曲
|
||||||
const autoScheduleExpected = () => {
|
const autoScheduleExpected = () => {
|
||||||
let count = 0
|
let count = 0
|
||||||
const expectedSongs = [...filteredUnscheduledSongs.value]
|
|
||||||
|
// 1. 收集所有待排期歌曲(不做严格的日期过滤,根据期望日期自动分配)
|
||||||
|
const pool = [...allUnscheduledSongs.value]
|
||||||
|
|
||||||
if (expectedSongs.length === 0) {
|
if (pool.length === 0) {
|
||||||
showToast('当前没有待排期的歌曲', 'info')
|
showToast('当前没有待排期的歌曲', 'info')
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 2. 排序(和之前保持一致:按策略)
|
||||||
if (autoScheduleStrategy.value === 'contributor-count') {
|
if (autoScheduleStrategy.value === 'contributor-count') {
|
||||||
expectedSongs.sort((a, b) => {
|
pool.sort((a, b) => {
|
||||||
const diffA = getSongContributorCount(b) - getSongContributorCount(a)
|
const diffA = getSongContributorCount(b) - getSongContributorCount(a)
|
||||||
if (diffA !== 0) return diffA
|
if (diffA !== 0) return diffA
|
||||||
const seqDiff = (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999)
|
const seqDiff = (a.expectedPlaySequence || 9999) - (b.expectedPlaySequence || 9999)
|
||||||
@@ -1968,21 +2132,64 @@ const autoScheduleExpected = () => {
|
|||||||
return (b.voteCount || 0) - (a.voteCount || 0)
|
return (b.voteCount || 0) - (a.voteCount || 0)
|
||||||
})
|
})
|
||||||
} else {
|
} 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()
|
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)
|
const existingIndex = localScheduledSongs.value.findIndex((s) => s.song?.id === song.id)
|
||||||
if (existingIndex !== -1) return
|
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 = {
|
const newSchedule = {
|
||||||
id: baseId + idx,
|
id: baseId + idx,
|
||||||
song: song,
|
song: song,
|
||||||
playDate: selectedDate.value,
|
playDate: targetPlayDate,
|
||||||
sequence: localScheduledSongs.value.length + 1,
|
sequence: nextSeq,
|
||||||
playTimeId,
|
playTimeId: targetPlayTimeId,
|
||||||
isNew: true,
|
isNew: true,
|
||||||
isLocalOnly: true
|
isLocalOnly: true
|
||||||
}
|
}
|
||||||
@@ -1991,14 +2198,42 @@ const autoScheduleExpected = () => {
|
|||||||
count++
|
count++
|
||||||
})
|
})
|
||||||
|
|
||||||
localScheduledSongs.value.forEach((item, idx) => {
|
// 5. 按 playDate + playTime 顺序重排 sequence
|
||||||
item.sequence = idx + 1
|
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
|
hasChanges.value = true
|
||||||
const strategyLabel =
|
const strategyLabel =
|
||||||
autoScheduleStrategy.value === 'contributor-count' ? '(按投稿人数优先)' : ''
|
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 sourceDate = selectedDate.value
|
||||||
const sourceSchedules = [...publicSchedules.value, ...drafts.value].filter((schedule) => {
|
const sourceSchedules = [...publicSchedules.value, ...drafts.value].filter((schedule) => {
|
||||||
if (!schedule.playDate) return false
|
if (!schedule.playDate) return false
|
||||||
return new Date(schedule.playDate).toISOString().split('T')[0] === sourceDate
|
return _fmtLocal(schedule.playDate) === sourceDate
|
||||||
})
|
})
|
||||||
|
|
||||||
if (sourceSchedules.length === 0) {
|
if (sourceSchedules.length === 0) {
|
||||||
@@ -2279,21 +2514,26 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 发布排期(需要确认)
|
// 发布排期(需要确认)
|
||||||
|
//
|
||||||
|
// ⚠️ 这里**只管发布,绝不删除**。
|
||||||
|
//
|
||||||
|
// 以前为了「清空某天排期」把删除偷偷挂在发布按钮上:列表为空时点发布 → 弹「删除排期」→
|
||||||
|
// 调 bulk-publish 传空数组 → 服务端先删光该日期(含草稿)再插入 0 条。
|
||||||
|
// 于是只要**界面上的列表是空的**(数据还没加载回来、时段筛选没匹配上、切换日期后没刷新完…),
|
||||||
|
// 点「发布」就等于清空当天 —— 按钮叫发布,干的是删除。删除已经拆到
|
||||||
|
// clearTodaySchedule(),发布路径不再有任何破坏性分支。
|
||||||
const publishSchedule = async () => {
|
const publishSchedule = async () => {
|
||||||
try {
|
try {
|
||||||
// 如果列表为空,提示删除排期
|
|
||||||
if (localScheduledSongs.value.length === 0) {
|
if (localScheduledSongs.value.length === 0) {
|
||||||
confirmDialogTitle.value = '删除排期'
|
showToast('当前列表没有歌曲,没有需要发布的内容', 'info')
|
||||||
confirmDialogMessage.value = '确定要删除当天的所有排期吗?此操作不可恢复。'
|
return
|
||||||
confirmDialogType.value = 'danger'
|
|
||||||
confirmDialogConfirmText.value = '确认删除'
|
|
||||||
} else {
|
|
||||||
confirmDialogTitle.value = '发布排期'
|
|
||||||
confirmDialogMessage.value = '确定要发布当前排期吗?发布后将立即公示并发送通知。'
|
|
||||||
confirmDialogType.value = 'warning'
|
|
||||||
confirmDialogConfirmText.value = '发布排期'
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confirmDialogTitle.value = '发布排期'
|
||||||
|
confirmDialogMessage.value = '确定要发布当前排期吗?发布后将立即公示并发送通知。'
|
||||||
|
confirmDialogType.value = 'warning'
|
||||||
|
confirmDialogConfirmText.value = '发布排期'
|
||||||
|
|
||||||
confirmAction.value = async () => {
|
confirmAction.value = async () => {
|
||||||
await publishScheduleConfirmed()
|
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 () => {
|
const publishScheduleConfirmed = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
@@ -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"
|
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">
|
<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>
|
<p class="text-sm text-zinc-500">自定义打印设置,预览并导出排期表</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 权限检查 -->
|
<!-- 权限检查 -->
|
||||||
<div
|
<div
|
||||||
v-if="!canPrintSchedule"
|
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
|
<div
|
||||||
class="w-16 h-16 bg-red-500/10 rounded-full flex items-center justify-center text-red-500 mb-4"
|
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" />
|
<path d="m9 9 6 6" />
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</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>
|
<p class="text-zinc-500 mt-2">您没有打印排期的权限,请联系管理员获取相应权限。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -29,9 +29,9 @@
|
|||||||
<!-- 设置面板 -->
|
<!-- 设置面板 -->
|
||||||
<div class="lg:col-span-4 flex flex-col gap-6 h-auto">
|
<div class="lg:col-span-4 flex flex-col gap-6 h-auto">
|
||||||
<div
|
<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" /> 打印设置
|
<Layout class="w-4 h-4 text-blue-500" /> 打印设置
|
||||||
</h3>
|
</h3>
|
||||||
|
|
||||||
@@ -59,8 +59,8 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'py-2.5 rounded-lg text-sm font-bold transition-all',
|
'py-2.5 rounded-lg text-sm font-bold transition-all',
|
||||||
settings.orientation === 'portrait'
|
settings.orientation === 'portrait'
|
||||||
? 'border border-blue-500/30 bg-blue-600/10 text-blue-400 shadow-sm'
|
? 'border border-blue-200 bg-blue-50 text-blue-700 shadow-sm'
|
||||||
: 'border border-zinc-800 bg-zinc-950 text-zinc-500 hover:text-zinc-300'
|
: 'border border-zinc-200 bg-white text-zinc-500 hover:text-zinc-600'
|
||||||
]"
|
]"
|
||||||
@click="settings.orientation = 'portrait'"
|
@click="settings.orientation = 'portrait'"
|
||||||
>
|
>
|
||||||
@@ -70,8 +70,8 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'py-2.5 rounded-lg text-sm font-bold transition-all',
|
'py-2.5 rounded-lg text-sm font-bold transition-all',
|
||||||
settings.orientation === 'landscape'
|
settings.orientation === 'landscape'
|
||||||
? 'border border-blue-500/30 bg-blue-600/10 text-blue-400 shadow-sm'
|
? 'border border-blue-200 bg-blue-50 text-blue-700 shadow-sm'
|
||||||
: 'border border-zinc-800 bg-zinc-950 text-zinc-500 hover:text-zinc-300'
|
: 'border border-zinc-200 bg-white text-zinc-500 hover:text-zinc-600'
|
||||||
]"
|
]"
|
||||||
@click="settings.orientation = 'landscape'"
|
@click="settings.orientation = 'landscape'"
|
||||||
>
|
>
|
||||||
@@ -89,13 +89,13 @@
|
|||||||
<input
|
<input
|
||||||
v-model="settings.startDate"
|
v-model="settings.startDate"
|
||||||
type="date"
|
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"
|
max="9999-12-31"
|
||||||
>
|
>
|
||||||
<input
|
<input
|
||||||
v-model="settings.endDate"
|
v-model="settings.endDate"
|
||||||
type="date"
|
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"
|
max="9999-12-31"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -103,7 +103,7 @@
|
|||||||
<button
|
<button
|
||||||
v-for="range in dateRanges"
|
v-for="range in dateRanges"
|
||||||
:key="range.value"
|
: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)"
|
@click="setDateRange(range.value)"
|
||||||
>
|
>
|
||||||
{{ range.label }}
|
{{ range.label }}
|
||||||
@@ -127,7 +127,7 @@
|
|||||||
'w-5 h-5 rounded flex items-center justify-center border transition-all',
|
'w-5 h-5 rounded flex items-center justify-center border transition-all',
|
||||||
settings[option.key]
|
settings[option.key]
|
||||||
? 'bg-blue-600 border-blue-600'
|
? '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" />
|
<CheckCircle2 v-if="settings[option.key]" class="w-3 h-3 text-white" />
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'text-sm font-medium transition-colors',
|
'text-sm font-medium transition-colors',
|
||||||
settings[option.key]
|
settings[option.key]
|
||||||
? 'text-zinc-200'
|
? 'text-zinc-700'
|
||||||
: 'text-zinc-500 group-hover:text-zinc-400'
|
: 'text-zinc-500 group-hover:text-zinc-400'
|
||||||
]"
|
]"
|
||||||
>{{ option.label }}</span
|
>{{ option.label }}</span
|
||||||
@@ -153,7 +153,7 @@
|
|||||||
'w-5 h-5 rounded flex items-center justify-center border transition-all',
|
'w-5 h-5 rounded flex items-center justify-center border transition-all',
|
||||||
settings.showSchoolLogo
|
settings.showSchoolLogo
|
||||||
? 'bg-blue-600 border-blue-600'
|
? '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" />
|
<CheckCircle2 v-if="settings.showSchoolLogo" class="w-3 h-3 text-white" />
|
||||||
@@ -163,7 +163,7 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'text-sm font-medium transition-colors',
|
'text-sm font-medium transition-colors',
|
||||||
settings.showSchoolLogo
|
settings.showSchoolLogo
|
||||||
? 'text-zinc-200'
|
? 'text-zinc-700'
|
||||||
: 'text-zinc-500 group-hover:text-zinc-400'
|
: 'text-zinc-500 group-hover:text-zinc-400'
|
||||||
]"
|
]"
|
||||||
>学校Logo</span
|
>学校Logo</span
|
||||||
@@ -180,15 +180,15 @@
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="settings.remark"
|
v-model="settings.remark"
|
||||||
placeholder="请输入备注信息(可选)"
|
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>
|
</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
|
<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"
|
@click="refreshPreview"
|
||||||
>
|
>
|
||||||
<RefreshCw class="w-4 h-4" /> 刷新预览
|
<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="lg:col-span-8 flex flex-col gap-4 h-[700px] lg:h-0 lg:min-h-full mb-8 lg:mb-0">
|
||||||
<div
|
<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">
|
<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-100">
|
<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" /> 预览区域
|
<AlignLeft class="w-4.5 h-4.5 text-zinc-500" /> 预览区域
|
||||||
</h3>
|
</h3>
|
||||||
<div class="flex items-center gap-2 text-xs font-bold">
|
<div class="flex items-center gap-2 text-xs font-bold">
|
||||||
@@ -245,7 +245,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<div
|
||||||
@@ -260,7 +260,7 @@
|
|||||||
<!-- 页面头部 -->
|
<!-- 页面头部 -->
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div class="logo-section">
|
<div class="logo-section">
|
||||||
<img :src="logoUrl" alt="VoiceHub Logo" class="logo" >
|
<img :src="logoUrl" alt="宝一外校园点歌系统 Logo" class="logo" >
|
||||||
<!-- 竖线分割 -->
|
<!-- 竖线分割 -->
|
||||||
<div class="logo-divider" />
|
<div class="logo-divider" />
|
||||||
<!-- 学校logo -->
|
<!-- 学校logo -->
|
||||||
@@ -356,7 +356,7 @@
|
|||||||
>备注:{{ settings.remark }}</span
|
>备注:{{ settings.remark }}</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<span class="footer-right">Generated by VoiceHub | 广播管理系统</span>
|
<span class="footer-right">Generated by 宝一外校园点歌系统 | 广播管理系统</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1383,15 +1383,15 @@ watch(
|
|||||||
/* 预览区域和打印样式的核心CSS - 保持原生CSS以确保精确控制 */
|
/* 预览区域和打印样式的核心CSS - 保持原生CSS以确保精确控制 */
|
||||||
|
|
||||||
.preview-content {
|
.preview-content {
|
||||||
background: #ffffff;
|
background: #ffffff !important;
|
||||||
padding: 0;
|
padding: 0;
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 打印页面样式 */
|
/* 打印页面样式 */
|
||||||
.print-page {
|
.print-page {
|
||||||
background: #ffffff;
|
background: #ffffff !important;
|
||||||
color: #000000;
|
color: #000000 !important;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 30px;
|
padding: 30px;
|
||||||
@@ -1426,7 +1426,7 @@ watch(
|
|||||||
.logo-divider {
|
.logo-divider {
|
||||||
width: 2px;
|
width: 2px;
|
||||||
height: 60px;
|
height: 60px;
|
||||||
background: linear-gradient(to bottom, #ddd, #999, #ddd);
|
background: linear-gradient(to bottom, var(--border-secondary, #dddddd), #999, #ddd);
|
||||||
border-radius: 1px;
|
border-radius: 1px;
|
||||||
margin: 0 4px;
|
margin: 0 4px;
|
||||||
}
|
}
|
||||||
@@ -1445,19 +1445,19 @@ watch(
|
|||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
margin: 0 0 4px 0;
|
margin: 0 0 4px 0;
|
||||||
color: #000;
|
color: #000000 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.title-section h2 {
|
.title-section h2 {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
color: #666;
|
color: #666666 !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-info {
|
.date-info {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #666;
|
color: #666666 !important;
|
||||||
text-align: right;
|
text-align: right;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -1477,14 +1477,14 @@ watch(
|
|||||||
.no-data-message {
|
.no-data-message {
|
||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 60px 20px;
|
padding: 60px 20px;
|
||||||
color: #666;
|
color: var(--text-quaternary, #666666);
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data-icon {
|
.no-data-icon {
|
||||||
width: 64px;
|
width: 64px;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
margin: 0 auto 20px;
|
margin: 0 auto 20px;
|
||||||
color: #ccc;
|
color: var(--text-secondary, #cccccc);
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data-icon svg {
|
.no-data-icon svg {
|
||||||
@@ -1496,7 +1496,7 @@ watch(
|
|||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin: 0 0 12px 0;
|
margin: 0 0 12px 0;
|
||||||
color: #333;
|
color: var(--text-quaternary, #333333);
|
||||||
}
|
}
|
||||||
|
|
||||||
.no-data-message p {
|
.no-data-message p {
|
||||||
@@ -1512,15 +1512,15 @@ watch(
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin: 24px 0 12px 0;
|
margin: 24px 0 12px 0;
|
||||||
padding: 8px 12px;
|
padding: 8px 12px;
|
||||||
border-bottom: 2px solid #ddd;
|
border-bottom: 2px solid var(--border-secondary, #ddd);
|
||||||
background: #f8f9fa;
|
background: #f8f9fa;
|
||||||
color: #333;
|
color: var(--text-quaternary, #333333);
|
||||||
border-radius: 4px 4px 0 0;
|
border-radius: 4px 4px 0 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playtime-group .group-title {
|
.playtime-group .group-title {
|
||||||
background: #e3f2fd;
|
background: #e3f2fd;
|
||||||
border-bottom-color: #2196f3;
|
border-bottom-color: var(--border-secondary, #2196f3);
|
||||||
color: #1565c0;
|
color: #1565c0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1533,7 +1533,7 @@ watch(
|
|||||||
.group-count {
|
.group-count {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: #666;
|
color: var(--text-quaternary, #666666);
|
||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1547,7 +1547,7 @@ watch(
|
|||||||
margin: 16px 0 8px 0;
|
margin: 16px 0 8px 0;
|
||||||
padding: 6px 10px;
|
padding: 6px 10px;
|
||||||
background: #f0f8ff;
|
background: #f0f8ff;
|
||||||
border-left: 3px solid #2196f3;
|
border-left: 3px solid var(--border-secondary, #2196f3);
|
||||||
color: #1565c0;
|
color: #1565c0;
|
||||||
border-radius: 0 4px 4px 0;
|
border-radius: 0 4px 4px 0;
|
||||||
}
|
}
|
||||||
@@ -1555,7 +1555,7 @@ watch(
|
|||||||
.playtime-count {
|
.playtime-count {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
color: #666;
|
color: var(--text-quaternary, #666666);
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1570,7 +1570,7 @@ watch(
|
|||||||
padding-top: 16px;
|
padding-top: 16px;
|
||||||
border-top: 1px solid #e5e5e5;
|
border-top: 1px solid #e5e5e5;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #666;
|
color: var(--text-quaternary, #666666);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-left {
|
.footer-left {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- 顶部标题栏 -->
|
<!-- 顶部标题栏 -->
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||||
<div>
|
<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 class="text-xs text-zinc-500 mt-1">
|
||||||
定义系统的教学周期,点歌、排期及统计数据将基于当前活跃学期进行归档
|
定义系统的教学周期,点歌、排期及统计数据将基于当前活跃学期进行归档
|
||||||
</p>
|
</p>
|
||||||
@@ -134,7 +134,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-else
|
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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
@@ -158,7 +158,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="18"
|
width="18"
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div
|
||||||
v-if="loading && semesters.length === 0"
|
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>
|
<p class="text-xs font-bold text-zinc-600">加载中...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -225,8 +225,8 @@
|
|||||||
class="group flex items-center justify-between p-6 rounded-[2rem] border transition-all"
|
class="group flex items-center justify-between p-6 rounded-[2rem] border transition-all"
|
||||||
:class="
|
:class="
|
||||||
sem.isActive
|
sem.isActive
|
||||||
? 'bg-blue-600/5 border-blue-500/30'
|
? 'bg-blue-600/5 border-blue-200'
|
||||||
: 'bg-zinc-900/40 border-zinc-800 hover:border-zinc-700'
|
: 'bg-zinc-50/40 border-zinc-200 hover:border-zinc-200'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-5">
|
<div class="flex items-center gap-5">
|
||||||
@@ -235,7 +235,7 @@
|
|||||||
:class="
|
:class="
|
||||||
sem.isActive
|
sem.isActive
|
||||||
? 'bg-blue-600 text-white shadow-lg shadow-blue-900/20'
|
? '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
|
<svg
|
||||||
@@ -258,7 +258,7 @@
|
|||||||
<h5
|
<h5
|
||||||
class="font-bold transition-colors"
|
class="font-bold transition-colors"
|
||||||
:class="
|
: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 }}
|
{{ sem.name }}
|
||||||
@@ -293,7 +293,7 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<button
|
<button
|
||||||
:disabled="loading"
|
: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)"
|
@click="setActive(sem.id)"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -342,7 +342,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="semesters.length === 0 && !loading"
|
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>
|
<p class="text-xs font-bold text-zinc-600">暂无学期数据</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -356,14 +356,14 @@
|
|||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="p-8 space-y-6">
|
<div class="p-8 space-y-6">
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<h3 class="text-xl font-black text-white tracking-tight">添加新学期</h3>
|
<h3 class="text-xl font-black text-white tracking-tight">添加新学期</h3>
|
||||||
<button
|
<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"
|
@click="closeAddModal"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -392,7 +392,7 @@
|
|||||||
v-model="newSemester.name"
|
v-model="newSemester.name"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="例如: 2025学年第一学期"
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -400,11 +400,11 @@
|
|||||||
<input
|
<input
|
||||||
v-model="newSemester.isActive"
|
v-model="newSemester.isActive"
|
||||||
type="checkbox"
|
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>
|
<div>
|
||||||
<span
|
<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
|
>设为当前活跃学期</span
|
||||||
>
|
>
|
||||||
<p class="text-[10px] text-zinc-600 font-medium">
|
<p class="text-[10px] text-zinc-600 font-medium">
|
||||||
@@ -413,7 +413,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</label>
|
</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
|
<svg
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
width="14"
|
width="14"
|
||||||
@@ -437,9 +437,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<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"
|
@click="closeAddModal"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside
|
<aside
|
||||||
:class="[
|
: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'
|
isOpen ? 'translate-x-0' : '-translate-x-full'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
|
<div class="flex-shrink-0 group-hover:scale-110 transition-all duration-300">
|
||||||
<img
|
<img
|
||||||
:src="logo"
|
:src="logo"
|
||||||
alt="VoiceHub Logo"
|
alt="宝一外校园点歌系统 Logo"
|
||||||
class="w-8 h-8 object-contain"
|
class="w-8 h-8 object-contain"
|
||||||
width="32"
|
width="32"
|
||||||
height="32"
|
height="32"
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 品牌文字 -->
|
<!-- 品牌文字 -->
|
||||||
<div class="flex flex-col justify-center">
|
<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
|
<p
|
||||||
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
|
class="text-[10px] text-zinc-500 mt-1.5 uppercase tracking-widest font-bold leading-none"
|
||||||
>
|
>
|
||||||
@@ -49,8 +49,8 @@
|
|||||||
:class="[
|
: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',
|
'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
|
activeTab === item.id
|
||||||
? 'bg-blue-600/10 text-blue-400 border-blue-500/20'
|
? 'bg-blue-50 text-blue-700 border-blue-200'
|
||||||
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40 border-transparent',
|
: 'text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100 border-transparent',
|
||||||
getTourClass(item.id)
|
getTourClass(item.id)
|
||||||
]"
|
]"
|
||||||
@click="onNavigate(item.id)"
|
@click="onNavigate(item.id)"
|
||||||
@@ -61,8 +61,8 @@
|
|||||||
:size="18"
|
:size="18"
|
||||||
:class="
|
:class="
|
||||||
activeTab === item.id
|
activeTab === item.id
|
||||||
? 'text-blue-400'
|
? 'text-blue-700'
|
||||||
: 'text-zinc-500 group-hover:text-zinc-300'
|
: 'text-zinc-500 group-hover:text-zinc-700'
|
||||||
"
|
"
|
||||||
/>
|
/>
|
||||||
<span class="truncate">{{ item.label }}</span>
|
<span class="truncate">{{ item.label }}</span>
|
||||||
@@ -78,26 +78,26 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<!-- 用户信息及退出登录 -->
|
<!-- 用户信息及退出登录 -->
|
||||||
<div class="mt-4 pt-4 border-t border-zinc-800">
|
<div class="mt-4 pt-4 border-t border-zinc-200">
|
||||||
<div
|
<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
|
<img
|
||||||
v-if="currentUser?.avatar && !avatarError"
|
v-if="currentUser?.avatar && !avatarError"
|
||||||
:src="currentUser.avatar"
|
: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"
|
@error="avatarError = true"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-else
|
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) }}
|
{{ (currentUser?.name || '管').charAt(0) }}
|
||||||
</div>
|
</div>
|
||||||
<!-- 用户详细信息 -->
|
<!-- 用户详细信息 -->
|
||||||
<div class="flex-1 min-w-0">
|
<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 || '管理员' }}
|
{{ currentUser?.name || '管理员' }}
|
||||||
</p>
|
</p>
|
||||||
<p
|
<p
|
||||||
@@ -108,7 +108,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<!-- 退出按钮 -->
|
<!-- 退出按钮 -->
|
||||||
<button
|
<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="退出登录"
|
title="退出登录"
|
||||||
@click="$emit('logout')"
|
@click="$emit('logout')"
|
||||||
>
|
>
|
||||||
@@ -190,7 +190,7 @@ const menuGroups = [
|
|||||||
{ icon: Music2, label: '歌曲管理', id: 'songs' },
|
{ icon: Music2, label: '歌曲管理', id: 'songs' },
|
||||||
{ icon: CalendarDays, label: '排期管理', id: 'schedule' },
|
{ icon: CalendarDays, label: '排期管理', id: 'schedule' },
|
||||||
{ icon: Printer, label: '打印排期', id: 'print' },
|
{ 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;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--bg-tertiary, #27272a);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- 顶部标题栏 -->
|
<!-- 顶部标题栏 -->
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-4">
|
||||||
<div>
|
<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 class="text-xs text-zinc-500 mt-1 font-medium">
|
||||||
管理站点全局属性、视觉识别、点歌逻辑及系统安全策略
|
管理站点全局属性、视觉识别、点歌逻辑及系统安全策略
|
||||||
</p>
|
</p>
|
||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="flex gap-3">
|
<div class="flex gap-3">
|
||||||
<button
|
<button
|
||||||
:disabled="loading || saving"
|
: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"
|
@click="resetForm"
|
||||||
>
|
>
|
||||||
<RotateCcw :size="14" /> 重置
|
<RotateCcw :size="14" /> 重置
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
|
<div v-if="loading" class="flex flex-col items-center justify-center py-20">
|
||||||
<div
|
<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>
|
<p class="text-zinc-500 text-sm">加载配置中...</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
<!-- 基础信息 -->
|
<!-- 基础信息 -->
|
||||||
<section :class="cardClass">
|
<section :class="cardClass">
|
||||||
<h3
|
<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" /> 基础信息
|
<Globe :size="16" class="text-blue-500" /> 基础信息
|
||||||
</h3>
|
</h3>
|
||||||
@@ -100,7 +100,7 @@
|
|||||||
<!-- 视觉识别 -->
|
<!-- 视觉识别 -->
|
||||||
<section :class="cardClass">
|
<section :class="cardClass">
|
||||||
<h3
|
<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" /> 视觉识别
|
<ImageIcon :size="16" class="text-purple-500" /> 视觉识别
|
||||||
</h3>
|
</h3>
|
||||||
@@ -137,10 +137,10 @@
|
|||||||
|
|
||||||
<!-- 投稿须知 -->
|
<!-- 投稿须知 -->
|
||||||
<section
|
<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
|
<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" /> 投稿须知
|
<FileText :size="16" class="text-emerald-500" /> 投稿须知
|
||||||
</h3>
|
</h3>
|
||||||
@@ -155,75 +155,75 @@
|
|||||||
<!-- 投稿逻辑设置 -->
|
<!-- 投稿逻辑设置 -->
|
||||||
<section :class="cardClass">
|
<section :class="cardClass">
|
||||||
<h3
|
<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" /> 投稿逻辑设置
|
<Settings2 :size="16" class="text-amber-500" /> 投稿逻辑设置
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<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>
|
<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>
|
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户添加联合投稿人并发起协作投稿</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="formData.enableCollaborativeSubmission"
|
v-model="formData.enableCollaborativeSubmission"
|
||||||
type="checkbox"
|
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>
|
||||||
|
|
||||||
<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>
|
<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>
|
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户在投稿时附加公开或仅管理员可见的备注</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="formData.enableSubmissionRemarks"
|
v-model="formData.enableSubmissionRemarks"
|
||||||
type="checkbox"
|
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>
|
||||||
|
|
||||||
<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>
|
<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>
|
<p class="text-[10px] text-zinc-500 mt-0.5">允许用户对本学期已播放过的歌曲再次申请</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="formData.enableReplayRequests"
|
v-model="formData.enableReplayRequests"
|
||||||
type="checkbox"
|
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>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<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>
|
<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>
|
<p class="text-[10px] text-zinc-500 mt-0.5">限制单个用户的点歌频率</p>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input
|
||||||
v-model="formData.enableSubmissionLimit"
|
v-model="formData.enableSubmissionLimit"
|
||||||
type="checkbox"
|
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>
|
||||||
|
|
||||||
<div v-if="formData.enableSubmissionLimit" class="space-y-4">
|
<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
|
<button
|
||||||
:class="[
|
:class="[
|
||||||
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||||
currentLimitType === 'daily'
|
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'
|
: 'text-zinc-600 hover:text-zinc-400'
|
||||||
]"
|
]"
|
||||||
@click="handleLimitTypeChange('daily')"
|
@click="handleLimitTypeChange('daily')"
|
||||||
@@ -234,7 +234,7 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||||
currentLimitType === 'weekly'
|
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'
|
: 'text-zinc-600 hover:text-zinc-400'
|
||||||
]"
|
]"
|
||||||
@click="handleLimitTypeChange('weekly')"
|
@click="handleLimitTypeChange('weekly')"
|
||||||
@@ -245,7 +245,7 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
'py-2 rounded-lg text-[10px] font-black uppercase tracking-widest transition-all',
|
||||||
currentLimitType === 'monthly'
|
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'
|
: 'text-zinc-600 hover:text-zinc-400'
|
||||||
]"
|
]"
|
||||||
@click="handleLimitTypeChange('monthly')"
|
@click="handleLimitTypeChange('monthly')"
|
||||||
@@ -279,23 +279,23 @@
|
|||||||
<!-- 安全与隐私设置 -->
|
<!-- 安全与隐私设置 -->
|
||||||
<section :class="cardClass">
|
<section :class="cardClass">
|
||||||
<h3
|
<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" /> 安全与隐私设置
|
<Shield :size="16" class="text-rose-500" /> 安全与隐私设置
|
||||||
</h3>
|
</h3>
|
||||||
<div class="space-y-4">
|
<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="flex items-start gap-4">
|
||||||
<div class="shrink-0 pt-0.5">
|
<div class="shrink-0 pt-0.5">
|
||||||
<input
|
<input
|
||||||
id="show-keywords"
|
id="show-keywords"
|
||||||
v-model="formData.showBlacklistKeywords"
|
v-model="formData.showBlacklistKeywords"
|
||||||
type="checkbox"
|
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>
|
</div>
|
||||||
<label for="show-keywords" class="cursor-pointer">
|
<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 class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
|
||||||
开启后,在投稿命中黑名单时将明确提示冲突关键词;关闭则仅提示“包含关键词”。
|
开启后,在投稿命中黑名单时将明确提示冲突关键词;关闭则仅提示“包含关键词”。
|
||||||
</p>
|
</p>
|
||||||
@@ -303,18 +303,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</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="flex items-start gap-4">
|
||||||
<div class="shrink-0 pt-0.5">
|
<div class="shrink-0 pt-0.5">
|
||||||
<input
|
<input
|
||||||
id="hide-students"
|
id="hide-students"
|
||||||
v-model="formData.hideStudentInfo"
|
v-model="formData.hideStudentInfo"
|
||||||
type="checkbox"
|
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>
|
</div>
|
||||||
<label for="hide-students" class="cursor-pointer">
|
<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 class="text-[10px] text-zinc-500 mt-1 leading-relaxed">
|
||||||
开启后,非管理员用户在前端点歌列表、排期预览中将无法查看投稿学生的完整学号与真实姓名。
|
开启后,非管理员用户在前端点歌列表、排期预览中将无法查看投稿学生的完整学号与真实姓名。
|
||||||
</p>
|
</p>
|
||||||
@@ -359,9 +359,9 @@ const saveSuccess = ref(false)
|
|||||||
|
|
||||||
// 样式类常量
|
// 样式类常量
|
||||||
const inputClass =
|
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 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. 投稿时无需加入书名号
|
const defaultSubmissionGuidelines = `1. 投稿时无需加入书名号
|
||||||
2. 除DJ外,其他类型歌曲均接收(包括小语种)
|
2. 除DJ外,其他类型歌曲均接收(包括小语种)
|
||||||
@@ -467,7 +467,7 @@ const saveConfig = async () => {
|
|||||||
saving.value = true
|
saving.value = true
|
||||||
const configToSave = {
|
const configToSave = {
|
||||||
...formData.value,
|
...formData.value,
|
||||||
siteTitle: (formData.value.siteTitle || '').trim() || '校园广播站点歌系统',
|
siteTitle: (formData.value.siteTitle || '').trim() || '宝一外校园点歌系统',
|
||||||
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/images/site-logo.png',
|
siteLogoUrl: (formData.value.siteLogoUrl || '').trim() || '/images/site-logo.png',
|
||||||
siteVersion: (formData.value.siteVersion || '').trim() || null,
|
siteVersion: (formData.value.siteVersion || '').trim() || null,
|
||||||
submissionGuidelines:
|
submissionGuidelines:
|
||||||
|
|||||||
@@ -3,14 +3,14 @@
|
|||||||
<!-- 头部区域 -->
|
<!-- 头部区域 -->
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6">
|
||||||
<div>
|
<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 class="text-xs text-zinc-500 mt-1">
|
||||||
配置 SMTP 服务以发送系统验证码、投稿通知及其他重要提醒
|
配置 SMTP 服务以发送系统验证码、投稿通知及其他重要提醒
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<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"
|
:disabled="reloading"
|
||||||
@click="reloadSmtpConfig"
|
@click="reloadSmtpConfig"
|
||||||
>
|
>
|
||||||
@@ -31,16 +31,16 @@
|
|||||||
<!-- 左侧栏:SMTP 设置与测试 -->
|
<!-- 左侧栏:SMTP 设置与测试 -->
|
||||||
<div class="xl:col-span-4 space-y-8">
|
<div class="xl:col-span-4 space-y-8">
|
||||||
<!-- SMTP 核心设置 -->
|
<!-- 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">
|
<div class="flex items-center justify-between">
|
||||||
<h3
|
<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" /> 服务配置
|
<Server :size="16" class="text-blue-500" /> 服务配置
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<button
|
||||||
class="relative w-10 h-5 rounded-full transition-colors"
|
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"
|
@click="config.smtpEnabled = !config.smtpEnabled"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
v-model="config.smtpHost"
|
v-model="config.smtpHost"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="smtp.example.com"
|
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>
|
||||||
<div class="grid grid-cols-2 gap-3">
|
<div class="grid grid-cols-2 gap-3">
|
||||||
@@ -71,7 +71,7 @@
|
|||||||
v-model.number="config.smtpPort"
|
v-model.number="config.smtpPort"
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="587"
|
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>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
v-model="config.smtpUsername"
|
v-model="config.smtpUsername"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="your-email@example.com"
|
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>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
v-model="config.smtpFromEmail"
|
v-model="config.smtpFromEmail"
|
||||||
type="text"
|
type="text"
|
||||||
:placeholder="config.smtpUsername || 'your-email@example.com'"
|
: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">
|
<p class="text-[9px] text-zinc-500 px-1 italic">
|
||||||
通常与发件人账号一致,部分服务商强制要求一致。
|
通常与发件人账号一致,部分服务商强制要求一致。
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
v-model="config.smtpPassword"
|
v-model="config.smtpPassword"
|
||||||
type="password"
|
type="password"
|
||||||
placeholder="••••••••••••"
|
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>
|
||||||
<div class="space-y-1.5">
|
<div class="space-y-1.5">
|
||||||
@@ -130,16 +130,16 @@
|
|||||||
v-model="config.smtpFromName"
|
v-model="config.smtpFromName"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="校园广播站"
|
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>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<!-- SMTP 测试模块 -->
|
<!-- 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
|
<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" /> 服务测试
|
<Send :size="16" class="text-emerald-500" /> 服务测试
|
||||||
</h3>
|
</h3>
|
||||||
@@ -154,10 +154,10 @@
|
|||||||
v-model="testEmail"
|
v-model="testEmail"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="输入测试邮箱地址"
|
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
|
<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"
|
:disabled="testing || !testEmail || !config.smtpEnabled"
|
||||||
@click="sendTestEmail"
|
@click="sendTestEmail"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 px-1">
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 px-1">
|
||||||
<div class="space-y-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-6 mt-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">待审核</span>
|
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest">待审核</span>
|
||||||
@@ -15,7 +15,7 @@
|
|||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<button
|
||||||
:disabled="loading"
|
: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)"
|
@click="refreshSongs(true)"
|
||||||
>
|
>
|
||||||
<RotateCcw :size="16" :class="{ 'animate-spin': loading }" /> 刷新
|
<RotateCcw :size="16" :class="{ 'animate-spin': loading }" /> 刷新
|
||||||
@@ -25,15 +25,15 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 歌曲表格 -->
|
<!-- 歌曲表格 -->
|
||||||
<div class="bg-zinc-900/10 border border-zinc-800/40 rounded-xl overflow-hidden shadow-2xl relative">
|
<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-zinc-950/40 backdrop-blur-sm z-10 flex items-center justify-center">
|
<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="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>
|
<span class="text-xs font-bold text-zinc-400 tracking-widest uppercase">正在加载...</span>
|
||||||
</div>
|
</div>
|
||||||
</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-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-2 text-[10px] font-black text-zinc-600 uppercase tracking-widest">投稿人</div>
|
||||||
<div class="col-span-3 text-[10px] font-black text-zinc-600 uppercase tracking-widest">期望排期</div>
|
<div class="col-span-3 text-[10px] font-black text-zinc-600 uppercase tracking-widest">期望排期</div>
|
||||||
@@ -49,17 +49,17 @@
|
|||||||
<div
|
<div
|
||||||
v-for="song in pendingAuditSongs"
|
v-for="song in pendingAuditSongs"
|
||||||
:key="song.id"
|
: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="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">
|
<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" />
|
<Music v-else :size="20" class="text-zinc-600" />
|
||||||
</div>
|
</div>
|
||||||
<div class="min-w-0">
|
<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>
|
<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" />
|
<MessageSquare :size="12" />
|
||||||
</button>
|
</button>
|
||||||
</h4>
|
</h4>
|
||||||
@@ -80,10 +80,10 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-span-12 lg:col-span-3 flex items-center justify-end gap-2">
|
<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>
|
||||||
<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>
|
</button>
|
||||||
</div>
|
</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">
|
<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 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-zinc-900 border border-zinc-800 rounded-xl shadow-2xl overflow-hidden" @click.stop>
|
<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-800/50 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-zinc-100">未过审处理</h3>
|
<h3 class="text-xl font-black text-zinc-900">未过审处理</h3>
|
||||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="showRejectDialog = false"><X :size="20" /></button>
|
<button class="text-zinc-500 hover:text-zinc-600 transition-colors" @click="showRejectDialog = false"><X :size="20" /></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-8 space-y-6">
|
<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>
|
<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>
|
</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" @click="showRejectDialog = false">取消</button>
|
<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">
|
<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 ? '处理中...' : '确认释放排期' }}
|
{{ auditLoading ? '处理中...' : '确认释放排期' }}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -8,14 +8,14 @@
|
|||||||
<Transition name="scale">
|
<Transition name="scale">
|
||||||
<div
|
<div
|
||||||
v-if="show"
|
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
|
@click.stop
|
||||||
>
|
>
|
||||||
<!-- 头部 -->
|
<!-- 头部 -->
|
||||||
<div class="flex items-center justify-between p-4 border-b border-zinc-800 shrink-0">
|
<div class="flex items-center justify-between p-4 border-b border-zinc-200 shrink-0">
|
||||||
<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>
|
||||||
<button
|
<button
|
||||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
class="text-zinc-500 hover:text-zinc-600 transition-colors"
|
||||||
@click="closeDialog"
|
@click="closeDialog"
|
||||||
>
|
>
|
||||||
<CloseIcon class="w-5 h-5" />
|
<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="flex flex-col p-4 rounded-2xl border text-left transition-all relative overflow-hidden group"
|
||||||
:class="[
|
:class="[
|
||||||
selectedQuality === option.value
|
selectedQuality === option.value
|
||||||
? 'bg-blue-600/10 border-blue-500 shadow-sm'
|
? 'bg-blue-50 border-blue-500 shadow-sm'
|
||||||
: 'bg-zinc-950 border-zinc-800 hover:border-zinc-700'
|
: 'bg-white border-zinc-200 hover:border-zinc-200'
|
||||||
]"
|
]"
|
||||||
@click="selectedQuality = option.value"
|
@click="selectedQuality = option.value"
|
||||||
>
|
>
|
||||||
<div class="flex items-center justify-between mb-1 relative z-10">
|
<div class="flex items-center justify-between mb-1 relative z-10">
|
||||||
<span
|
<span
|
||||||
class="text-xs font-bold transition-colors"
|
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
|
>{{ option.label }}</span
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -69,16 +69,16 @@
|
|||||||
>
|
>
|
||||||
</div>
|
</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 items-center justify-between">
|
||||||
<div class="flex flex-col">
|
<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>
|
<span class="text-[10px] text-zinc-500">将选中歌曲按顺序合并为单个音频</span>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="w-10 h-6 rounded-full transition-colors relative"
|
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"
|
@click="mergeSongs = !mergeSongs"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -90,20 +90,20 @@
|
|||||||
|
|
||||||
<!-- 合并设置区域 -->
|
<!-- 合并设置区域 -->
|
||||||
<Transition name="expand">
|
<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="space-y-2">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Music class="w-3 h-3 text-zinc-500" />
|
<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>
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<button
|
<button
|
||||||
class="flex-1 py-1.5 px-3 rounded-lg border text-[10px] font-bold transition-all"
|
class="flex-1 py-1.5 px-3 rounded-lg border text-[10px] font-bold transition-all"
|
||||||
:class="
|
:class="
|
||||||
exportFormat === 'mp3'
|
exportFormat === 'mp3'
|
||||||
? 'bg-blue-600/10 border-blue-500 text-blue-400'
|
? 'bg-blue-50 border-blue-500 text-blue-700'
|
||||||
: 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:border-zinc-700'
|
: 'bg-white border-zinc-200 text-zinc-400 hover:border-zinc-200'
|
||||||
"
|
"
|
||||||
@click="exportFormat = 'mp3'"
|
@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="flex-1 py-1.5 px-3 rounded-lg border text-[10px] font-bold transition-all"
|
||||||
:class="
|
:class="
|
||||||
exportFormat === 'wav'
|
exportFormat === 'wav'
|
||||||
? 'bg-blue-600/10 border-blue-500 text-blue-400'
|
? 'bg-blue-50 border-blue-500 text-blue-700'
|
||||||
: 'bg-zinc-900 border-zinc-800 text-zinc-400 hover:border-zinc-700'
|
: 'bg-white border-zinc-200 text-zinc-400 hover:border-zinc-200'
|
||||||
"
|
"
|
||||||
@click="exportFormat = 'wav'"
|
@click="exportFormat = 'wav'"
|
||||||
>
|
>
|
||||||
@@ -128,10 +128,10 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<Edit3 class="w-3 h-3 text-zinc-500" />
|
<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>
|
</div>
|
||||||
<button
|
<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 }"
|
:class="{ 'opacity-50 cursor-not-allowed': !customFilename }"
|
||||||
title="保存为默认预设"
|
title="保存为默认预设"
|
||||||
@click="saveFilenamePreset"
|
@click="saveFilenamePreset"
|
||||||
@@ -145,19 +145,19 @@
|
|||||||
v-model="customFilename"
|
v-model="customFilename"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="例如: 第XX期 - {songs}"
|
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">
|
<div class="absolute right-2 top-1/2 -translate-y-1/2 flex gap-1">
|
||||||
<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="插入所有歌名"
|
title="插入所有歌名"
|
||||||
@click="insertPlaceholder('{songs}')"
|
@click="insertPlaceholder('{songs}')"
|
||||||
>
|
>
|
||||||
{songs}
|
{songs}
|
||||||
</button>
|
</button>
|
||||||
<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="插入当前日期"
|
title="插入当前日期"
|
||||||
@click="insertPlaceholder('{date}')"
|
@click="insertPlaceholder('{date}')"
|
||||||
>
|
>
|
||||||
@@ -168,13 +168,13 @@
|
|||||||
<p class="text-[9px] text-zinc-600">
|
<p class="text-[9px] text-zinc-600">
|
||||||
可用占位符:
|
可用占位符:
|
||||||
<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('{songs}')"
|
@click="insertPlaceholder('{songs}')"
|
||||||
>{songs}</code
|
>{songs}</code
|
||||||
>
|
>
|
||||||
(所有歌名),
|
(所有歌名),
|
||||||
<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}')"
|
@click="insertPlaceholder('{date}')"
|
||||||
>{date}</code
|
>{date}</code
|
||||||
>
|
>
|
||||||
@@ -184,14 +184,14 @@
|
|||||||
|
|
||||||
<!-- 标准化选项 (仅在合并模式或需要时显示,这里允许单独使用) -->
|
<!-- 标准化选项 (仅在合并模式或需要时显示,这里允许单独使用) -->
|
||||||
<div
|
<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 flex-col">
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<span
|
||||||
v-if="normalizeAudio"
|
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
|
>Peak {{ targetDb }}dB</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -201,7 +201,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<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="保存当前音量设置(包括开启状态)为默认"
|
title="保存当前音量设置(包括开启状态)为默认"
|
||||||
@click="saveDbPreset"
|
@click="saveDbPreset"
|
||||||
>
|
>
|
||||||
@@ -210,7 +210,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="w-10 h-6 rounded-full transition-colors relative"
|
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"
|
@click="normalizeAudio = !normalizeAudio"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@@ -232,9 +232,9 @@
|
|||||||
min="-10"
|
min="-10"
|
||||||
max="0"
|
max="0"
|
||||||
step="0.5"
|
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
|
>{{ targetDb }} dB</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -255,7 +255,7 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="estimatedTotalDuration.count > 0"
|
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" />
|
<Clock class="w-3 h-3" />
|
||||||
<span>预估总时长: {{ formatDuration(estimatedTotalDuration.total) }}</span>
|
<span>预估总时长: {{ formatDuration(estimatedTotalDuration.total) }}</span>
|
||||||
@@ -264,7 +264,7 @@
|
|||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="selectedSongs.size > 0"
|
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="预下载选中歌曲到浏览器缓存"
|
title="预下载选中歌曲到浏览器缓存"
|
||||||
@click="preloadSelectedSongs"
|
@click="preloadSelectedSongs"
|
||||||
>
|
>
|
||||||
@@ -272,7 +272,7 @@
|
|||||||
预下载选中
|
预下载选中
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="toggleSelectAll"
|
||||||
>
|
>
|
||||||
{{ isAllSelected ? '取消全选' : '全选' }}
|
{{ isAllSelected ? '取消全选' : '全选' }}
|
||||||
@@ -281,12 +281,12 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<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
|
<div
|
||||||
v-for="song in songs"
|
v-for="song in songs"
|
||||||
:key="song.id"
|
: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
|
<div
|
||||||
@@ -310,7 +310,7 @@
|
|||||||
:class="[
|
:class="[
|
||||||
selectedSongs.has(song.song.id)
|
selectedSongs.has(song.song.id)
|
||||||
? 'bg-blue-600 border-blue-600 shadow-sm'
|
? '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)"
|
@click="toggleSongSelection(song.song.id)"
|
||||||
>
|
>
|
||||||
@@ -326,7 +326,7 @@
|
|||||||
@click="toggleSongSelection(song.song.id)"
|
@click="toggleSongSelection(song.song.id)"
|
||||||
>
|
>
|
||||||
<div class="flex items-center gap-2">
|
<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
|
<div
|
||||||
v-if="
|
v-if="
|
||||||
@@ -360,7 +360,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else
|
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="预下载此歌曲"
|
title="预下载此歌曲"
|
||||||
@click.stop="preloadSong(song.song)"
|
@click.stop="preloadSong(song.song)"
|
||||||
>
|
>
|
||||||
@@ -378,16 +378,16 @@
|
|||||||
<!-- 进度条 -->
|
<!-- 进度条 -->
|
||||||
<section
|
<section
|
||||||
v-if="downloading || downloadedCount > 0"
|
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
|
<div
|
||||||
class="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider"
|
class="flex items-center justify-between text-[10px] font-bold uppercase tracking-wider"
|
||||||
>
|
>
|
||||||
<span class="text-zinc-400">{{ mergeSongs ? '处理进度' : '下载进度' }}</span>
|
<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>
|
||||||
<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
|
<div
|
||||||
class="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-300 ease-out relative overflow-hidden"
|
class="h-full bg-gradient-to-r from-blue-600 to-indigo-500 transition-all duration-300 ease-out relative overflow-hidden"
|
||||||
@@ -402,7 +402,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="text-[10px] text-zinc-500 truncate">
|
<div class="text-[10px] text-zinc-500 truncate">
|
||||||
<template v-if="downloading">
|
<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
|
processingStatus
|
||||||
}}</span>
|
}}</span>
|
||||||
<span v-else>{{
|
<span v-else>{{
|
||||||
@@ -438,14 +438,14 @@
|
|||||||
|
|
||||||
<!-- 底部按钮 -->
|
<!-- 底部按钮 -->
|
||||||
<div
|
<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">
|
<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>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<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"
|
:disabled="downloading"
|
||||||
@click="closeDialog"
|
@click="closeDialog"
|
||||||
>
|
>
|
||||||
@@ -453,7 +453,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-if="!downloading && downloadedCount > 0"
|
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"
|
@click="closeDialog"
|
||||||
>
|
>
|
||||||
关闭
|
关闭
|
||||||
@@ -1228,7 +1228,7 @@ onUnmounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--bg-tertiary, #27272a);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -4,13 +4,13 @@
|
|||||||
<div class="space-y-6">
|
<div class="space-y-6">
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 px-1">
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 px-1">
|
||||||
<div class="space-y-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-6 mt-4">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||||
>总计</span
|
>总计</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>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
<span class="text-[10px] font-black text-zinc-600 uppercase tracking-widest"
|
||||||
@@ -39,10 +39,10 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="selectedSongs.length > 0"
|
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
|
<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"
|
@click="openDownloadDialog"
|
||||||
>
|
>
|
||||||
<Download :size="14" class="text-emerald-500/70" /> 下载 ({{
|
<Download :size="14" class="text-emerald-500/70" /> 下载 ({{
|
||||||
@@ -50,25 +50,25 @@
|
|||||||
}})
|
}})
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="batchMarkPlayed"
|
||||||
>
|
>
|
||||||
<Check :size="14" class="text-emerald-500/70" /> 已播放 ({{ selectedSongs.length }})
|
<Check :size="14" class="text-emerald-500/70" /> 已播放 ({{ selectedSongs.length }})
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="batchMarkUnplayed"
|
||||||
>
|
>
|
||||||
<RotateCcw :size="14" class="text-blue-500/70" /> 未播放 ({{ selectedSongs.length }})
|
<RotateCcw :size="14" class="text-blue-500/70" /> 未播放 ({{ selectedSongs.length }})
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="batchReject"
|
||||||
>
|
>
|
||||||
<X :size="14" class="text-amber-500/70" /> 驳回 ({{ selectedSongs.length }})
|
<X :size="14" class="text-amber-500/70" /> 驳回 ({{ selectedSongs.length }})
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="batchDelete"
|
||||||
>
|
>
|
||||||
<Trash2 :size="14" class="text-red-500/70" /> 删除 ({{ selectedSongs.length }})
|
<Trash2 :size="14" class="text-red-500/70" /> 删除 ({{ selectedSongs.length }})
|
||||||
@@ -77,14 +77,14 @@
|
|||||||
</Transition>
|
</Transition>
|
||||||
|
|
||||||
<button
|
<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"
|
@click="openAddSongModal"
|
||||||
>
|
>
|
||||||
<Plus :size="16" /> 手动添加
|
<Plus :size="16" /> 手动添加
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="loading"
|
: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)"
|
@click="refreshSongs(true)"
|
||||||
>
|
>
|
||||||
<RotateCcw :size="16" :class="{ 'animate-spin': loading }" /> 刷新
|
<RotateCcw :size="16" :class="{ 'animate-spin': loading }" /> 刷新
|
||||||
@@ -94,7 +94,7 @@
|
|||||||
|
|
||||||
<!-- 过滤器栏 -->
|
<!-- 过滤器栏 -->
|
||||||
<div
|
<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">
|
<div class="relative flex-1 group w-full lg:w-auto">
|
||||||
<Search
|
<Search
|
||||||
@@ -105,7 +105,7 @@
|
|||||||
v-model="searchQuery"
|
v-model="searchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索歌曲标题、艺术家或投稿人..."
|
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>
|
||||||
<div class="grid grid-cols-2 md:grid-cols-3 lg:flex lg:items-center gap-3 w-full lg:w-auto">
|
<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
|
<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
|
<div
|
||||||
v-if="loading"
|
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="flex flex-col items-center gap-3">
|
||||||
<div
|
<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>
|
<span class="text-xs font-bold text-zinc-400 tracking-widest uppercase">正在加载...</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -155,13 +155,13 @@
|
|||||||
|
|
||||||
<!-- 表头 -->
|
<!-- 表头 -->
|
||||||
<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"
|
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">
|
<div class="col-span-1 flex items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="isAllSelected"
|
: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"
|
@change="toggleSelectAll"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,14 +204,14 @@
|
|||||||
:key="song.id"
|
:key="song.id"
|
||||||
:class="[
|
:class="[
|
||||||
'grid grid-cols-1 lg:grid-cols-12 gap-4 px-6 lg:px-8 py-5 transition-all items-center group relative',
|
'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">
|
<div class="hidden lg:flex col-span-1 items-center">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
:checked="selectedSongs.includes(song.id)"
|
: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)"
|
@change="toggleSelectSong(song.id)"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -219,7 +219,7 @@
|
|||||||
<div class="col-span-12 lg:col-span-4 flex items-center gap-4">
|
<div class="col-span-12 lg:col-span-4 flex items-center gap-4">
|
||||||
<div
|
<div
|
||||||
:class="[
|
: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' : ''
|
selectedSongs.includes(song.id) ? 'border-blue-500/50' : ''
|
||||||
]"
|
]"
|
||||||
@click.stop="playSong(song)"
|
@click.stop="playSong(song)"
|
||||||
@@ -237,8 +237,8 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'font-bold transition-colors flex items-center',
|
'font-bold transition-colors flex items-center',
|
||||||
selectedSongs.includes(song.id)
|
selectedSongs.includes(song.id)
|
||||||
? 'text-blue-400'
|
? 'text-blue-700'
|
||||||
: 'text-zinc-100 group-hover:text-blue-400'
|
: 'text-zinc-900 group-hover:text-blue-700'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
@@ -250,7 +250,7 @@
|
|||||||
<span v-else class="truncate">{{ song.title }}</span>
|
<span v-else class="truncate">{{ song.title }}</span>
|
||||||
<button
|
<button
|
||||||
v-if="song.hasSubmissionNote && song.submissionNote"
|
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="查看备注留言"
|
title="查看备注留言"
|
||||||
@click.stop="openSubmissionRemark(song)"
|
@click.stop="openSubmissionRemark(song)"
|
||||||
>
|
>
|
||||||
@@ -261,7 +261,7 @@
|
|||||||
|
|
||||||
<!-- 期望播出日期标签 -->
|
<!-- 期望播出日期标签 -->
|
||||||
<div v-if="song.expectedPlayDate" class="mt-1 flex items-center">
|
<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" />
|
<CalendarDays :size="10" />
|
||||||
期望: {{ song.expectedPlayDate }}
|
期望: {{ song.expectedPlayDate }}
|
||||||
<template v-if="song.expectedPlaySequence">
|
<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',
|
'inline-flex items-center gap-1.5 text-xs font-bold px-2 py-1 rounded-lg transition-all',
|
||||||
song.voteCount > 0
|
song.voteCount > 0
|
||||||
? 'bg-pink-500/10 text-pink-500 hover:bg-pink-500/20 cursor-pointer'
|
? '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)"
|
@click="showVoters(song.id)"
|
||||||
>
|
>
|
||||||
@@ -312,7 +312,7 @@
|
|||||||
? 'bg-emerald-500/10 text-emerald-500'
|
? 'bg-emerald-500/10 text-emerald-500'
|
||||||
: song.scheduled
|
: song.scheduled
|
||||||
? 'bg-blue-500/10 text-blue-500'
|
? 'bg-blue-500/10 text-blue-500'
|
||||||
: 'bg-zinc-800 text-zinc-500'
|
: 'bg-zinc-100 text-zinc-500'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ getStatusText(song) }}
|
{{ 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"
|
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
|
<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="编辑歌曲"
|
title="编辑歌曲"
|
||||||
@click="editSong(song)"
|
@click="editSong(song)"
|
||||||
>
|
>
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
v-if="!song.played"
|
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="标记为已播放"
|
title="标记为已播放"
|
||||||
@click="markAsPlayed(song.id)"
|
@click="markAsPlayed(song.id)"
|
||||||
>
|
>
|
||||||
@@ -340,7 +340,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
v-else
|
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="标记为未播放"
|
title="标记为未播放"
|
||||||
@click="markAsUnplayed(song.id)"
|
@click="markAsUnplayed(song.id)"
|
||||||
>
|
>
|
||||||
@@ -348,14 +348,14 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<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="驳回歌曲"
|
title="驳回歌曲"
|
||||||
@click="rejectSong(song.id)"
|
@click="rejectSong(song.id)"
|
||||||
>
|
>
|
||||||
<X :size="14" />
|
<X :size="14" />
|
||||||
</button>
|
</button>
|
||||||
<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="删除歌曲"
|
title="删除歌曲"
|
||||||
@click="deleteSong(song.id)"
|
@click="deleteSong(song.id)"
|
||||||
>
|
>
|
||||||
@@ -407,16 +407,16 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="showRejectDialog"
|
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
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="px-8 py-6 border-b border-zinc-800/50 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-zinc-100">{{ isBatchReject ? `批量驳回歌曲 (${selectedSongs.length} 首)` : '驳回歌曲' }}</h3>
|
<h3 class="text-xl font-black text-zinc-900">{{ isBatchReject ? `批量驳回歌曲 (${selectedSongs.length} 首)` : '驳回歌曲' }}</h3>
|
||||||
<button
|
<button
|
||||||
class="text-zinc-500 hover:text-zinc-300 transition-colors"
|
class="text-zinc-500 hover:text-zinc-600 transition-colors"
|
||||||
@click="cancelReject"
|
@click="cancelReject"
|
||||||
>
|
>
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
@@ -424,25 +424,25 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-8 space-y-6">
|
<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
|
<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"
|
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" />
|
<Music :size="18" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-xs text-zinc-500">投稿人: {{ rejectSongInfo.requester }}</p>
|
||||||
</div>
|
</div>
|
||||||
</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
|
<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"
|
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" />
|
<ListMusic :size="18" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-xs text-zinc-500">驳回后将一并清理投票、排期记录并发送通知给对应投稿人</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -454,7 +454,7 @@
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="rejectReason"
|
v-model="rejectReason"
|
||||||
placeholder="请输入驳回原因,将通过系统通知发送给投稿人..."
|
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>
|
</div>
|
||||||
|
|
||||||
@@ -462,11 +462,11 @@
|
|||||||
<input
|
<input
|
||||||
v-model="addToBlacklist"
|
v-model="addToBlacklist"
|
||||||
type="checkbox"
|
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>
|
<div>
|
||||||
<span
|
<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
|
>同时将此歌曲加入黑名单</span
|
||||||
>
|
>
|
||||||
<p class="text-[10px] text-zinc-600 font-medium">
|
<p class="text-[10px] text-zinc-600 font-medium">
|
||||||
@@ -476,9 +476,9 @@
|
|||||||
</label>
|
</label>
|
||||||
</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
|
<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"
|
@click="cancelReject"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -516,18 +516,18 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="showEditModal || showAddSongModal"
|
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
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="px-8 py-6 border-b border-zinc-800/50 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-zinc-100">
|
<h3 class="text-xl font-black text-zinc-900">
|
||||||
{{ showEditModal ? '编辑歌曲' : '手动添加歌曲' }}
|
{{ showEditModal ? '编辑歌曲' : '手动添加歌曲' }}
|
||||||
</h3>
|
</h3>
|
||||||
<button
|
<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()"
|
@click="showEditModal ? cancelEditSong() : cancelAddSong()"
|
||||||
>
|
>
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
@@ -545,14 +545,14 @@
|
|||||||
v-model="editForm.title"
|
v-model="editForm.title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入歌曲标题"
|
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
|
<input
|
||||||
v-else
|
v-else
|
||||||
v-model="addForm.title"
|
v-model="addForm.title"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入歌曲标题"
|
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>
|
||||||
<div class="space-y-2">
|
<div class="space-y-2">
|
||||||
@@ -564,14 +564,14 @@
|
|||||||
v-model="editForm.artist"
|
v-model="editForm.artist"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入歌手姓名"
|
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
|
<input
|
||||||
v-else
|
v-else
|
||||||
v-model="addForm.artist"
|
v-model="addForm.artist"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入歌手姓名"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -587,7 +587,7 @@
|
|||||||
v-model="editUserSearchQuery"
|
v-model="editUserSearchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索用户姓名或用户名"
|
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"
|
@focus="showEditUserDropdown = true"
|
||||||
@input="searchEditUsers()"
|
@input="searchEditUsers()"
|
||||||
>
|
>
|
||||||
@@ -596,7 +596,7 @@
|
|||||||
v-model="userSearchQuery"
|
v-model="userSearchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索用户姓名或用户名"
|
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"
|
@focus="showUserDropdown = true"
|
||||||
@input="searchUsers()"
|
@input="searchUsers()"
|
||||||
>
|
>
|
||||||
@@ -605,7 +605,7 @@
|
|||||||
class="absolute right-4 top-1/2 -translate-y-1/2"
|
class="absolute right-4 top-1/2 -translate-y-1/2"
|
||||||
>
|
>
|
||||||
<div
|
<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>
|
||||||
</div>
|
</div>
|
||||||
@@ -617,18 +617,18 @@
|
|||||||
? showEditUserDropdown && filteredEditUsers.length > 0
|
? showEditUserDropdown && filteredEditUsers.length > 0
|
||||||
: showUserDropdown && filteredUsers.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
|
<div
|
||||||
v-for="user in showEditModal
|
v-for="user in showEditModal
|
||||||
? filteredEditUsers.slice(0, 10)
|
? filteredEditUsers.slice(0, 10)
|
||||||
: filteredUsers.slice(0, 10)"
|
: filteredUsers.slice(0, 10)"
|
||||||
:key="user.id"
|
: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)"
|
@click="showEditModal ? selectEditUser(user) : selectUser(user)"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<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>
|
<span class="text-[10px] text-zinc-500">@{{ user.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Check
|
<Check
|
||||||
@@ -674,7 +674,7 @@
|
|||||||
<span
|
<span
|
||||||
v-for="collaborator in selectedEditCollaborators"
|
v-for="collaborator in selectedEditCollaborators"
|
||||||
:key="collaborator.id"
|
: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) }}
|
{{ getCollaboratorDisplayName(collaborator) }}
|
||||||
<button
|
<button
|
||||||
@@ -690,7 +690,7 @@
|
|||||||
v-model="editCollaboratorSearchQuery"
|
v-model="editCollaboratorSearchQuery"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="搜索并添加联合投稿人"
|
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"
|
@focus="showEditCollaboratorDropdown = true"
|
||||||
@input="searchEditCollaborators()"
|
@input="searchEditCollaborators()"
|
||||||
>
|
>
|
||||||
@@ -699,21 +699,21 @@
|
|||||||
class="absolute right-4 top-1/2 -translate-y-1/2"
|
class="absolute right-4 top-1/2 -translate-y-1/2"
|
||||||
>
|
>
|
||||||
<div
|
<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>
|
||||||
<div
|
<div
|
||||||
v-if="showEditCollaboratorDropdown && filteredEditCollaborators.length > 0"
|
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
|
<div
|
||||||
v-for="user in filteredEditCollaborators.slice(0, 10)"
|
v-for="user in filteredEditCollaborators.slice(0, 10)"
|
||||||
:key="user.id"
|
: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)"
|
@click="selectEditCollaborator(user)"
|
||||||
>
|
>
|
||||||
<div class="flex flex-col">
|
<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>
|
<span class="text-[10px] text-zinc-500">@{{ user.username }}</span>
|
||||||
</div>
|
</div>
|
||||||
<Plus :size="14" class="text-blue-500 opacity-70 group-hover:opacity-100" />
|
<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="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="
|
:class="
|
||||||
submissionNoteClearRequested
|
submissionNoteClearRequested
|
||||||
? 'border-zinc-700 bg-zinc-950 text-zinc-300 hover:border-zinc-600'
|
? 'border-zinc-200 bg-white text-zinc-600 hover:border-zinc-300'
|
||||||
: 'border-amber-500/20 bg-amber-500/10 text-amber-400 hover:bg-amber-500/20'
|
: 'border-amber-500/20 bg-amber-500/10 text-amber-700 hover:bg-amber-500/20'
|
||||||
"
|
"
|
||||||
@click="
|
@click="
|
||||||
submissionNoteClearRequested
|
submissionNoteClearRequested
|
||||||
@@ -775,7 +775,7 @@
|
|||||||
v-model="editForm.submissionNote"
|
v-model="editForm.submissionNote"
|
||||||
:disabled="submissionNoteClearRequested"
|
:disabled="submissionNoteClearRequested"
|
||||||
placeholder="填写歌曲备注"
|
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
|
<div
|
||||||
v-if="submissionNoteClearRequested"
|
v-if="submissionNoteClearRequested"
|
||||||
@@ -787,16 +787,16 @@
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="submissionNoteClearReason"
|
v-model="submissionNoteClearReason"
|
||||||
placeholder="可选:请输入清空备注的理由,例如:备注内容违规"
|
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">
|
<label class="flex items-center gap-3 cursor-pointer group">
|
||||||
<input
|
<input
|
||||||
v-model="notifyOnSubmissionNoteClear"
|
v-model="notifyOnSubmissionNoteClear"
|
||||||
type="checkbox"
|
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>
|
<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
|
>清空后发送通知</span
|
||||||
>
|
>
|
||||||
<p class="text-[10px] text-zinc-600 font-medium">
|
<p class="text-[10px] text-zinc-600 font-medium">
|
||||||
@@ -806,7 +806,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="space-y-2">
|
||||||
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
<label class="text-[10px] font-black text-zinc-600 uppercase tracking-widest px-1"
|
||||||
>音乐平台 (可选)</label
|
>音乐平台 (可选)</label
|
||||||
@@ -841,14 +841,14 @@
|
|||||||
v-model="editForm.musicId"
|
v-model="editForm.musicId"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入平台唯一标识符"
|
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
|
<input
|
||||||
v-else
|
v-else
|
||||||
v-model="addForm.musicId"
|
v-model="addForm.musicId"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="输入平台唯一标识符"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -862,14 +862,14 @@
|
|||||||
v-model="editForm.cover"
|
v-model="editForm.cover"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="http://..."
|
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
|
<input
|
||||||
v-else
|
v-else
|
||||||
v-model="addForm.cover"
|
v-model="addForm.cover"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="http://..."
|
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
|
<p
|
||||||
v-if="
|
v-if="
|
||||||
@@ -901,14 +901,14 @@
|
|||||||
v-model="editForm.playUrl"
|
v-model="editForm.playUrl"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="http://..."
|
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
|
<input
|
||||||
v-else
|
v-else
|
||||||
v-model="addForm.playUrl"
|
v-model="addForm.playUrl"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="http://..."
|
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
|
<p
|
||||||
v-if="
|
v-if="
|
||||||
@@ -932,9 +932,9 @@
|
|||||||
</div>
|
</div>
|
||||||
</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
|
<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()"
|
@click="showEditModal ? cancelEditSong() : cancelAddSong()"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -2179,7 +2179,7 @@ onUnmounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--bg-tertiary, #27272a);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
|
|||||||
@@ -9,16 +9,16 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="show"
|
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"
|
@click="close"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="px-8 py-6 border-b border-zinc-800/50 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-zinc-100">投稿备注留言</h3>
|
<h3 class="text-xl font-black text-zinc-900">投稿备注留言</h3>
|
||||||
<button class="text-zinc-500 hover:text-zinc-300 transition-colors" @click="close">
|
<button class="text-zinc-500 hover:text-zinc-600 transition-colors" @click="close">
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,15 +29,15 @@
|
|||||||
:class="[
|
:class="[
|
||||||
'px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border',
|
'px-2 py-0.5 rounded text-[10px] font-bold uppercase tracking-wider border',
|
||||||
isPublic
|
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'
|
: 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ isPublic ? '公开备注' : '仅管理员可见' }}
|
{{ isPublic ? '公开备注' : '仅管理员可见' }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="bg-zinc-950/50 border border-zinc-800/50 rounded-xl p-4">
|
<div class="bg-white/70 border border-zinc-200 rounded-xl p-4">
|
||||||
<p class="text-sm text-zinc-200 leading-relaxed whitespace-pre-wrap">
|
<p class="text-sm text-zinc-700 leading-relaxed whitespace-pre-wrap">
|
||||||
{{ content }}
|
{{ content }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mt-4">
|
<div class="flex flex-col md:flex-row md:items-end justify-between gap-6 mt-4">
|
||||||
<div>
|
<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>
|
<p class="text-xs text-zinc-500 mt-1">系统共有 {{ totalUsers }} 位成员 · 权限与账户管理</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
@@ -15,14 +15,14 @@
|
|||||||
添加
|
添加
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="showImportModal = true"
|
||||||
>
|
>
|
||||||
<FileSpreadsheet class="text-emerald-500" :size="14" />
|
<FileSpreadsheet class="text-emerald-500" :size="14" />
|
||||||
导入
|
导入
|
||||||
</button>
|
</button>
|
||||||
<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"
|
@click="showBatchUpdateModal = true"
|
||||||
>
|
>
|
||||||
<Layers class="text-purple-500" :size="14" />
|
<Layers class="text-purple-500" :size="14" />
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
|
|
||||||
<!-- Filter Bar -->
|
<!-- Filter Bar -->
|
||||||
<div
|
<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">
|
<div class="relative flex-1 w-full group">
|
||||||
<Search
|
<Search
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="searchQuery"
|
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="通过姓名或学号搜索..."
|
placeholder="通过姓名或学号搜索..."
|
||||||
type="text"
|
type="text"
|
||||||
>
|
>
|
||||||
@@ -84,7 +84,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<button
|
<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)"
|
@click="loadUsers(1)"
|
||||||
>
|
>
|
||||||
<RefreshCw :size="14" />
|
<RefreshCw :size="14" />
|
||||||
@@ -96,17 +96,17 @@
|
|||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div v-if="loading" class="flex flex-col items-center justify-center py-20 text-zinc-500">
|
<div v-if="loading" class="flex flex-col items-center justify-center py-20 text-zinc-500">
|
||||||
<div
|
<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 class="text-xs font-black uppercase tracking-widest">正在加载用户...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="users.length === 0"
|
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
|
<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" />
|
<Search :size="32" />
|
||||||
</div>
|
</div>
|
||||||
@@ -118,12 +118,12 @@
|
|||||||
<template v-else>
|
<template v-else>
|
||||||
<!-- 桌面端表格 -->
|
<!-- 桌面端表格 -->
|
||||||
<div
|
<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">
|
<table class="w-full">
|
||||||
<thead>
|
<thead>
|
||||||
<tr
|
<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>
|
||||||
<th class="px-6 py-5 text-left">角色权限</th>
|
<th class="px-6 py-5 text-left">角色权限</th>
|
||||||
@@ -137,7 +137,7 @@
|
|||||||
<tr
|
<tr
|
||||||
v-for="user in users"
|
v-for="user in users"
|
||||||
:key="user.id"
|
: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)"
|
@click="showUserDetail(user, $event)"
|
||||||
>
|
>
|
||||||
<td class="px-6 py-5">
|
<td class="px-6 py-5">
|
||||||
@@ -145,17 +145,17 @@
|
|||||||
<img
|
<img
|
||||||
v-if="user.avatar && !failedImages[user.id]"
|
v-if="user.avatar && !failedImages[user.id]"
|
||||||
:src="user.avatar"
|
: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)"
|
@error="handleImageError(user.id)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-else
|
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() }}
|
{{ (user.name || user.username || '?').charAt(0).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<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">
|
<p class="text-[10px] text-zinc-600 font-mono mt-0.5">
|
||||||
ID: {{ user.username }}
|
ID: {{ user.username }}
|
||||||
</p>
|
</p>
|
||||||
@@ -167,9 +167,9 @@
|
|||||||
class="px-2 py-0.5 text-[10px] font-black rounded border uppercase tracking-widest"
|
class="px-2 py-0.5 text-[10px] font-black rounded border uppercase tracking-widest"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-orange-500/10 text-orange-400 border-orange-500/20': user.role?.toUpperCase() === 'SUPER_ADMIN',
|
'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-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()) }}
|
{{ getRoleDisplayName(user.role?.toUpperCase()) }}
|
||||||
@@ -222,14 +222,14 @@
|
|||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
:disabled="isSelf(user) || !canManageTargetUser(user)"
|
: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) ? '权限不足:无法编辑该用户' : '编辑用户'"
|
:title="!canManageTargetUser(user) ? '权限不足:无法编辑该用户' : '编辑用户'"
|
||||||
@click="editUser(user)"
|
@click="editUser(user)"
|
||||||
>
|
>
|
||||||
<Edit2 :size="13" />
|
<Edit2 :size="13" />
|
||||||
</button>
|
</button>
|
||||||
<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="查看歌曲"
|
title="查看歌曲"
|
||||||
@click="viewUserSongs(user)"
|
@click="viewUserSongs(user)"
|
||||||
>
|
>
|
||||||
@@ -237,7 +237,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="isSelf(user) || !canResetPasswordFor(user)"
|
: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) ? '权限不足:无法重置该用户的密码' : '重置密码'"
|
:title="!canResetPasswordFor(user) ? '权限不足:无法重置该用户的密码' : '重置密码'"
|
||||||
@click="resetPassword(user)"
|
@click="resetPassword(user)"
|
||||||
>
|
>
|
||||||
@@ -245,7 +245,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="isSelf(user) || !canManageTargetUser(user)"
|
: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) ? '权限不足:无法删除该用户' : '删除用户'"
|
:title="!canManageTargetUser(user) ? '权限不足:无法删除该用户' : '删除用户'"
|
||||||
@click="confirmDeleteUser(user)"
|
@click="confirmDeleteUser(user)"
|
||||||
>
|
>
|
||||||
@@ -263,7 +263,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="user in users"
|
v-for="user in users"
|
||||||
:key="user.id"
|
: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)"
|
@click="showUserDetail(user, $event)"
|
||||||
>
|
>
|
||||||
<div class="flex items-start justify-between">
|
<div class="flex items-start justify-between">
|
||||||
@@ -271,17 +271,17 @@
|
|||||||
<img
|
<img
|
||||||
v-if="user.avatar && !failedImages[user.id]"
|
v-if="user.avatar && !failedImages[user.id]"
|
||||||
:src="user.avatar"
|
: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)"
|
@error="handleImageError(user.id)"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-else
|
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() }}
|
{{ (user.name || user.username || '?').charAt(0).toUpperCase() }}
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-xs text-zinc-500 font-mono">@{{ user.username }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -320,7 +320,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="space-y-1">
|
||||||
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-widest">
|
<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="px-2 py-0.5 text-[10px] font-black rounded border uppercase tracking-widest"
|
||||||
:class="{
|
:class="{
|
||||||
'bg-orange-500/10 text-orange-400 border-orange-500/20': user.role?.toUpperCase() === 'SUPER_ADMIN',
|
'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-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()) }}
|
{{ getRoleDisplayName(user.role?.toUpperCase()) }}
|
||||||
@@ -343,7 +343,7 @@
|
|||||||
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-widest">
|
<p class="text-[9px] font-black text-zinc-600 uppercase tracking-widest">
|
||||||
所在班级
|
所在班级
|
||||||
</p>
|
</p>
|
||||||
<p class="text-xs font-bold text-zinc-300">
|
<p class="text-xs font-bold text-zinc-600">
|
||||||
{{ user.grade || '-' }} {{ user.class || '-' }}
|
{{ user.grade || '-' }} {{ user.class || '-' }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -364,21 +364,21 @@
|
|||||||
<div class="flex gap-2 action-buttons">
|
<div class="flex gap-2 action-buttons">
|
||||||
<button
|
<button
|
||||||
:disabled="isSelf(user) || !canManageTargetUser(user)"
|
: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) ? '权限不足:无法编辑该用户' : '编辑用户'"
|
:title="!canManageTargetUser(user) ? '权限不足:无法编辑该用户' : '编辑用户'"
|
||||||
@click="editUser(user)"
|
@click="editUser(user)"
|
||||||
>
|
>
|
||||||
<Edit2 :size="12" /> 编辑
|
<Edit2 :size="12" /> 编辑
|
||||||
</button>
|
</button>
|
||||||
<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)"
|
@click="viewUserSongs(user)"
|
||||||
>
|
>
|
||||||
<Music :size="12" /> 记录
|
<Music :size="12" /> 记录
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="isSelf(user) || !canResetPasswordFor(user)"
|
: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) ? '权限不足:无法重置该用户的密码' : '重置密码'"
|
:title="!canResetPasswordFor(user) ? '权限不足:无法重置该用户的密码' : '重置密码'"
|
||||||
@click="resetPassword(user)"
|
@click="resetPassword(user)"
|
||||||
>
|
>
|
||||||
@@ -386,7 +386,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="isSelf(user) || !canManageTargetUser(user)"
|
: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) ? '权限不足:无法删除该用户' : '删除用户'"
|
:title="!canManageTargetUser(user) ? '权限不足:无法删除该用户' : '删除用户'"
|
||||||
@click="confirmDeleteUser(user)"
|
@click="confirmDeleteUser(user)"
|
||||||
>
|
>
|
||||||
@@ -422,15 +422,15 @@
|
|||||||
@click="closeModal"
|
@click="closeModal"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div>
|
<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
|
<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" />
|
<UserPlus v-if="!editingUser" :size="20" />
|
||||||
<Edit2 v-else :size="20" />
|
<Edit2 v-else :size="20" />
|
||||||
@@ -440,7 +440,7 @@
|
|||||||
<p class="text-xs text-zinc-500 mt-1 ml-13">请填写以下账户详细信息以继续</p>
|
<p class="text-xs text-zinc-500 mt-1 ml-13">请填写以下账户详细信息以继续</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
@click="closeModal"
|
||||||
>
|
>
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
@@ -460,7 +460,7 @@
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="userForm.name"
|
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="请输入真实姓名"
|
placeholder="请输入真实姓名"
|
||||||
type="text"
|
type="text"
|
||||||
>
|
>
|
||||||
@@ -478,7 +478,7 @@
|
|||||||
<input
|
<input
|
||||||
v-model="userForm.username"
|
v-model="userForm.username"
|
||||||
:disabled="!!editingUser"
|
: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="登录唯一标识"
|
placeholder="登录唯一标识"
|
||||||
type="text"
|
type="text"
|
||||||
>
|
>
|
||||||
@@ -497,7 +497,7 @@
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="userForm.password"
|
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="设置安全访问密码"
|
placeholder="设置安全访问密码"
|
||||||
type="password"
|
type="password"
|
||||||
>
|
>
|
||||||
@@ -571,7 +571,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3 mt-10">
|
<div class="flex gap-3 mt-10">
|
||||||
<button
|
<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"
|
@click="closeModal"
|
||||||
>
|
>
|
||||||
取消操作
|
取消操作
|
||||||
@@ -606,7 +606,7 @@
|
|||||||
@click="closeResetPassword"
|
@click="closeResetPassword"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="p-8 text-center">
|
<div class="p-8 text-center">
|
||||||
@@ -615,10 +615,10 @@
|
|||||||
>
|
>
|
||||||
<Lock :size="32" />
|
<Lock :size="32" />
|
||||||
</div>
|
</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">
|
<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>
|
</p>
|
||||||
|
|
||||||
<div class="space-y-4 text-left">
|
<div class="space-y-4 text-left">
|
||||||
@@ -633,7 +633,7 @@
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="passwordForm.password"
|
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="设置高强度新密码"
|
placeholder="设置高强度新密码"
|
||||||
type="password"
|
type="password"
|
||||||
>
|
>
|
||||||
@@ -650,7 +650,7 @@
|
|||||||
/>
|
/>
|
||||||
<input
|
<input
|
||||||
v-model="passwordForm.confirmPassword"
|
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="请再次输入以确认"
|
placeholder="请再次输入以确认"
|
||||||
type="password"
|
type="password"
|
||||||
>
|
>
|
||||||
@@ -668,7 +668,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3 mt-8">
|
<div class="flex gap-3 mt-8">
|
||||||
<button
|
<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"
|
@click="closeResetPassword"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -703,13 +703,13 @@
|
|||||||
@click="closeImportModal"
|
@click="closeImportModal"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div>
|
<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
|
<div
|
||||||
class="w-10 h-10 rounded-2xl bg-emerald-600/10 flex items-center justify-center text-emerald-500"
|
class="w-10 h-10 rounded-2xl bg-emerald-600/10 flex items-center justify-center text-emerald-500"
|
||||||
>
|
>
|
||||||
@@ -722,7 +722,7 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
@click="closeImportModal"
|
||||||
>
|
>
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
@@ -730,9 +730,9 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-6 max-h-[60vh] overflow-y-auto pr-2 custom-scrollbar">
|
<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="p-5 bg-white/70 border border-zinc-200 rounded-3xl space-y-4">
|
||||||
<div class="flex items-center gap-2 text-zinc-300 font-bold text-sm mb-2">
|
<div class="flex items-center gap-2 text-zinc-600 font-bold text-sm mb-2">
|
||||||
<Info :size="16" class="text-blue-400" />
|
<Info :size="16" class="text-blue-700" />
|
||||||
导入说明
|
导入说明
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-zinc-500 leading-relaxed">
|
<p class="text-xs text-zinc-500 leading-relaxed">
|
||||||
@@ -740,16 +740,16 @@
|
|||||||
注意:第一行可以是标题行(会自动跳过),角色字段必须匹配系统定义的角色。
|
注意:第一行可以是标题行(会自动跳过),角色字段必须匹配系统定义的角色。
|
||||||
</p>
|
</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">
|
<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>
|
<tr>
|
||||||
<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-800">账号名</th>
|
<th class="px-3 py-2 border-b border-zinc-200">账号名</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-800">角色</th>
|
<th class="px-3 py-2 border-b border-zinc-200">角色</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-800">班级</th>
|
<th class="px-3 py-2 border-b border-zinc-200">班级</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody class="text-zinc-500">
|
<tbody class="text-zinc-500">
|
||||||
@@ -767,7 +767,7 @@
|
|||||||
|
|
||||||
<div class="flex items-center justify-between gap-4">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<div
|
<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>
|
<strong>支持的角色:</strong>
|
||||||
<span v-if="isSuperAdmin"
|
<span v-if="isSuperAdmin"
|
||||||
@@ -804,15 +804,15 @@
|
|||||||
@change="handleFileUpload"
|
@change="handleFileUpload"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
<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" />
|
<Upload :size="24" />
|
||||||
</div>
|
</div>
|
||||||
<div class="text-center">
|
<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>
|
<p class="text-xs text-zinc-500 mt-1">仅支持 .xlsx 格式</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -829,7 +829,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="importSuccess"
|
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" />
|
<CheckCircle2 :size="16" />
|
||||||
{{ importSuccess }}
|
{{ importSuccess }}
|
||||||
@@ -841,9 +841,9 @@
|
|||||||
>预览数据 ({{ previewData.length }}条记录)</label
|
>预览数据 ({{ previewData.length }}条记录)</label
|
||||||
>
|
>
|
||||||
</div>
|
</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">
|
<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>
|
<tr>
|
||||||
<th class="px-4 py-3 font-medium">姓名</th>
|
<th class="px-4 py-3 font-medium">姓名</th>
|
||||||
<th class="px-4 py-3 font-medium">账号</th>
|
<th class="px-4 py-3 font-medium">账号</th>
|
||||||
@@ -855,13 +855,13 @@
|
|||||||
<tr
|
<tr
|
||||||
v-for="(row, index) in previewData.slice(0, 5)"
|
v-for="(row, index) in previewData.slice(0, 5)"
|
||||||
:key="index"
|
: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.name }}</td>
|
||||||
<td class="px-4 py-3">{{ row.username }}</td>
|
<td class="px-4 py-3">{{ row.username }}</td>
|
||||||
<td class="px-4 py-3">
|
<td class="px-4 py-3">
|
||||||
<span
|
<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
|
>{{ row.role }}</span
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
@@ -883,7 +883,7 @@
|
|||||||
|
|
||||||
<div class="flex gap-3 mt-8">
|
<div class="flex gap-3 mt-8">
|
||||||
<button
|
<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"
|
@click="closeImportModal"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -945,15 +945,15 @@
|
|||||||
@click="closeUserDetailModal"
|
@click="closeUserDetailModal"
|
||||||
>
|
>
|
||||||
<div
|
<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
|
@click.stop
|
||||||
>
|
>
|
||||||
<div class="p-8">
|
<div class="p-8">
|
||||||
<div class="flex items-center justify-between mb-8">
|
<div class="flex items-center justify-between mb-8">
|
||||||
<div>
|
<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
|
<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" />
|
<User :size="20" />
|
||||||
</div>
|
</div>
|
||||||
@@ -962,7 +962,7 @@
|
|||||||
<p class="text-xs text-zinc-500 mt-1 ml-13">查看完整的账户资料与操作记录</p>
|
<p class="text-xs text-zinc-500 mt-1 ml-13">查看完整的账户资料与操作记录</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
@click="closeUserDetailModal"
|
||||||
>
|
>
|
||||||
<X :size="20" />
|
<X :size="20" />
|
||||||
@@ -982,27 +982,27 @@
|
|||||||
基本信息
|
基本信息
|
||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
<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">
|
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||||||
用户 ID
|
用户 ID
|
||||||
</div>
|
</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>
|
||||||
<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 class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||||||
姓名
|
姓名
|
||||||
</div>
|
</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>
|
||||||
<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 class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||||||
用户名
|
用户名
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-bold text-zinc-300">
|
<div class="text-sm font-bold text-zinc-600">
|
||||||
{{ selectedUserDetail.username }}
|
{{ selectedUserDetail.username }}
|
||||||
</div>
|
</div>
|
||||||
</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 class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||||||
角色权限
|
角色权限
|
||||||
</div>
|
</div>
|
||||||
@@ -1017,19 +1017,19 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</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 class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||||||
年级
|
年级
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-bold text-zinc-300">
|
<div class="text-sm font-bold text-zinc-600">
|
||||||
{{ selectedUserDetail.grade || '未设置' }}
|
{{ selectedUserDetail.grade || '未设置' }}
|
||||||
</div>
|
</div>
|
||||||
</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 class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||||||
班级
|
班级
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm font-bold text-zinc-300">
|
<div class="text-sm font-bold text-zinc-600">
|
||||||
{{ selectedUserDetail.class || '未设置' }}
|
{{ selectedUserDetail.class || '未设置' }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1046,7 +1046,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<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="space-y-1">
|
||||||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
||||||
@@ -1089,7 +1089,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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="space-y-1">
|
||||||
<div class="text-[10px] font-black text-zinc-600 uppercase tracking-tighter">
|
<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',
|
'w-8 h-8 rounded-xl flex items-center justify-center',
|
||||||
selectedUserDetail.meowNickname
|
selectedUserDetail.meowNickname
|
||||||
? 'bg-emerald-500/10 text-emerald-500'
|
? 'bg-emerald-500/10 text-emerald-500'
|
||||||
: 'bg-zinc-800 text-zinc-600'
|
: 'bg-zinc-100 text-zinc-600'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
<AtSign :size="16" />
|
<AtSign :size="16" />
|
||||||
@@ -1133,18 +1133,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<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-500">最后登录</span>
|
||||||
<span class="text-zinc-300 font-medium">{{
|
<span class="text-zinc-600 font-medium">{{
|
||||||
formatDate(selectedUserDetail.lastLogin)
|
formatDate(selectedUserDetail.lastLogin)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<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-500">登录 IP</span>
|
||||||
<span class="text-zinc-300 font-medium">{{
|
<span class="text-zinc-600 font-medium">{{
|
||||||
selectedUserDetail.lastLoginIp || '未知'
|
selectedUserDetail.lastLoginIp || '未知'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1159,18 +1159,18 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
<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-500">创建时间</span>
|
||||||
<span class="text-zinc-300 font-medium">{{
|
<span class="text-zinc-600 font-medium">{{
|
||||||
formatDate(selectedUserDetail.createdAt)
|
formatDate(selectedUserDetail.createdAt)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
<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-500">最近更新</span>
|
||||||
<span class="text-zinc-300 font-medium">{{
|
<span class="text-zinc-600 font-medium">{{
|
||||||
formatDate(selectedUserDetail.updatedAt)
|
formatDate(selectedUserDetail.updatedAt)
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1201,21 +1201,21 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-else-if="statusLogs.length === 0"
|
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>
|
<p class="text-xs text-zinc-600">暂无状态变更记录</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div
|
<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 v-for="log in statusLogs" :key="log.id" class="relative">
|
||||||
<div
|
<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
|
<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 justify-between">
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -1244,7 +1244,7 @@
|
|||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="log.reason"
|
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>
|
<span class="text-zinc-600 font-bold mr-1">原因:</span>
|
||||||
{{ log.reason }}
|
{{ log.reason }}
|
||||||
@@ -1252,12 +1252,12 @@
|
|||||||
|
|
||||||
<div class="flex items-center gap-2 text-[10px]">
|
<div class="flex items-center gap-2 text-[10px]">
|
||||||
<div
|
<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" />
|
<User :size="8" />
|
||||||
</div>
|
</div>
|
||||||
<span class="text-zinc-500">操作者:</span>
|
<span class="text-zinc-500">操作者:</span>
|
||||||
<span class="text-zinc-300 font-bold">{{
|
<span class="text-zinc-600 font-bold">{{
|
||||||
log.operator?.name || '系统'
|
log.operator?.name || '系统'
|
||||||
}}</span>
|
}}</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -1277,7 +1277,7 @@
|
|||||||
|
|
||||||
<div class="mt-8">
|
<div class="mt-8">
|
||||||
<button
|
<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"
|
@click="closeUserDetailModal"
|
||||||
>
|
>
|
||||||
关闭窗口
|
关闭窗口
|
||||||
|
|||||||
@@ -26,16 +26,16 @@
|
|||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="show"
|
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
|
@click.stop
|
||||||
>
|
>
|
||||||
<!-- 头部 -->
|
<!-- 头部 -->
|
||||||
<div
|
<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>
|
<h3 class="text-lg font-semibold text-white">用户歌曲信息</h3>
|
||||||
<button
|
<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')"
|
@click="$emit('close')"
|
||||||
>
|
>
|
||||||
<svg
|
<svg
|
||||||
@@ -56,7 +56,7 @@
|
|||||||
</div>
|
</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">
|
<div v-if="loading" class="flex-1 flex flex-col items-center justify-center">
|
||||||
<LoadingState message="加载数据中..." spinner-type="circle" />
|
<LoadingState message="加载数据中..." spinner-type="circle" />
|
||||||
@@ -80,7 +80,7 @@
|
|||||||
<!-- 内容 -->
|
<!-- 内容 -->
|
||||||
<div v-else-if="userSongs" class="flex-1 flex flex-col min-h-0">
|
<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 flex-col gap-1">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<h4 class="text-base font-medium text-white">{{ userSongs.user.name }}</h4>
|
<h4 class="text-base font-medium text-white">{{ userSongs.user.name }}</h4>
|
||||||
@@ -99,7 +99,7 @@
|
|||||||
|
|
||||||
<!-- 工具栏 (标签页 + 过滤) -->
|
<!-- 工具栏 (标签页 + 过滤) -->
|
||||||
<div
|
<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">
|
<div class="flex w-full sm:w-auto overflow-x-auto no-scrollbar">
|
||||||
@@ -110,7 +110,7 @@
|
|||||||
:class="
|
:class="
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'text-blue-500'
|
? '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"
|
@click="activeTab = tab.id"
|
||||||
>
|
>
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
:class="
|
:class="
|
||||||
activeTab === tab.id
|
activeTab === tab.id
|
||||||
? 'bg-blue-500/10 text-blue-500'
|
? 'bg-blue-500/10 text-blue-500'
|
||||||
: 'bg-zinc-800 text-zinc-500'
|
: 'bg-zinc-100 text-zinc-500'
|
||||||
"
|
"
|
||||||
>
|
>
|
||||||
{{ getTabCount(tab.id) }}
|
{{ getTabCount(tab.id) }}
|
||||||
@@ -135,7 +135,7 @@
|
|||||||
|
|
||||||
<!-- 学期过滤器 -->
|
<!-- 学期过滤器 -->
|
||||||
<div
|
<div
|
||||||
class="w-full sm:w-auto px-4 py-2 border-t sm:border-t-0 sm:border-l border-zinc-800 flex items-center gap-3 bg-zinc-900/30"
|
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
|
<CustomSelect
|
||||||
v-model="selectedSemester"
|
v-model="selectedSemester"
|
||||||
@@ -160,7 +160,7 @@
|
|||||||
<div
|
<div
|
||||||
v-for="song in filteredSongs"
|
v-for="song in filteredSongs"
|
||||||
:key="song.id"
|
: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">
|
<div class="flex-1 min-w-0">
|
||||||
@@ -168,7 +168,7 @@
|
|||||||
<h5 class="text-sm font-medium text-white truncate">{{ song.title }}</h5>
|
<h5 class="text-sm font-medium text-white truncate">{{ song.title }}</h5>
|
||||||
<span
|
<span
|
||||||
v-if="song.semester"
|
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 }}
|
{{ song.semester }}
|
||||||
</span>
|
</span>
|
||||||
@@ -214,7 +214,7 @@
|
|||||||
|
|
||||||
<!-- Submitter Info (if available) -->
|
<!-- Submitter Info (if available) -->
|
||||||
<div v-if="song.requester" class="hidden sm:block text-right min-w-[80px]">
|
<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">
|
<div class="text-[10px] text-zinc-500">
|
||||||
{{ song.requester.grade }}{{ song.requester.class }}
|
{{ song.requester.grade }}{{ song.requester.class }}
|
||||||
</div>
|
</div>
|
||||||
@@ -224,10 +224,10 @@
|
|||||||
<!-- 投稿人信息 (移动端) -->
|
<!-- 投稿人信息 (移动端) -->
|
||||||
<div
|
<div
|
||||||
v-if="song.requester"
|
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-500">投稿人:</span>
|
||||||
<span class="text-zinc-300">
|
<span class="text-zinc-600">
|
||||||
{{ song.requester.name }}
|
{{ song.requester.name }}
|
||||||
<span class="text-zinc-500 ml-1">
|
<span class="text-zinc-500 ml-1">
|
||||||
{{ song.requester.grade }}{{ song.requester.class }}
|
{{ 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'
|
return 'bg-emerald-500/10 text-emerald-500 border-emerald-500/20'
|
||||||
}
|
}
|
||||||
if (song.scheduled) {
|
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'
|
return 'bg-amber-500/10 text-amber-500 border-amber-500/20'
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -202,9 +202,9 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: #1a1a1a;
|
background: var(--surface-primary, var(--bg-primary, #ffffff));
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--border-primary, #2a2a2a);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 500px;
|
max-width: 500px;
|
||||||
max-height: 80vh;
|
max-height: 80vh;
|
||||||
@@ -223,14 +223,14 @@ watch(
|
|||||||
.modal-title {
|
.modal-title {
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #888;
|
color: var(--text-tertiary, #888888);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 8px;
|
padding: 8px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -238,8 +238,8 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close-btn:hover {
|
.close-btn:hover {
|
||||||
background: #2a2a2a;
|
background: var(--bg-tertiary, #2a2a2a);
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn svg {
|
.close-btn svg {
|
||||||
@@ -254,22 +254,22 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.song-info {
|
.song-info {
|
||||||
background: #2a2a2a;
|
background: var(--bg-tertiary, #2a2a2a);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
border: 1px solid #3a3a3a;
|
border: 1px solid var(--border-secondary, #3a3a3a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-title {
|
.song-title {
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-artist {
|
.song-artist {
|
||||||
color: #888;
|
color: var(--text-tertiary, #888888);
|
||||||
margin: 0 0 16px 0;
|
margin: 0 0 16px 0;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@@ -304,7 +304,7 @@ watch(
|
|||||||
.spinner {
|
.spinner {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border: 3px solid #3a3a3a;
|
border: 3px solid var(--border-secondary, #3a3a3a);
|
||||||
border-top: 3px solid #667eea;
|
border-top: 3px solid #667eea;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: spin 1s linear infinite;
|
animation: spin 1s linear infinite;
|
||||||
@@ -342,7 +342,7 @@ watch(
|
|||||||
|
|
||||||
.retry-btn {
|
.retry-btn {
|
||||||
background: #667eea;
|
background: #667eea;
|
||||||
color: white;
|
color: var(--text-primary, #09090b);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 8px 16px;
|
padding: 8px 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -366,12 +366,12 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding-bottom: 12px;
|
padding-bottom: 12px;
|
||||||
border-bottom: 1px solid #3a3a3a;
|
border-bottom: 1px solid var(--border-secondary, #3a3a3a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voters-title {
|
.voters-title {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -388,15 +388,15 @@ watch(
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 12px 16px;
|
padding: 12px 16px;
|
||||||
background: #2a2a2a;
|
background: var(--bg-tertiary, #2a2a2a);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid #3a3a3a;
|
border: 1px solid var(--border-secondary, #3a3a3a);
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voter-item:hover {
|
.voter-item:hover {
|
||||||
background: #333333;
|
background: var(--bg-quaternary, #333333);
|
||||||
border-color: #4a4a4a;
|
border-color: var(--border-secondary, #4a4a4a);
|
||||||
}
|
}
|
||||||
|
|
||||||
.voter-info {
|
.voter-info {
|
||||||
@@ -414,7 +414,7 @@ watch(
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
color: white;
|
color: var(--text-primary, #09090b);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
@@ -426,18 +426,18 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.voter-name {
|
.voter-name {
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.vote-time {
|
.vote-time {
|
||||||
color: #888;
|
color: var(--text-tertiary, #888888);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.voter-number {
|
.voter-number {
|
||||||
color: #888;
|
color: var(--text-tertiary, #888888);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
@@ -454,11 +454,11 @@ watch(
|
|||||||
.empty-icon {
|
.empty-icon {
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 48px;
|
height: 48px;
|
||||||
color: #666;
|
color: var(--text-quaternary, #666666);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-state p {
|
.empty-state p {
|
||||||
color: #888;
|
color: var(--text-tertiary, #888888);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -469,8 +469,8 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close-button {
|
.close-button {
|
||||||
background: #3a3a3a;
|
background: var(--bg-quaternary, #3a3a3a);
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
border: none;
|
border: none;
|
||||||
padding: 12px 24px;
|
padding: 12px 24px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
@@ -490,7 +490,7 @@ watch(
|
|||||||
}
|
}
|
||||||
|
|
||||||
.voters-container::-webkit-scrollbar-track {
|
.voters-container::-webkit-scrollbar-track {
|
||||||
background: #2a2a2a;
|
background: var(--bg-tertiary, #2a2a2a);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,36 +1,36 @@
|
|||||||
<template>
|
<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="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">
|
<div class="flex flex-col gap-2">
|
||||||
<h2 class="text-2xl md:text-3xl font-bold tracking-tight text-zinc-100">工作流与使用教程</h2>
|
<h2 class="text-2xl md:text-3xl font-bold tracking-tight text-zinc-900">工作流与使用教程</h2>
|
||||||
<p class="text-zinc-400">了解校园广播站点歌系统的标准操作流程和功能说明,帮助您更高效地管理点歌与排期。</p>
|
<p class="text-zinc-400">了解宝一外校园点歌系统的标准操作流程和功能说明,帮助您更高效地管理点歌与排期。</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
<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="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-400">
|
<div class="flex items-center gap-3 mb-4 text-blue-700">
|
||||||
<CheckCircle2 class="w-6 h-6" />
|
<CheckCircle2 class="w-6 h-6" />
|
||||||
<h3 class="text-xl font-semibold">1. 歌曲管理与审核</h3>
|
<h3 class="text-xl font-semibold">1. 歌曲管理与审核</h3>
|
||||||
</div>
|
</div>
|
||||||
<ol class="space-y-4 text-zinc-300">
|
<ol class="space-y-4 text-zinc-600">
|
||||||
<li class="flex gap-3">
|
<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>
|
<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>
|
<p class="text-sm text-zinc-500 mt-1">进入“歌曲管理”页面,系统会列出所有用户提交的歌曲。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex gap-3">
|
<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>
|
<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>
|
<p class="text-sm text-zinc-500 mt-1">点击播放按钮试听歌曲质量,检查留言内容是否合规,确保没有不当言论。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex gap-3">
|
<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>
|
<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>
|
<p class="text-sm text-zinc-500 mt-1">如不符合要求,可以在歌曲管理中拒绝该歌曲,并填写退回原因;审核通过的歌曲可直接用于排期。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -38,30 +38,30 @@
|
|||||||
</div>
|
</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-emerald-400">
|
<div class="flex items-center gap-3 mb-4 text-emerald-700">
|
||||||
<CalendarDays class="w-6 h-6" />
|
<CalendarDays class="w-6 h-6" />
|
||||||
<h3 class="text-xl font-semibold">2. 节目排期流程</h3>
|
<h3 class="text-xl font-semibold">2. 节目排期流程</h3>
|
||||||
</div>
|
</div>
|
||||||
<ol class="space-y-4 text-zinc-300">
|
<ol class="space-y-4 text-zinc-600">
|
||||||
<li class="flex gap-3">
|
<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>
|
<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>
|
<p class="text-sm text-zinc-500 mt-1">在“排期管理”中,选择目标播放日期和播出时段。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex gap-3">
|
<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>
|
<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>
|
<p class="text-sm text-zinc-500 mt-1">将左侧“待排歌曲”拖拽至右侧的“播放列表”中,可上下拖拽调整播放顺序。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex gap-3">
|
<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>
|
<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>
|
<p class="text-sm text-zinc-500 mt-1">排期完成后,点击“发布排期”。发布后用户即可在前端页面看到该日期的节目单。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -69,23 +69,23 @@
|
|||||||
</div>
|
</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">
|
<div class="flex items-center gap-3 mb-4 text-purple-400">
|
||||||
<Mic2 class="w-6 h-6" />
|
<Mic2 class="w-6 h-6" />
|
||||||
<h3 class="text-xl font-semibold">3. 播音与打印</h3>
|
<h3 class="text-xl font-semibold">3. 播音与打印</h3>
|
||||||
</div>
|
</div>
|
||||||
<ol class="space-y-4 text-zinc-300">
|
<ol class="space-y-4 text-zinc-600">
|
||||||
<li class="flex gap-3">
|
<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>
|
<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>
|
<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>
|
<p class="text-sm text-zinc-500 mt-1">播音前,前往“导入酷狗”生成今日节目单图片用于宣传,或在“打印排期”中生成播音员用的纸质稿。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
<li class="flex gap-3">
|
<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>
|
<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>
|
<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>
|
<p class="text-sm text-zinc-500 mt-1">播音结束后,系统可自动/手动将排期内的歌曲标记为“已播”,方便统计数据。</p>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -93,22 +93,22 @@
|
|||||||
</div>
|
</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-amber-400">
|
<div class="flex items-center gap-3 mb-4 text-amber-700">
|
||||||
<HelpCircle class="w-6 h-6" />
|
<HelpCircle class="w-6 h-6" />
|
||||||
<h3 class="text-xl font-semibold">常见问题 (FAQ)</h3>
|
<h3 class="text-xl font-semibold">常见问题 (FAQ)</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<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>
|
<p class="text-sm text-zinc-500 mt-1">A: 请检查是否在排期页面的左侧选错了标签(如普通投稿/重播申请),或者该歌曲还未通过审核。</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-sm text-zinc-500 mt-1">A: 选曲日期是用户在点歌时可选择的期望播放日期。请在“选曲日期”设置中提前开放可用的日期。</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-sm text-zinc-500 mt-1">A: 不同的功能需要不同的管理员角色(如歌曲管理员、超级管理员),请联系超级管理员调整您的角色权限。</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
<div class="form-header">
|
<div class="form-header">
|
||||||
<h2>{{ isBindMode ? '绑定账号' : '欢迎回来' }}</h2>
|
<h2>{{ isBindMode ? '绑定账号' : '欢迎回来' }}</h2>
|
||||||
<p v-if="isBindMode">即将绑定 {{ providerName }} 账号: {{ providerUsername }}</p>
|
<p v-if="isBindMode">即将绑定 {{ providerName }} 账号: {{ providerUsername }}</p>
|
||||||
<p v-else>登录您的VoiceHub账户</p>
|
<p v-else>登录宝一外校园点歌系统账户</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleLogin">
|
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleLogin">
|
||||||
@@ -74,6 +74,11 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="forgot-password-row">
|
||||||
|
<NuxtLink to="/forgot-password" class="forgot-password-link">
|
||||||
|
忘记密码?
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="error" class="error-container">
|
<div v-if="error" class="error-container">
|
||||||
@@ -138,10 +143,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<AuthOAuthButtons v-if="!isBindMode" />
|
<AuthOAuthButtons v-if="!isBindMode" :always-show="true" />
|
||||||
|
|
||||||
<div class="form-footer">
|
<div class="form-footer">
|
||||||
<p class="help-text">不同VoiceHub平台的账号不互通</p>
|
<p class="help-text">不同宝一外校园点歌系统平台的账号不互通</p>
|
||||||
<div style="margin-top: 16px;">
|
<div style="margin-top: 16px;">
|
||||||
<NuxtLink to="/register" class="register-link-btn">
|
<NuxtLink to="/register" class="register-link-btn">
|
||||||
还没有账号?去注册
|
还没有账号?去注册
|
||||||
@@ -556,6 +561,25 @@ const handleWebAuthnLogin = async () => {
|
|||||||
height: 20px;
|
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 {
|
.form-footer {
|
||||||
margin-top: 24px;
|
margin-top: 24px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
|
|||||||
@@ -117,6 +117,45 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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 -->
|
<!-- WebAuthn / Passkey -->
|
||||||
<div
|
<div
|
||||||
v-if="isWebAuthnSupported || webauthnIdentities.length > 0 || !isSecureContext"
|
v-if="isWebAuthnSupported || webauthnIdentities.length > 0 || !isSecureContext"
|
||||||
@@ -350,6 +389,7 @@ const itemClass =
|
|||||||
const githubIdentity = computed(() => identities.value.find((i) => i.provider === 'github'))
|
const githubIdentity = computed(() => identities.value.find((i) => i.provider === 'github'))
|
||||||
const casdoorIdentity = computed(() => identities.value.find((i) => i.provider === 'casdoor'))
|
const casdoorIdentity = computed(() => identities.value.find((i) => i.provider === 'casdoor'))
|
||||||
const googleIdentity = computed(() => identities.value.find((i) => i.provider === 'google'))
|
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 webauthnIdentities = computed(() => identities.value.filter((i) => i.provider === 'webauthn'))
|
||||||
|
|
||||||
const fetchIdentities = async () => {
|
const fetchIdentities = async () => {
|
||||||
|
|||||||
@@ -1,15 +1,13 @@
|
|||||||
<template>
|
<template>
|
||||||
<div v-if="hasEnabledProviders" class="mt-6 w-full">
|
<div v-if="hasEnabledProviders" class="oauth-buttons-section">
|
||||||
<div class="flex items-center text-center text-[var(--text-tertiary)] text-xs mb-4">
|
<div class="oauth-divider">
|
||||||
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
|
<span>或使用第三方账号登录</span>
|
||||||
<span class="px-2.5">或使用第三方账号登录</span>
|
|
||||||
<div class="flex-1 border-b border-[var(--border-color)] opacity-20" />
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex justify-center gap-4">
|
<div class="oauth-buttons">
|
||||||
<button
|
<button
|
||||||
v-if="config.public.oauth.github"
|
v-if="config.public.oauth.github"
|
||||||
type="button"
|
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 登录"
|
title="使用 GitHub 登录"
|
||||||
@click="loginWith('github')"
|
@click="loginWith('github')"
|
||||||
>
|
>
|
||||||
@@ -19,7 +17,7 @@
|
|||||||
<button
|
<button
|
||||||
v-if="config.public.oauth.google"
|
v-if="config.public.oauth.google"
|
||||||
type="button"
|
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 登录"
|
title="使用 Google 登录"
|
||||||
@click="loginWith('google')"
|
@click="loginWith('google')"
|
||||||
>
|
>
|
||||||
@@ -29,26 +27,185 @@
|
|||||||
<button
|
<button
|
||||||
v-if="config.public.oauth.aggregate"
|
v-if="config.public.oauth.aggregate"
|
||||||
type="button"
|
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]"
|
class="oauth-btn oauth-btn-wechat"
|
||||||
title="使用 QQ / 微信登录"
|
title="使用微信登录"
|
||||||
@click="loginWith('aggregate')"
|
@click="loginWith('aggregate')"
|
||||||
>
|
>
|
||||||
<svg viewBox="0 0 24 24" width="22" height="22" fill="currentColor" aria-hidden="true">
|
<svg viewBox="0 0 24 24" width="28" height="28" 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"/>
|
<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>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
alwaysShow?: boolean
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
alwaysShow: false
|
||||||
|
})
|
||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
const hasEnabledProviders = computed(() => {
|
const hasEnabledProviders = computed(() => {
|
||||||
return config.public.oauth && Object.values(config.public.oauth).some((enabled) => enabled)
|
return config.public.oauth && Object.values(config.public.oauth).some((enabled) => enabled)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const showPlaceholderHint = computed(() => false)
|
||||||
|
|
||||||
const loginWith = (provider: string) => {
|
const loginWith = (provider: string) => {
|
||||||
navigateTo(`/api/auth/${provider}`, { external: true })
|
navigateTo(`/api/auth/${provider}`, { external: true })
|
||||||
}
|
}
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -224,24 +224,24 @@ const handleUpdateProfile = async () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.custom-select-profile :deep(> .flex) {
|
.custom-select-profile :deep(> .flex) {
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
background-color: #09090b;
|
background-color: var(--input-bg, #09090b);
|
||||||
border: 1px solid #27272a;
|
border: 1px solid var(--input-border, #27272a);
|
||||||
border-radius: 0.75rem;
|
border-radius: 0.75rem;
|
||||||
color: #f4f4f5;
|
color: var(--input-text, #f4f4f5);
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
line-height: 1.25rem;
|
line-height: 1.25rem;
|
||||||
height: 46px;
|
height: 46px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-profile :deep(> .flex:hover) {
|
.custom-select-profile :deep(> .flex:hover) {
|
||||||
border-color: #3f3f46;
|
border-color: var(--input-border-focus, #3f3f46);
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-select-profile :deep(> .border-blue-500\/50) {
|
.custom-select-profile :deep(> .border-blue-500\/50) {
|
||||||
border-color: rgba(34, 197, 94, 0.5) !important;
|
border-color: var(--primary, rgba(34, 197, 94, 0.5)) !important;
|
||||||
--tw-ring-color: rgba(34, 197, 94, 0.5);
|
--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);
|
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\]) {
|
.custom-select-profile :deep(.text-\[11px\]) {
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<div class="login-form">
|
<div class="login-form">
|
||||||
<div class="form-header">
|
<div class="form-header">
|
||||||
<h2>注册账号</h2>
|
<h2>注册账号</h2>
|
||||||
<p>创建您的VoiceHub账户</p>
|
<p>创建您的宝一外校园点歌系统账户</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleRegister">
|
<form :class="['auth-form', { 'has-error': !!error }]" @submit.prevent="handleRegister">
|
||||||
@@ -575,7 +575,7 @@ const handleRegister = async () => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: var(--radius-lg);
|
border-radius: var(--radius-lg);
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
|
|||||||
@@ -209,7 +209,7 @@ input[type='range']::-webkit-slider-thumb {
|
|||||||
-webkit-appearance: none;
|
-webkit-appearance: none;
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: #2563eb; /* blue-600 */
|
background: var(--bg-tertiary, #2563eb); /* blue-600 */
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@@ -218,7 +218,7 @@ input[type='range']::-webkit-slider-thumb {
|
|||||||
input[type='range']::-moz-range-thumb {
|
input[type='range']::-moz-range-thumb {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
background: #2563eb;
|
background: var(--bg-tertiary, #2563eb);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
border: none;
|
border: none;
|
||||||
|
|||||||
@@ -160,7 +160,7 @@ const jumpSeek = (line: LyricLineMouseEvent) => {
|
|||||||
:deep(.am-lyric .is-active),
|
:deep(.am-lyric .is-active),
|
||||||
:deep(.am-lyric .lyric-line.current),
|
:deep(.am-lyric .lyric-line.current),
|
||||||
:deep(.am-lyric .lyric-line.is-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);
|
text-shadow: 0 4px 18px rgba(0, 0, 0, 0.35);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -824,6 +824,6 @@ onBeforeUnmount(() => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
color: #fff;
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -23,11 +23,16 @@
|
|||||||
</span>
|
</span>
|
||||||
<span v-if="siteTitle" class="footer-item">© {{ currentYear }} {{ siteTitle }}</span>
|
<span v-if="siteTitle" class="footer-item">© {{ currentYear }} {{ siteTitle }}</span>
|
||||||
<span v-else class="footer-item">© {{ currentYear }} {{ copyrightOwner }}</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">
|
<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 }}
|
{{ displayName }} v{{ systemVersion }}
|
||||||
</NuxtLink>
|
</a>
|
||||||
</span>
|
</span>
|
||||||
<span v-if="isNetlify" class="footer-item">
|
<span v-if="isNetlify" class="footer-item">
|
||||||
<a
|
<a
|
||||||
@@ -91,7 +96,7 @@ onMounted(() => {
|
|||||||
text-align: center;
|
text-align: center;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
margin-top: 30px;
|
margin-top: 30px;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.1);
|
border-top: 1px solid var(--border-primary);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -104,7 +109,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-item {
|
.footer-item {
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: var(--text-tertiary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
@@ -112,7 +117,7 @@ onMounted(() => {
|
|||||||
.footer-item:not(:last-child)::after {
|
.footer-item:not(:last-child)::after {
|
||||||
content: ' | ';
|
content: ' | ';
|
||||||
margin: 0 10px;
|
margin: 0 10px;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--text-quaternary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.footer-item a {
|
.footer-item a {
|
||||||
@@ -122,7 +127,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.footer-item a:hover {
|
.footer-item a:hover {
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.icp-link,
|
.icp-link,
|
||||||
@@ -134,7 +139,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
.icp-link:hover,
|
.icp-link:hover,
|
||||||
.voicehub-link:hover {
|
.voicehub-link:hover {
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.netlify-badge {
|
.netlify-badge {
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -104,13 +104,13 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="!schedules || schedules.length === 0" key="empty-all" class="empty">
|
<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>暂无排期信息</p>
|
||||||
<p class="text-sm text-gray">点歌后等待管理员安排播出时间</p>
|
<p class="text-sm text-text-secondary">点歌后等待管理员安排播出时间</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="currentDateSchedules.length === 0" key="empty-date" class="empty">
|
<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>
|
||||||
<p>请选择其他日期查看</p>
|
<p>请选择其他日期查看</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -1600,15 +1600,15 @@ const vRipple = {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1.5rem;
|
margin-bottom: 1.5rem;
|
||||||
padding: 1rem 1.25rem;
|
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-radius: 12px;
|
||||||
border: 1px solid rgba(11, 90, 254, 0.2);
|
border: 1px solid var(--border-secondary);
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||||
}
|
}
|
||||||
|
|
||||||
.semester-label {
|
.semester-label {
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
margin-right: 0.75rem;
|
margin-right: 0.75rem;
|
||||||
@@ -1617,10 +1617,10 @@ const vRipple = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.semester-select {
|
.semester-select {
|
||||||
background: linear-gradient(135deg, rgba(255, 255, 255, 0.15) 0%, rgba(255, 255, 255, 0.08) 100%);
|
background: var(--bg-tertiary);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.25);
|
border: 1px solid var(--border-tertiary);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
padding: 0.6rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -1648,8 +1648,8 @@ const vRipple = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.semester-select option {
|
.semester-select option {
|
||||||
background: #1a1d24;
|
background: var(--bg-secondary, #1a1d24);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
padding: 0.5rem;
|
padding: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1690,7 +1690,7 @@ const vRipple = {
|
|||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: var(--text-secondary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
@@ -1703,24 +1703,24 @@ const vRipple = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.date-item:hover {
|
.date-item:hover {
|
||||||
background: #21242d;
|
background: var(--bg-tertiary);
|
||||||
transform: translateY(-2px);
|
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 {
|
.date-item.active {
|
||||||
background: #21242d;
|
background: var(--bg-tertiary);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
border-radius: 8px;
|
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;
|
border-left: 3px solid #0b5afe;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-dates {
|
.empty-dates {
|
||||||
padding: 2rem 1rem;
|
padding: 2rem 1rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: var(--text-tertiary);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1775,7 +1775,7 @@ const vRipple = {
|
|||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 20px;
|
font-size: 20px;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1784,9 +1784,9 @@ const vRipple = {
|
|||||||
padding: 3rem;
|
padding: 3rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #21242d;
|
background: var(--bg-tertiary);
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: var(--text-tertiary);
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1819,13 +1819,13 @@ const vRipple = {
|
|||||||
padding: 2rem;
|
padding: 2rem;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: #21242d;
|
background: var(--bg-tertiary);
|
||||||
margin: 1rem 0;
|
margin: 1rem 0;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: var(--text-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error {
|
.error {
|
||||||
color: #ef4444;
|
color: var(--error);
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty .icon {
|
.empty .icon {
|
||||||
@@ -1849,13 +1849,13 @@ const vRipple = {
|
|||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: var(--text-secondary);
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playtime-time {
|
.playtime-time {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--text-tertiary);
|
||||||
margin-left: 0.5rem;
|
margin-left: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1869,7 +1869,7 @@ const vRipple = {
|
|||||||
.song-card {
|
.song-card {
|
||||||
width: 320px;
|
width: 320px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: #21242d;
|
background: var(--bg-tertiary);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
@@ -1883,22 +1883,22 @@ const vRipple = {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
padding: 0.6rem 1.2rem;
|
padding: 0.6rem 1.2rem;
|
||||||
border-radius: 999px;
|
border-radius: 12px;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--border-secondary);
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--bg-secondary);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(6px);
|
||||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-playlist-btn:hover {
|
.add-playlist-btn:hover {
|
||||||
background: rgba(255, 255, 255, 0.2);
|
background: var(--bg-tertiary);
|
||||||
transform: translateY(-1px);
|
transform: translateY(-1px);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: var(--border-tertiary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-playlist-btn:active {
|
.add-playlist-btn:active {
|
||||||
@@ -1930,7 +1930,7 @@ const vRipple = {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 580px;
|
max-width: 580px;
|
||||||
max-height: 85vh;
|
max-height: 85vh;
|
||||||
background: #1e1e24;
|
background: var(--bg-secondary, #1e1e24);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
@@ -1964,7 +1964,7 @@ const vRipple = {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1984,7 +1984,7 @@ const vRipple = {
|
|||||||
|
|
||||||
.playlist-modal-close:hover {
|
.playlist-modal-close:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
transform: rotate(90deg);
|
transform: rotate(90deg);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1993,7 +1993,7 @@ const vRipple = {
|
|||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
background: #1e1e24;
|
background: var(--bg-secondary, #1e1e24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动条美化 */
|
/* 滚动条美化 */
|
||||||
@@ -2066,7 +2066,7 @@ const vRipple = {
|
|||||||
height: 40px;
|
height: 40px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: #2a2a32;
|
background: var(--bg-tertiary, #2a2a32);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -2087,7 +2087,7 @@ const vRipple = {
|
|||||||
|
|
||||||
.user-name {
|
.user-name {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2161,9 +2161,9 @@ const vRipple = {
|
|||||||
.custom-select,
|
.custom-select,
|
||||||
.custom-input {
|
.custom-input {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: #141418;
|
background: var(--bg-secondary, #141418);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
@@ -2188,9 +2188,9 @@ const vRipple = {
|
|||||||
.custom-select:focus,
|
.custom-select:focus,
|
||||||
.custom-input:focus {
|
.custom-input:focus {
|
||||||
outline: none;
|
outline: none;
|
||||||
border-color: var(--primary);
|
border-color: var(--input-border-focus, var(--primary));
|
||||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
box-shadow: var(--input-shadow-focus, 0 0 0 3px rgba(102, 126, 234, 0.1));
|
||||||
background: #1a1a20;
|
background: var(--input-bg, #1a1a20);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon {
|
.btn-icon {
|
||||||
@@ -2210,7 +2210,7 @@ const vRipple = {
|
|||||||
|
|
||||||
.btn-icon:hover {
|
.btn-icon:hover {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-icon:disabled {
|
.btn-icon:disabled {
|
||||||
@@ -2421,7 +2421,7 @@ const vRipple = {
|
|||||||
|
|
||||||
.song-name {
|
.song-name {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2469,7 +2469,7 @@ const vRipple = {
|
|||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--primary);
|
background: var(--primary);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25);
|
box-shadow: 0 4px 12px rgba(102, 126, 234, 0.25);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2488,7 +2488,7 @@ const vRipple = {
|
|||||||
|
|
||||||
.btn-secondary {
|
.btn-secondary {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-secondary:hover {
|
.btn-secondary:hover {
|
||||||
@@ -2502,7 +2502,7 @@ const vRipple = {
|
|||||||
|
|
||||||
.btn-ghost:hover {
|
.btn-ghost:hover {
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 动画定义 */
|
/* 动画定义 */
|
||||||
@@ -2537,7 +2537,7 @@ const vRipple = {
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
margin-right: 0.5rem;
|
margin-right: 0.5rem;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -2661,8 +2661,8 @@ const vRipple = {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, #0043f8 0%, #0075f8 100%);
|
background: linear-gradient(135deg, var(--bg-secondary, #0043f8) 0%, #0075f8 100%);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
@@ -2725,7 +2725,7 @@ const vRipple = {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@@ -2777,14 +2777,14 @@ const vRipple = {
|
|||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--text-secondary, #52525b);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 重播申请人数样式 */
|
/* 重播申请人数样式 */
|
||||||
.replay-requester {
|
.replay-requester {
|
||||||
/* 使用和普通投稿人相同的颜色 */
|
/* 使用和普通投稿人相同的颜色 */
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--text-secondary, #52525b);
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
cursor: help;
|
cursor: help;
|
||||||
}
|
}
|
||||||
@@ -2824,8 +2824,8 @@ const vRipple = {
|
|||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #ffffff;
|
color: var(--text-secondary, #52525b);
|
||||||
opacity: 0.4;
|
opacity: 0.95;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==================== 移动端设计 ==================== */
|
/* ==================== 移动端设计 ==================== */
|
||||||
@@ -2919,7 +2919,7 @@ const vRipple = {
|
|||||||
.date-text {
|
.date-text {
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
text-shadow:
|
text-shadow:
|
||||||
0 0 10px rgba(255, 255, 255, 0.2),
|
0 0 10px rgba(255, 255, 255, 0.2),
|
||||||
@@ -2928,7 +2928,7 @@ const vRipple = {
|
|||||||
|
|
||||||
.dropdown-icon {
|
.dropdown-icon {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-action-btn {
|
.mobile-action-btn {
|
||||||
@@ -2937,7 +2937,7 @@ const vRipple = {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: 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: none;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
box-shadow: 0 4px 15px rgba(11, 90, 254, 0.4);
|
box-shadow: 0 4px 15px rgba(11, 90, 254, 0.4);
|
||||||
@@ -3118,13 +3118,13 @@ const vRipple = {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
line-height: 1.4;
|
line-height: 1.4;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.requester {
|
.requester {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.5);
|
color: var(--text-secondary, #52525b);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3140,7 +3140,7 @@ const vRipple = {
|
|||||||
.vote-count .label {
|
.vote-count .label {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--text-secondary, #52525b);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3168,33 +3168,35 @@ const vRipple = {
|
|||||||
|
|
||||||
/* 日期选择弹窗 */
|
/* 日期选择弹窗 */
|
||||||
.date-picker-content {
|
.date-picker-content {
|
||||||
background: #1a1a1f;
|
background: var(--bg-primary, #ffffff);
|
||||||
border-radius: 20px 20px 0 0;
|
border-radius: 20px 20px 0 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
border: none;
|
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 {
|
.date-picker-header {
|
||||||
padding: 20px;
|
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 {
|
.date-picker-header h3 {
|
||||||
font-size: 17px;
|
font-size: 17px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
color: var(--text-primary, #09090b);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: var(--bg-tertiary, rgba(0, 0, 0, 0.06));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
color: var(--text-secondary, #52525b);
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-picker-list {
|
.date-picker-list {
|
||||||
@@ -3205,20 +3207,23 @@ const vRipple = {
|
|||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
background: rgba(255, 255, 255, 0.03);
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
border: none;
|
border: none;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
color: var(--text-primary, #09090b);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-picker-item:hover {
|
.date-picker-item:hover {
|
||||||
background: rgba(255, 255, 255, 0.06);
|
background: rgba(79, 70, 229, 0.08);
|
||||||
transform: none;
|
transform: none;
|
||||||
|
color: var(--primary, #4f46e5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-picker-item.active {
|
.date-picker-item.active {
|
||||||
background: rgba(11, 90, 254, 0.15);
|
background: rgba(79, 70, 229, 0.14);
|
||||||
border-left: none;
|
border-left: none;
|
||||||
color: #0b5afe;
|
color: var(--primary, #4f46e5);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3366,7 +3371,7 @@ const vRipple = {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
background: #21242d;
|
background: var(--bg-tertiary, #21242d);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
|
box-shadow: 0px 4px 6px rgba(0, 0, 0, 0.1);
|
||||||
@@ -3378,8 +3383,8 @@ const vRipple = {
|
|||||||
|
|
||||||
.date-nav-btn {
|
.date-nav-btn {
|
||||||
background: rgba(11, 90, 254, 0.1);
|
background: rgba(11, 90, 254, 0.1);
|
||||||
border: 1px solid rgba(11, 90, 254, 0.2);
|
border: 1px solid var(--border-secondary);
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
@@ -3403,7 +3408,7 @@ const vRipple = {
|
|||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #ffffff;
|
color: var(--text-primary);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
@@ -3453,10 +3458,10 @@ const vRipple = {
|
|||||||
width: 85%;
|
width: 85%;
|
||||||
max-width: 350px;
|
max-width: 350px;
|
||||||
max-height: 70vh;
|
max-height: 70vh;
|
||||||
background: #1a1d24;
|
background: var(--bg-primary, #ffffff);
|
||||||
border-radius: 12px;
|
border-radius: 18px;
|
||||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.12);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--border-secondary, #e4e4e7);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
animation: scale-in 0.2s ease;
|
animation: scale-in 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -3468,19 +3473,19 @@ const vRipple = {
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
padding: 1rem;
|
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 {
|
.date-picker-header h3 {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: white;
|
color: var(--text-primary, #09090b);
|
||||||
}
|
}
|
||||||
|
|
||||||
.close-btn {
|
.close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: var(--text-secondary, #52525b);
|
||||||
font-size: 22px;
|
font-size: 22px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: 0 5px;
|
padding: 0 5px;
|
||||||
@@ -3495,24 +3500,28 @@ const vRipple = {
|
|||||||
|
|
||||||
.date-picker-item {
|
.date-picker-item {
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
border-radius: 8px;
|
border-radius: 10px;
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
white-space: pre-line;
|
white-space: pre-line;
|
||||||
|
color: var(--text-primary, #09090b);
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-picker-item:hover {
|
.date-picker-item:hover {
|
||||||
background: rgba(11, 90, 254, 0.1);
|
background: rgba(79, 70, 229, 0.1);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
color: var(--primary, #4f46e5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.date-picker-item.active {
|
.date-picker-item.active {
|
||||||
background: rgba(11, 90, 254, 0.2);
|
background: rgba(79, 70, 229, 0.14);
|
||||||
border-left: 3px solid #0b5afe;
|
border-left: 3px solid var(--primary, #4f46e5);
|
||||||
|
color: var(--primary, #4f46e5);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 过渡动画 */
|
/* 过渡动画 */
|
||||||
|
|||||||
@@ -13,16 +13,16 @@
|
|||||||
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-sm"
|
||||||
@click="emit('close')"
|
@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="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-800 flex items-start justify-between gap-4">
|
<div class="px-5 py-4 border-b border-zinc-100 flex items-start justify-between gap-4">
|
||||||
<div class="min-w-0">
|
<div class="min-w-0">
|
||||||
<p class="text-sm font-black text-zinc-100">歌曲评论</p>
|
<p class="text-sm font-black text-zinc-900">歌曲评论</p>
|
||||||
<p class="text-[10px] text-zinc-500 mt-1 truncate">
|
<p class="text-[11px] text-zinc-500 mt-1 truncate">
|
||||||
{{ song?.title }} - {{ song?.artist }}
|
{{ song?.title }} - {{ song?.artist }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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')"
|
@click="emit('close')"
|
||||||
>
|
>
|
||||||
<X :size="16" class="mx-auto" />
|
<X :size="16" class="mx-auto" />
|
||||||
@@ -30,36 +30,36 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="p-5 space-y-4 max-h-[70vh] overflow-y-auto">
|
<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-if="loading" class="text-center text-zinc-400 py-6">加载中...</div>
|
||||||
<div v-else-if="comments.length === 0" class="text-center text-zinc-500 py-6">
|
<div v-else-if="comments.length === 0" class="text-center text-zinc-400 py-6">
|
||||||
暂无评论
|
暂无评论
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="space-y-3">
|
<div v-else class="space-y-3">
|
||||||
<div
|
<div
|
||||||
v-for="c in comments"
|
v-for="c in comments"
|
||||||
:key="c.id"
|
: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="flex items-center justify-between gap-3">
|
||||||
<div class="min-w-0">
|
<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>
|
<p class="text-[10px] text-zinc-500 mt-0.5">{{ formatTime(c.createdAt) }}</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
v-if="canDelete(c)"
|
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"
|
:disabled="deletingId === c.id"
|
||||||
@click="deleteComment(c)"
|
@click="deleteComment(c)"
|
||||||
>
|
>
|
||||||
删除
|
删除
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
||||||
</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 v-if="!isAuthenticated" class="text-xs text-zinc-500">
|
||||||
登录后可发表评论
|
登录后可发表评论
|
||||||
</div>
|
</div>
|
||||||
@@ -67,13 +67,13 @@
|
|||||||
<textarea
|
<textarea
|
||||||
v-model="draft"
|
v-model="draft"
|
||||||
:rows="3"
|
: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 字)"
|
placeholder="写下你的评论(最多 300 字)"
|
||||||
/>
|
/>
|
||||||
<div class="flex items-center justify-between gap-3">
|
<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
|
<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"
|
:disabled="posting || !draft.trim() || draft.trim().length > 300"
|
||||||
@click="postComment"
|
@click="postComment"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -234,14 +234,16 @@
|
|||||||
</span>
|
</span>
|
||||||
<span
|
<span
|
||||||
v-else-if="song.scheduled"
|
v-else-if="song.scheduled"
|
||||||
:title="
|
class="scheduled-group"
|
||||||
song.scheduleDate
|
|
||||||
? `排期日期:${formatScheduleDate(song.scheduleDate)}`
|
|
||||||
: '已排期'
|
|
||||||
"
|
|
||||||
class="scheduled-tag"
|
|
||||||
>
|
>
|
||||||
已排期
|
<span class="scheduled-tag">已排期</span>
|
||||||
|
<span
|
||||||
|
v-if="song.scheduleDate"
|
||||||
|
:title="`排期日期:${formatScheduleDate(song.scheduleDate)}`"
|
||||||
|
class="scheduled-date"
|
||||||
|
>
|
||||||
|
{{ formatScheduleDate(song.scheduleDate) }}
|
||||||
|
</span>
|
||||||
</span>
|
</span>
|
||||||
<span v-else-if="song.isReplay" title="重播歌曲" class="replay-tag"> 重播 </span>
|
<span v-else-if="song.isReplay" title="重播歌曲" class="replay-tag"> 重播 </span>
|
||||||
<button
|
<button
|
||||||
@@ -1691,14 +1693,14 @@ const vRipple = {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 12px 16px 12px 42px;
|
padding: 12px 16px 12px 42px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: #fff;
|
color: var(--text-primary, #ffffff);
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.mobile-search-input:focus {
|
.mobile-search-input:focus {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
border-color: #3b82f6;
|
border-color: var(--border-secondary, #3b82f6);
|
||||||
outline: none;
|
outline: none;
|
||||||
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.1);
|
||||||
}
|
}
|
||||||
@@ -1785,7 +1787,7 @@ const vRipple = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tab-button.active {
|
.tab-button.active {
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
border-bottom-color: #0b5afe;
|
border-bottom-color: #0b5afe;
|
||||||
transform: none;
|
transform: none;
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
@@ -1834,7 +1836,7 @@ const vRipple = {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
background: #21242d;
|
background: var(--bg-tertiary, #21242d);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1845,7 +1847,7 @@ const vRipple = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.semester-toggle-btn:hover {
|
.semester-toggle-btn:hover {
|
||||||
background: #2a2e38;
|
background: var(--bg-tertiary, #2a2e38);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
color: #0b5afe;
|
color: #0b5afe;
|
||||||
@@ -1855,7 +1857,7 @@ const vRipple = {
|
|||||||
position: absolute;
|
position: absolute;
|
||||||
top: calc(100% + 8px);
|
top: calc(100% + 8px);
|
||||||
right: 0;
|
right: 0;
|
||||||
background: #1a1d24;
|
background: var(--bg-secondary, #1a1d24);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
@@ -1879,7 +1881,7 @@ const vRipple = {
|
|||||||
|
|
||||||
.semester-option:hover {
|
.semester-option:hover {
|
||||||
background: rgba(11, 90, 254, 0.1);
|
background: rgba(11, 90, 254, 0.1);
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.semester-option.active {
|
.semester-option.active {
|
||||||
@@ -1894,14 +1896,14 @@ const vRipple = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.search-input {
|
.search-input {
|
||||||
background: #040e15;
|
background: var(--input-bg, #040e15);
|
||||||
border: 1px solid #242f38;
|
border: 1px solid var(--input-border, #242f38);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.5rem 1rem;
|
||||||
padding-right: 2.5rem;
|
padding-right: 2.5rem;
|
||||||
font-family: 'MiSans-Demibold', sans-serif;
|
font-family: 'MiSans-Demibold', sans-serif;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: var(--input-text, rgba(255, 255, 255, 0.8));
|
||||||
width: 100%;
|
width: 100%;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
@@ -1925,7 +1927,7 @@ const vRipple = {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
background: #21242d;
|
background: var(--bg-tertiary, #21242d);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -1934,7 +1936,7 @@ const vRipple = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.refresh-button:hover {
|
.refresh-button:hover {
|
||||||
background: #2a2e38;
|
background: var(--bg-tertiary, #2a2e38);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
|
||||||
}
|
}
|
||||||
@@ -2028,7 +2030,7 @@ const vRipple = {
|
|||||||
|
|
||||||
.song-card-main {
|
.song-card-main {
|
||||||
padding: 1rem 0 1rem 1rem; /* 移除右侧内边距,保留左侧、上下内边距 */
|
padding: 1rem 0 1rem 1rem; /* 移除右侧内边距,保留左侧、上下内边距 */
|
||||||
background: #21242d;
|
background: var(--bg-tertiary, #21242d);
|
||||||
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
|
box-shadow: 0px 4px 4px rgba(0, 0, 0, 0.25);
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 100px; /* 减小卡片高度 */
|
height: 100px; /* 减小卡片高度 */
|
||||||
@@ -2076,8 +2078,8 @@ const vRipple = {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, #0043f8 0%, #0075f8 100%);
|
background: linear-gradient(135deg, var(--bg-secondary, #0043f8) 0%, #0075f8 100%);
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
font-family: 'MiSans-Demibold', sans-serif;
|
font-family: 'MiSans-Demibold', sans-serif;
|
||||||
@@ -2139,7 +2141,7 @@ const vRipple = {
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
letter-spacing: 0.04em;
|
letter-spacing: 0.04em;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
margin-bottom: 0.5rem;
|
margin-bottom: 0.5rem;
|
||||||
width: 100%; /* 确保标题占满整个容器宽度 */
|
width: 100%; /* 确保标题占满整个容器宽度 */
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2167,7 +2169,7 @@ const vRipple = {
|
|||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
font-weight: normal;
|
font-weight: normal;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--text-secondary, #52525b);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -2215,8 +2217,8 @@ const vRipple = {
|
|||||||
font-family: 'MiSans-Demibold', sans-serif;
|
font-family: 'MiSans-Demibold', sans-serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #ffffff;
|
color: var(--text-secondary, #52525b);
|
||||||
opacity: 0.4;
|
opacity: 0.9;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 点赞按钮样式 */
|
/* 点赞按钮样式 */
|
||||||
@@ -2231,24 +2233,27 @@ const vRipple = {
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 48px;
|
width: 48px;
|
||||||
height: 45px;
|
height: 45px;
|
||||||
background: linear-gradient(180deg, #0043f8 0%, #0075f8 100%);
|
background: linear-gradient(180deg, #4f46e5 0%, #6366f1 100%);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.16);
|
border: 1px solid rgba(79, 70, 229, 0.25);
|
||||||
border-radius: 8px;
|
border-radius: 12px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
|
box-shadow: 0 4px 10px rgba(79, 70, 229, 0.18);
|
||||||
}
|
}
|
||||||
|
|
||||||
.like-button.liked {
|
.like-button.liked {
|
||||||
background: #1a1d24;
|
background: var(--primary-light, rgba(79, 70, 229, 0.12));
|
||||||
border-color: #242f38;
|
border-color: var(--primary, #4f46e5);
|
||||||
background-image: none;
|
background-image: none;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.like-button.disabled {
|
.like-button.disabled {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: var(--border-secondary, #e4e4e7);
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.like-button.disabled .like-icon {
|
.like-button.disabled .like-icon {
|
||||||
@@ -2265,17 +2270,36 @@ const vRipple = {
|
|||||||
transform: scale(1.2);
|
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 {
|
.scheduled-tag {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
background: rgba(16, 185, 129, 0.2);
|
background: rgba(16, 185, 129, 0.12);
|
||||||
border: 1px solid rgba(16, 185, 129, 0.4);
|
border: 1px solid rgba(16, 185, 129, 0.35);
|
||||||
border-radius: 4px;
|
border-radius: 6px;
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.15rem 0.5rem;
|
||||||
font-size: 0.7rem;
|
font-size: 0.72rem;
|
||||||
color: #10b981;
|
font-weight: 700;
|
||||||
margin-left: 0.5rem;
|
color: #059669;
|
||||||
flex-shrink: 0; /* 防止标签被压缩 */
|
}
|
||||||
align-self: center; /* 确保垂直居中 */
|
|
||||||
|
.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 {
|
.played-tag {
|
||||||
@@ -2308,13 +2332,14 @@ const vRipple = {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.25rem;
|
gap: 0.25rem;
|
||||||
padding: 0.15rem 0.4rem;
|
padding: 0.25rem 0.6rem;
|
||||||
border-radius: 4px;
|
border-radius: 8px;
|
||||||
font-size: 0.7rem;
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
background: rgba(59, 130, 246, 0.15);
|
background: rgba(79, 70, 229, 0.08);
|
||||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
border: 1px solid rgba(79, 70, 229, 0.2);
|
||||||
color: #93c5fd;
|
color: var(--primary, #4f46e5);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2329,20 +2354,21 @@ const vRipple = {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
background: #21242d;
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
border-radius: 0 0 10px 10px;
|
border-radius: 0 0 14px 14px;
|
||||||
padding: 0.5rem 1rem;
|
padding: 0.6rem 1rem;
|
||||||
width: 95%;
|
width: 95%;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
height: 45px;
|
height: 45px;
|
||||||
|
border-top: 1px solid var(--border-secondary, #e4e4e7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submission-time {
|
.submission-time {
|
||||||
font-family: 'MiSans-Demibold', sans-serif;
|
font-family: 'MiSans-Demibold', sans-serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: var(--text-secondary, #52525b);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
max-width: 70%;
|
max-width: 70%;
|
||||||
}
|
}
|
||||||
@@ -2355,7 +2381,7 @@ const vRipple = {
|
|||||||
font-family: 'MiSans-Demibold', sans-serif;
|
font-family: 'MiSans-Demibold', sans-serif;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
height: 27px;
|
height: 27px;
|
||||||
@@ -2368,12 +2394,12 @@ const vRipple = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.replay-cancel-btn {
|
.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;
|
min-width: 75px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.replay-request-btn {
|
.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;
|
min-width: 75px;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2408,13 +2434,13 @@ button:disabled {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-number.active {
|
.page-number.active {
|
||||||
background: #0b5afe;
|
background: var(--bg-secondary, #0b5afe);
|
||||||
border-color: #0b5afe;
|
border-color: #0b5afe;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2444,7 +2470,7 @@ button:disabled {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
width: 60px;
|
width: 60px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
@@ -2467,7 +2493,7 @@ button:disabled {
|
|||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0.25rem 0.5rem;
|
padding: 0.25rem 0.5rem;
|
||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -2593,7 +2619,7 @@ button:disabled {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.semester-dropdown {
|
.semester-dropdown {
|
||||||
background: #1a1a1f;
|
background: var(--surface-primary, var(--bg-primary, #1a1a1f));
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.4);
|
||||||
@@ -2705,13 +2731,13 @@ button:disabled {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin-bottom: 4px;
|
margin-bottom: 4px;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #09090b);
|
||||||
letter-spacing: 0.01em;
|
letter-spacing: 0.01em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.requester {
|
.requester {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
color: rgba(255, 255, 255, 0.4);
|
color: var(--text-secondary, #52525b);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
}
|
}
|
||||||
@@ -2736,7 +2762,7 @@ button:disabled {
|
|||||||
.vote-count .label {
|
.vote-count .label {
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: var(--text-secondary, #52525b);
|
||||||
margin-top: 2px;
|
margin-top: 2px;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
}
|
}
|
||||||
@@ -2745,8 +2771,8 @@ button:disabled {
|
|||||||
width: 44px;
|
width: 44px;
|
||||||
height: 44px;
|
height: 44px;
|
||||||
border-radius: 14px;
|
border-radius: 14px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--border-secondary, #e4e4e7);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -2765,16 +2791,16 @@ button:disabled {
|
|||||||
|
|
||||||
/* 投稿时间和操作 */
|
/* 投稿时间和操作 */
|
||||||
.submission-footer {
|
.submission-footer {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
padding: 10px 16px;
|
padding: 10px 16px;
|
||||||
height: auto;
|
height: auto;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
border-top: 1px solid rgba(255, 255, 255, 0.04);
|
border-top: 1px solid var(--border-secondary, #e4e4e7);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submission-time {
|
.submission-time {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: rgba(255, 255, 255, 0.3);
|
color: var(--text-secondary, #52525b);
|
||||||
font-weight: 400;
|
font-weight: 400;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2783,9 +2809,9 @@ button:disabled {
|
|||||||
padding: 0 12px;
|
padding: 0 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid var(--border-secondary, #e4e4e7);
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: var(--text-primary, #09090b);
|
||||||
}
|
}
|
||||||
|
|
||||||
.withdraw-button.replay-cancel-btn,
|
.withdraw-button.replay-cancel-btn,
|
||||||
@@ -2930,7 +2956,7 @@ button:disabled {
|
|||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
background: rgba(255, 255, 255, 0.05);
|
background: rgba(255, 255, 255, 0.05);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
color: #fff;
|
color: var(--text-primary, #ffffff);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -2964,7 +2990,7 @@ button:disabled {
|
|||||||
width: 40px;
|
width: 40px;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
color: #fff;
|
color: var(--text-primary, #ffffff);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
@@ -2991,7 +3017,7 @@ button:disabled {
|
|||||||
|
|
||||||
.page-nav-btn:hover:not(:disabled) {
|
.page-nav-btn:hover:not(:disabled) {
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border-color: #3b82f6;
|
border-color: var(--border-secondary, #3b82f6);
|
||||||
}
|
}
|
||||||
|
|
||||||
.page-nav-btn:disabled {
|
.page-nav-btn:disabled {
|
||||||
@@ -3052,22 +3078,23 @@ button:disabled {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 22px;
|
width: 26px;
|
||||||
height: 22px;
|
height: 26px;
|
||||||
margin-left: 6px;
|
margin-left: 6px;
|
||||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
border: 1px solid var(--border-secondary, #e4e4e7);
|
||||||
border-radius: 999px;
|
border-radius: 10px;
|
||||||
background: rgba(59, 130, 246, 0.08);
|
background: var(--bg-secondary, #f4f4f5);
|
||||||
color: #60a5fa;
|
color: var(--primary, #4f46e5);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
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 {
|
.submission-note-trigger:hover {
|
||||||
background: rgba(59, 130, 246, 0.18);
|
background: rgba(79, 70, 229, 0.08);
|
||||||
border-color: rgba(59, 130, 246, 0.5);
|
border-color: rgba(79, 70, 229, 0.35);
|
||||||
box-shadow: 0 6px 12px rgba(96, 165, 250, 0.15);
|
color: #4338ca;
|
||||||
|
box-shadow: 0 4px 10px rgba(79, 70, 229, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
.submission-note-modal {
|
.submission-note-modal {
|
||||||
|
|||||||
@@ -1528,7 +1528,7 @@ const getFirstChar = (text) => {
|
|||||||
|
|
||||||
.mobile-top-progress .progress-fill {
|
.mobile-top-progress .progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #0b5afe;
|
background: var(--bg-secondary, #0b5afe);
|
||||||
box-shadow: 0 0 8px rgba(11, 90, 254, 0.6);
|
box-shadow: 0 0 8px rgba(11, 90, 254, 0.6);
|
||||||
border-radius: 0 1px 1px 0;
|
border-radius: 0 1px 1px 0;
|
||||||
}
|
}
|
||||||
@@ -1729,7 +1729,7 @@ const getFirstChar = (text) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-family:
|
font-family:
|
||||||
@@ -1756,7 +1756,7 @@ const getFirstChar = (text) => {
|
|||||||
line-height: 22px;
|
line-height: 22px;
|
||||||
padding-bottom: 2px;
|
padding-bottom: 2px;
|
||||||
letter-spacing: -0.4px;
|
letter-spacing: -0.4px;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
font-family:
|
font-family:
|
||||||
'SF Pro', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica,
|
'SF Pro', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
@@ -1776,7 +1776,7 @@ const getFirstChar = (text) => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
line-height: 20px;
|
line-height: 20px;
|
||||||
letter-spacing: -0.4px;
|
letter-spacing: -0.4px;
|
||||||
color: #ffffff75;
|
color: var(--text-primary, #ffffff)75;
|
||||||
font-family:
|
font-family:
|
||||||
'SF Pro', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica,
|
'SF Pro', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
@@ -1797,7 +1797,7 @@ const getFirstChar = (text) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
border-radius: 100px;
|
border-radius: 100px;
|
||||||
background: #00000042;
|
background: var(--bg-primary, #111111)42;
|
||||||
padding: 6px;
|
padding: 6px;
|
||||||
width: 32px;
|
width: 32px;
|
||||||
height: 32px;
|
height: 32px;
|
||||||
@@ -1814,7 +1814,7 @@ const getFirstChar = (text) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.close-icon {
|
.close-icon {
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
font-size: 15px;
|
font-size: 15px;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-family:
|
font-family:
|
||||||
@@ -1851,7 +1851,7 @@ const getFirstChar = (text) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
border: 1px solid #1a1a1a;
|
border: 1px solid var(--border-secondary, #1a1a1a);
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
@@ -1864,14 +1864,14 @@ const getFirstChar = (text) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ios-progress-bar:hover {
|
.ios-progress-bar:hover {
|
||||||
border-color: #333;
|
border-color: var(--border-secondary, #333);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 20px rgba(255, 255, 255, 0.1),
|
0 0 20px rgba(255, 255, 255, 0.1),
|
||||||
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
inset 0 1px 0 rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
background: linear-gradient(90deg, #ffffffb2, #ffffff);
|
background: linear-gradient(90deg, var(--card-bg, #ffffff)b2, #ffffff);
|
||||||
height: 8px;
|
height: 8px;
|
||||||
transition: width 0.1s linear;
|
transition: width 0.1s linear;
|
||||||
border-radius: 7px;
|
border-radius: 7px;
|
||||||
@@ -1916,7 +1916,7 @@ const getFirstChar = (text) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.clickable-time:hover {
|
.clickable-time:hover {
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
transform: scale(1.05);
|
transform: scale(1.05);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1931,10 +1931,10 @@ const getFirstChar = (text) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #ffffff1a;
|
background: var(--card-bg, #ffffff)1a;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
color: #ffffffb3;
|
color: var(--text-primary, #ffffff)b3;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -1944,8 +1944,8 @@ const getFirstChar = (text) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.audio-quality-badge:hover {
|
.audio-quality-badge:hover {
|
||||||
background: #ffffff26;
|
background: var(--card-bg, #ffffff)26;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 控制按钮区域 */
|
/* 控制按钮区域 */
|
||||||
@@ -1962,7 +1962,7 @@ const getFirstChar = (text) => {
|
|||||||
.ios-control-btn {
|
.ios-control-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #ffffff92;
|
color: var(--text-primary, #ffffff)92;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
transition: all 0.3s cubic-bezier(0.25, 0.46, 0.45, 0.94);
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -1997,7 +1997,7 @@ const getFirstChar = (text) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.ios-control-btn:hover {
|
.ios-control-btn:hover {
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
box-shadow:
|
box-shadow:
|
||||||
0 0 20px rgba(255, 255, 255, 0.2),
|
0 0 20px rgba(255, 255, 255, 0.2),
|
||||||
@@ -2196,7 +2196,7 @@ const getFirstChar = (text) => {
|
|||||||
width: 60px;
|
width: 60px;
|
||||||
line-height: 18px;
|
line-height: 18px;
|
||||||
letter-spacing: 0.42px;
|
letter-spacing: 0.42px;
|
||||||
color: #ffffffb3;
|
color: var(--text-primary, #ffffff)b3;
|
||||||
font-family:
|
font-family:
|
||||||
'SF Pro', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica,
|
'SF Pro', 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', SimHei, Arial, Helvetica,
|
||||||
sans-serif;
|
sans-serif;
|
||||||
@@ -2215,10 +2215,10 @@ const getFirstChar = (text) => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #ffffff1a;
|
background: var(--card-bg, #ffffff)1a;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
color: #ffffffb3;
|
color: var(--text-primary, #ffffff)b3;
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
backdrop-filter: blur(10px);
|
backdrop-filter: blur(10px);
|
||||||
@@ -2228,8 +2228,8 @@ const getFirstChar = (text) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.audio-quality:hover {
|
.audio-quality:hover {
|
||||||
background: #ffffff26;
|
background: var(--card-bg, #ffffff)26;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-quality:active {
|
.audio-quality:active {
|
||||||
@@ -2251,13 +2251,13 @@ const getFirstChar = (text) => {
|
|||||||
width: 39px;
|
width: 39px;
|
||||||
font-size: 25px;
|
font-size: 25px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
color: #ffffff92;
|
color: var(--text-primary, #ffffff)92;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.lyrics-btn:hover {
|
.lyrics-btn:hover {
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
transform: scale(1.1);
|
transform: scale(1.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2265,7 +2265,7 @@ const getFirstChar = (text) => {
|
|||||||
.control-btn {
|
.control-btn {
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -2290,7 +2290,7 @@ const getFirstChar = (text) => {
|
|||||||
.play-pause-btn {
|
.play-pause-btn {
|
||||||
line-height: 44px;
|
line-height: 44px;
|
||||||
font-size: 37px;
|
font-size: 37px;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
<template>
|
<template>
|
||||||
<div ref="containerRef" class="relative" :class="className">
|
<div ref="containerRef" class="relative" :class="className">
|
||||||
<div
|
<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="[
|
:class="[
|
||||||
isOpen
|
isOpen
|
||||||
? 'border-blue-500/50 bg-blue-600/5 shadow-lg'
|
? 'border-indigo-400/60 bg-indigo-50/60 shadow-sm'
|
||||||
: 'border-zinc-800 hover:border-zinc-700'
|
: 'border-zinc-200 hover:border-zinc-300'
|
||||||
]"
|
]"
|
||||||
@click="toggleDropdown"
|
@click="toggleDropdown"
|
||||||
>
|
>
|
||||||
@@ -13,14 +13,14 @@
|
|||||||
<span
|
<span
|
||||||
v-if="label"
|
v-if="label"
|
||||||
class="text-[8px] font-black uppercase tracking-widest leading-none mb-0.5 transition-colors"
|
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 }}
|
{{ label }}
|
||||||
</span>
|
</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>
|
||||||
<div class="transition-transform duration-200" :class="{ 'rotate-180': isOpen }">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -28,26 +28,26 @@
|
|||||||
<Transition
|
<Transition
|
||||||
enter-active-class="transition duration-100 ease-out"
|
enter-active-class="transition duration-100 ease-out"
|
||||||
enter-from-class="transform scale-95 opacity-0"
|
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-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"
|
leave-to-class="transform scale-95 opacity-0"
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
v-if="isOpen"
|
v-if="isOpen"
|
||||||
ref="dropdownRef"
|
ref="dropdownRef"
|
||||||
:style="dropdownStyle"
|
: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
|
<button
|
||||||
v-for="option in normalizedOptions"
|
v-for="option in normalizedOptions"
|
||||||
:key="option.value"
|
: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="[
|
:class="[
|
||||||
isSelected(option)
|
isSelected(option)
|
||||||
? 'bg-blue-600/10 text-blue-400'
|
? 'bg-indigo-50 text-indigo-600'
|
||||||
: 'text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800/40'
|
: 'text-zinc-600 hover:text-zinc-900 hover:bg-zinc-100'
|
||||||
]"
|
]"
|
||||||
@click="selectOption(option)"
|
@click="selectOption(option)"
|
||||||
>
|
>
|
||||||
@@ -216,10 +216,10 @@ onUnmounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--border-secondary, #e4e4e7);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: #3f3f46;
|
background: var(--border-primary, #d4d4d8);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -157,6 +157,16 @@
|
|||||||
<polyline points="12,6 12,12 16,14" />
|
<polyline points="12,6 12,12 16,14" />
|
||||||
</g>
|
</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'">
|
<g v-else-if="name === 'user'">
|
||||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
@@ -367,6 +377,42 @@
|
|||||||
/>
|
/>
|
||||||
</g>
|
</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'">
|
<g v-else-if="name === 'search'">
|
||||||
<circle cx="11" cy="11" r="8" />
|
<circle cx="11" cy="11" r="8" />
|
||||||
|
|||||||
@@ -953,8 +953,8 @@ onUnmounted(() => {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #000000;
|
background: var(--bg-primary, #000000);
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -1136,7 +1136,7 @@ onUnmounted(() => {
|
|||||||
.default-cover {
|
.default-cover {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: linear-gradient(135deg, #333, #555);
|
background: linear-gradient(135deg, var(--bg-quaternary, #333333), #555);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -1152,7 +1152,7 @@ onUnmounted(() => {
|
|||||||
font-size: 2.2rem;
|
font-size: 2.2rem;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0 0.5rem 0;
|
margin: 0 0 0.5rem 0;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
letter-spacing: -0.02em;
|
letter-spacing: -0.02em;
|
||||||
white-space: normal;
|
white-space: normal;
|
||||||
@@ -1246,7 +1246,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background: #ffffff;
|
background: var(--card-bg, #ffffff);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
@@ -1256,7 +1256,7 @@ onUnmounted(() => {
|
|||||||
top: 50%;
|
top: 50%;
|
||||||
width: 14px;
|
width: 14px;
|
||||||
height: 14px;
|
height: 14px;
|
||||||
background: #ffffff;
|
background: var(--card-bg, #ffffff);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
transform: translate(-50%, -50%) scale(0);
|
transform: translate(-50%, -50%) scale(0);
|
||||||
transition: transform 0.1s ease;
|
transition: transform 0.1s ease;
|
||||||
@@ -1306,7 +1306,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.secondary-btn.active {
|
.secondary-btn.active {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
text-shadow: 0 0 8px rgba(255, 255, 255, 0.4);
|
text-shadow: 0 0 8px rgba(255, 255, 255, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1472,7 +1472,7 @@ onUnmounted(() => {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: #333; /* 深色文字 */
|
color: var(--text-quaternary, #333333); /* 深色文字 */
|
||||||
padding: 8px 12px; /* 减小内边距 */
|
padding: 8px 12px; /* 减小内边距 */
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
font-size: 0.85rem; /* 稍微减小字体 */
|
font-size: 0.85rem; /* 稍微减小字体 */
|
||||||
@@ -1490,7 +1490,7 @@ onUnmounted(() => {
|
|||||||
|
|
||||||
.badge-quality-option.active {
|
.badge-quality-option.active {
|
||||||
color: #007aff; /* 蓝色字 */
|
color: #007aff; /* 蓝色字 */
|
||||||
background: #ffffff;
|
background: var(--card-bg, #ffffff);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
@@ -1522,7 +1522,7 @@ onUnmounted(() => {
|
|||||||
width: 20px;
|
width: 20px;
|
||||||
height: 5px;
|
height: 5px;
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
background: #ffffff;
|
background: var(--card-bg, #ffffff);
|
||||||
box-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
|
box-shadow: 0 0 10px rgba(255, 255, 255, 0.3);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1759,7 +1759,7 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.progress-fill {
|
.progress-fill {
|
||||||
background: #ffffff;
|
background: var(--card-bg, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-thumb {
|
.progress-thumb {
|
||||||
|
|||||||
@@ -70,8 +70,8 @@ defineEmits(['close'])
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
background: #21242d;
|
background: var(--bg-tertiary, #21242d);
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
@@ -79,7 +79,7 @@ defineEmits(['close'])
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification.success {
|
.notification.success {
|
||||||
border-left: 4px solid #10b981;
|
border-left: 4px solid var(--border-secondary, #10b981);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification.error {
|
.notification.error {
|
||||||
@@ -140,7 +140,7 @@ defineEmits(['close'])
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-close:hover {
|
.notification-close:hover {
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 进度条 */
|
/* 进度条 */
|
||||||
@@ -161,7 +161,7 @@ defineEmits(['close'])
|
|||||||
}
|
}
|
||||||
|
|
||||||
.success .notification-progress-bar {
|
.success .notification-progress-bar {
|
||||||
background-color: #10b981;
|
background-color: var(--bg-secondary, #10b981);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error .notification-progress-bar {
|
.error .notification-progress-bar {
|
||||||
@@ -169,7 +169,7 @@ defineEmits(['close'])
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info .notification-progress-bar {
|
.info .notification-progress-bar {
|
||||||
background-color: #0b5afe;
|
background-color: var(--bg-secondary, #0b5afe);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes progress-shrink {
|
@keyframes progress-shrink {
|
||||||
|
|||||||
@@ -82,8 +82,8 @@ onMounted(() => {
|
|||||||
padding: 16px;
|
padding: 16px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4);
|
||||||
background: #21242d;
|
background: var(--bg-tertiary, #21242d);
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
max-width: 400px;
|
max-width: 400px;
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
@@ -94,7 +94,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-item.success {
|
.notification-item.success {
|
||||||
border-left: 4px solid #10b981;
|
border-left: 4px solid var(--border-secondary, #10b981);
|
||||||
}
|
}
|
||||||
|
|
||||||
.notification-item.error {
|
.notification-item.error {
|
||||||
@@ -158,7 +158,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.notification-close:hover {
|
.notification-close:hover {
|
||||||
color: #ffffff;
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 进度条 */
|
/* 进度条 */
|
||||||
@@ -179,7 +179,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.success .notification-progress-bar {
|
.success .notification-progress-bar {
|
||||||
background-color: #10b981;
|
background-color: var(--bg-secondary, #10b981);
|
||||||
}
|
}
|
||||||
|
|
||||||
.error .notification-progress-bar {
|
.error .notification-progress-bar {
|
||||||
@@ -187,7 +187,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info .notification-progress-bar {
|
.info .notification-progress-bar {
|
||||||
background-color: #0b5afe;
|
background-color: var(--bg-secondary, #0b5afe);
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes progress-shrink {
|
@keyframes progress-shrink {
|
||||||
|
|||||||
@@ -2,11 +2,11 @@
|
|||||||
<div class="user-tutorial-container relative">
|
<div class="user-tutorial-container relative">
|
||||||
<div class="tutorial-header flex justify-between items-start">
|
<div class="tutorial-header flex justify-between items-start">
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-lg font-black text-zinc-100 flex items-center gap-2">
|
<h3 class="text-lg font-black text-text-primary flex items-center gap-2">
|
||||||
<BookOpen class="w-5 h-5 text-blue-400" />
|
<BookOpen class="w-5 h-5 text-primary" />
|
||||||
使用指南
|
使用指南
|
||||||
</h3>
|
</h3>
|
||||||
<p class="text-xs text-zinc-500 mt-1">快速了解如何使用 VoiceHub 校园广播系统</p>
|
<p class="text-xs text-text-secondary mt-1">快速了解如何使用宝一外校园点歌系统</p>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="start-tour-btn"
|
class="start-tour-btn"
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
|
|
||||||
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mt-4">
|
||||||
<div class="tutorial-card">
|
<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" />
|
<Search class="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h4 class="card-title">1. 搜索与投稿</h4>
|
<h4 class="card-title">1. 搜索与投稿</h4>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tutorial-card">
|
<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" />
|
<ThumbsUp class="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h4 class="card-title">2. 互动与点赞</h4>
|
<h4 class="card-title">2. 互动与点赞</h4>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="tutorial-card">
|
<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" />
|
<CalendarDays class="w-6 h-6" />
|
||||||
</div>
|
</div>
|
||||||
<h4 class="card-title">3. 关注排期与通知</h4>
|
<h4 class="card-title">3. 关注排期与通知</h4>
|
||||||
@@ -92,7 +92,7 @@ const windowSize = ref({ width: 0, height: 0 })
|
|||||||
const tourSteps = [
|
const tourSteps = [
|
||||||
{
|
{
|
||||||
element: '.user-tutorial-container',
|
element: '.user-tutorial-container',
|
||||||
title: '欢迎来到 VoiceHub!',
|
title: '欢迎来到宝一外校园点歌系统!',
|
||||||
description: '这是一个简单的引导,带您快速了解如何使用校园广播系统。',
|
description: '这是一个简单的引导,带您快速了解如何使用校园广播系统。',
|
||||||
position: 'top'
|
position: 'top'
|
||||||
},
|
},
|
||||||
@@ -276,25 +276,25 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.user-tutorial-container {
|
.user-tutorial-container {
|
||||||
background: rgba(0, 0, 0, 0.4);
|
background: var(--bg-secondary, rgba(0, 0, 0, 0.4));
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
padding: 1.5rem;
|
padding: 1.5rem;
|
||||||
margin-top: 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);
|
backdrop-filter: blur(10px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tutorial-card {
|
.tutorial-card {
|
||||||
background: rgba(255, 255, 255, 0.02);
|
background: var(--bg-tertiary, rgba(255, 255, 255, 0.02));
|
||||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
border: 1px solid var(--border-secondary, rgba(255, 255, 255, 0.05));
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
transition: all 0.3s ease;
|
transition: all 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tutorial-card:hover {
|
.tutorial-card:hover {
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--bg-hover, rgba(255, 255, 255, 0.04));
|
||||||
border-color: rgba(255, 255, 255, 0.1);
|
border-color: var(--border-tertiary, rgba(255, 255, 255, 0.1));
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,14 +312,14 @@ onMounted(() => {
|
|||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
font-size: 0.95rem;
|
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;
|
margin-bottom: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-desc {
|
.card-desc {
|
||||||
font-family: 'MiSans', sans-serif;
|
font-family: 'MiSans', sans-serif;
|
||||||
font-size: 0.8rem;
|
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;
|
line-height: 1.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -359,15 +359,15 @@ onMounted(() => {
|
|||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.5), 0 0 15px rgba(0, 0, 0, 0.5);
|
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;
|
pointer-events: none;
|
||||||
transition: all 0.4s ease;
|
transition: all 0.4s ease;
|
||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tour-popover {
|
.tour-popover {
|
||||||
background: #18181b;
|
background: var(--bg-secondary, #18181b);
|
||||||
border: 1px solid #27272a;
|
border: 1px solid var(--border-secondary, #27272a);
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 1.25rem;
|
padding: 1.25rem;
|
||||||
width: 320px;
|
width: 320px;
|
||||||
@@ -387,14 +387,14 @@ onMounted(() => {
|
|||||||
.tour-title {
|
.tour-title {
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 800;
|
font-weight: 800;
|
||||||
color: #f4f4f5;
|
color: var(--text-primary);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tour-close-btn {
|
.tour-close-btn {
|
||||||
background: none;
|
background: none;
|
||||||
border: none;
|
border: none;
|
||||||
color: #71717a;
|
color: var(--text-tertiary);
|
||||||
font-size: 1.5rem;
|
font-size: 1.5rem;
|
||||||
line-height: 1;
|
line-height: 1;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
@@ -403,11 +403,11 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tour-close-btn:hover {
|
.tour-close-btn:hover {
|
||||||
color: #f4f4f5;
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tour-body p {
|
.tour-body p {
|
||||||
color: #a1a1aa;
|
color: var(--text-secondary);
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
margin: 0 0 1rem 0;
|
margin: 0 0 1rem 0;
|
||||||
@@ -420,7 +420,7 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tour-progress {
|
.tour-progress {
|
||||||
color: #71717a;
|
color: var(--text-tertiary);
|
||||||
font-size: 0.8rem;
|
font-size: 0.8rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
@@ -440,9 +440,9 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.tour-btn-secondary {
|
.tour-btn-secondary {
|
||||||
background: #27272a;
|
background: var(--bg-tertiary, #27272a);
|
||||||
color: #d4d4d8;
|
color: var(--text-primary);
|
||||||
border: 1px solid #3f3f46;
|
border: 1px solid var(--border-secondary, #3f3f46);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tour-btn-secondary:hover {
|
.tour-btn-secondary:hover {
|
||||||
@@ -451,11 +451,11 @@ onMounted(() => {
|
|||||||
|
|
||||||
.tour-btn-primary {
|
.tour-btn-primary {
|
||||||
background: #3b82f6;
|
background: #3b82f6;
|
||||||
color: white;
|
color: var(--btn-primary-text);
|
||||||
border: none;
|
border: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.tour-btn-primary:hover {
|
.tour-btn-primary:hover {
|
||||||
background: #2563eb;
|
background: var(--bg-tertiary, #2563eb);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -37,13 +37,13 @@ defineProps({
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #1a0b2e;
|
background-color: var(--surface-primary, var(--bg-primary, #1a0b2e));
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-overlay {
|
.bg-overlay {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: linear-gradient(to top, #000, transparent, transparent);
|
background: linear-gradient(to top, var(--bg-primary, #111111), transparent, transparent);
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@ const formatDate = (dateStr) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #000;
|
background-color: var(--bg-primary, #111111);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-blur {
|
.bg-blur {
|
||||||
@@ -120,7 +120,7 @@ const formatDate = (dateStr) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.highlight {
|
.highlight {
|
||||||
color: #fff;
|
color: var(--text-primary, #ffffff);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0 0.5rem;
|
margin: 0 0.5rem;
|
||||||
}
|
}
|
||||||
@@ -141,7 +141,7 @@ const formatDate = (dateStr) => {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.5);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
background: #111827;
|
background: var(--bg-secondary, #111827);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -181,7 +181,7 @@ const formatDate = (dateStr) => {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
color: #fff;
|
color: var(--text-primary, #ffffff);
|
||||||
}
|
}
|
||||||
|
|
||||||
.song-artist {
|
.song-artist {
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<div v-show="active" class="content">
|
<div v-show="active" class="content">
|
||||||
<div class="year-bg">{{ data.year }}</div>
|
<div class="year-bg">{{ data.year }}</div>
|
||||||
<div class="sub-title">年度点歌报告</div>
|
<div class="sub-title">年度点歌报告</div>
|
||||||
<h1 class="main-title">VoiceHub</h1>
|
<h1 class="main-title">宝一外校园点歌系统</h1>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -31,7 +31,7 @@ defineProps({
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background: radial-gradient(circle at center, #111827, #000000);
|
background: radial-gradient(circle at center, var(--bg-secondary, #111827), #000000);
|
||||||
}
|
}
|
||||||
|
|
||||||
.blob {
|
.blob {
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ const formatPlatform = (platform) => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #111827; /* gray-900 */
|
background-color: var(--bg-secondary, #111827); /* gray-900 */
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-gradient {
|
.bg-gradient {
|
||||||
@@ -63,7 +63,7 @@ const formatPlatform = (platform) => {
|
|||||||
left: 0;
|
left: 0;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 50%;
|
height: 50%;
|
||||||
background: linear-gradient(to bottom, #1f2937, transparent);
|
background: linear-gradient(to bottom, var(--bg-secondary, #1f2937), transparent);
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,13 +56,13 @@ defineEmits(['share'])
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #000;
|
background-color: var(--bg-primary, #111111);
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-gradient {
|
.bg-gradient {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
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;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -132,8 +132,8 @@ defineEmits(['share'])
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 1rem 2rem;
|
padding: 1rem 2rem;
|
||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: #fff;
|
background: var(--card-bg, #fff);
|
||||||
color: #000;
|
color: var(--text-primary, #000);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -173,7 +173,7 @@ defineEmits(['share'])
|
|||||||
border-radius: 9999px;
|
border-radius: 9999px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
background: rgba(255, 255, 255, 0.1);
|
||||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
color: #fff;
|
color: var(--text-primary, #ffffff);
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.125rem;
|
font-size: 1.125rem;
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
|
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div class="header">
|
<div class="header">
|
||||||
<div class="logo">VoiceHub</div>
|
<div class="logo">宝一外校园点歌系统</div>
|
||||||
<div class="year">{{ data.year }}</div>
|
<div class="year">{{ data.year }}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -63,7 +63,7 @@ defineExpose({
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
min-height: 600px;
|
min-height: 600px;
|
||||||
overflow: hidden;
|
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 {
|
.card-background {
|
||||||
@@ -127,7 +127,7 @@ defineExpose({
|
|||||||
.main-title {
|
.main-title {
|
||||||
font-size: 3.5rem;
|
font-size: 3.5rem;
|
||||||
font-weight: 900;
|
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;
|
-webkit-background-clip: text;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
color: transparent;
|
color: transparent;
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ defineProps({
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
background-color: #030712; /* gray-950 */
|
background-color: var(--surface-primary, var(--bg-primary, #030712));
|
||||||
}
|
}
|
||||||
|
|
||||||
.bg-gradient {
|
.bg-gradient {
|
||||||
|
|||||||
@@ -21,7 +21,9 @@ const siteConfig = ref({
|
|||||||
icpNumber: '',
|
icpNumber: '',
|
||||||
gonganNumber: '',
|
gonganNumber: '',
|
||||||
defaultTheme: null,
|
defaultTheme: null,
|
||||||
enabledThemes: null
|
enabledThemes: null,
|
||||||
|
statisticsCode: '',
|
||||||
|
statisticsCodeEnabled: false
|
||||||
})
|
})
|
||||||
|
|
||||||
const isLoaded = ref(false)
|
const isLoaded = ref(false)
|
||||||
@@ -50,7 +52,7 @@ export const useSiteConfig = () => {
|
|||||||
siteConfig.value = {
|
siteConfig.value = {
|
||||||
siteTitle: '校园广播站点歌系统',
|
siteTitle: '校园广播站点歌系统',
|
||||||
siteLogoUrl: '/images/site-logo.png',
|
siteLogoUrl: '/images/site-logo.png',
|
||||||
schoolLogoHomeUrl: '/images/school-logo-white.png',
|
schoolLogoHomeUrl: '/images/school-logo-black.png',
|
||||||
schoolLogoPrintUrl: '/images/school-logo-transparent.png',
|
schoolLogoPrintUrl: '/images/school-logo-transparent.png',
|
||||||
siteDescription: '校园广播站点歌系统 - 让你的声音被听见',
|
siteDescription: '校园广播站点歌系统 - 让你的声音被听见',
|
||||||
siteVersion: '',
|
siteVersion: '',
|
||||||
@@ -60,8 +62,10 @@ export const useSiteConfig = () => {
|
|||||||
enableReplayRequests: false,
|
enableReplayRequests: false,
|
||||||
enableCollaborativeSubmission: true,
|
enableCollaborativeSubmission: true,
|
||||||
enableSubmissionRemarks: false,
|
enableSubmissionRemarks: false,
|
||||||
defaultTheme: 'ClassicLight',
|
defaultTheme: 'ClassicDark',
|
||||||
enabledThemes: '["ClassicLight","ModernLight"]'
|
enabledThemes: '["System","ClassicDark","ClassicLight","ModernLight"]',
|
||||||
|
statisticsCode: '',
|
||||||
|
statisticsCodeEnabled: false
|
||||||
}
|
}
|
||||||
isLoaded.value = true
|
isLoaded.value = true
|
||||||
} finally {
|
} finally {
|
||||||
@@ -77,7 +81,7 @@ export const useSiteConfig = () => {
|
|||||||
})
|
})
|
||||||
const schoolLogoHomeUrl = computed(() => {
|
const schoolLogoHomeUrl = computed(() => {
|
||||||
const raw = (siteConfig.value.schoolLogoHomeUrl || '').trim()
|
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) return fallback
|
||||||
if (raw === '/images/school-logo-white.png' || raw === '/images/school-logo-black.png') 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 enableSubmissionRemarks = computed(() => siteConfig.value.enableSubmissionRemarks === true)
|
||||||
const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled)
|
const smtpEnabled = computed(() => !!siteConfig.value.smtpEnabled)
|
||||||
|
const statisticsCode = computed(() => siteConfig.value.statisticsCode || '')
|
||||||
|
const statisticsCodeEnabled = computed(() => !!siteConfig.value.statisticsCodeEnabled)
|
||||||
|
|
||||||
function parseEnabledThemes(raw) {
|
function parseEnabledThemes(raw) {
|
||||||
if (Array.isArray(raw)) return raw.filter(Boolean)
|
if (Array.isArray(raw)) return raw.filter(Boolean)
|
||||||
@@ -158,6 +164,8 @@ export const useSiteConfig = () => {
|
|||||||
smtpEnabled,
|
smtpEnabled,
|
||||||
defaultTheme,
|
defaultTheme,
|
||||||
enabledThemes,
|
enabledThemes,
|
||||||
|
statisticsCode,
|
||||||
|
statisticsCodeEnabled,
|
||||||
fetchSiteConfig,
|
fetchSiteConfig,
|
||||||
initSiteConfig,
|
initSiteConfig,
|
||||||
refreshSiteConfig
|
refreshSiteConfig
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export const useSongs = () => {
|
|||||||
const playTimes = ref<PlayTime[]>([])
|
const playTimes = ref<PlayTime[]>([])
|
||||||
const playTimeEnabled = ref(false)
|
const playTimeEnabled = ref(false)
|
||||||
const songCount = ref(0)
|
const songCount = ref(0)
|
||||||
|
const expectedPlayDates = ref<any[]>([])
|
||||||
|
|
||||||
// 显示通知
|
// 显示通知
|
||||||
const showNotification = (message: string, type: 'success' | 'error' | 'info' = 'info') => {
|
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 () => {
|
const initialize = async () => {
|
||||||
await fetchPlayTimes()
|
await fetchPlayTimes()
|
||||||
@@ -932,6 +983,10 @@ export const useSongs = () => {
|
|||||||
publicSchedules,
|
publicSchedules,
|
||||||
visibleSongs,
|
visibleSongs,
|
||||||
songCount,
|
songCount,
|
||||||
|
expectedPlayDates,
|
||||||
|
totalFreeSlots,
|
||||||
|
openDaysCount,
|
||||||
|
expectedPlayDatesLoaded,
|
||||||
loading,
|
loading,
|
||||||
error,
|
error,
|
||||||
notification,
|
notification,
|
||||||
@@ -943,6 +998,7 @@ export const useSongs = () => {
|
|||||||
fetchPublicSchedules,
|
fetchPublicSchedules,
|
||||||
fetchPlayTimes,
|
fetchPlayTimes,
|
||||||
fetchSongCount,
|
fetchSongCount,
|
||||||
|
fetchExpectedPlayDates,
|
||||||
refreshSongsSilent,
|
refreshSongsSilent,
|
||||||
refreshSchedulesSilent,
|
refreshSchedulesSilent,
|
||||||
checkSimilarSongs,
|
checkSimilarSongs,
|
||||||
|
|||||||
@@ -99,13 +99,16 @@ export function useTheme() {
|
|||||||
|
|
||||||
const defaultTheme = computed<string>(() => {
|
const defaultTheme = computed<string>(() => {
|
||||||
if (siteConfigLoaded.value && siteDefaultTheme.value) {
|
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
|
const fromRuntime = config.public?.defaultTheme
|
||||||
if (typeof fromRuntime === 'string' && fromRuntime) {
|
if (typeof fromRuntime === 'string' && fromRuntime) {
|
||||||
|
if (fromRuntime === 'System' || fromRuntime === 'dark' || fromRuntime.endsWith('Dark')) return 'ModernLight'
|
||||||
return fromRuntime
|
return fromRuntime
|
||||||
}
|
}
|
||||||
return 'System'
|
return 'ModernLight'
|
||||||
})
|
})
|
||||||
|
|
||||||
const theme = computed<string>({
|
const theme = computed<string>({
|
||||||
@@ -193,7 +196,8 @@ export function useTheme() {
|
|||||||
|
|
||||||
if (stored) {
|
if (stored) {
|
||||||
const valid = firstValidTheme(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)
|
setTheme(stored)
|
||||||
watchSystemTheme()
|
watchSystemTheme()
|
||||||
return
|
return
|
||||||
|
|||||||
+19
-16
@@ -10,27 +10,30 @@ import {fileURLToPath} from 'url';
|
|||||||
config({ path: path.resolve(process.cwd(), '.env') });
|
config({ path: path.resolve(process.cwd(), '.env') });
|
||||||
|
|
||||||
// 检查环境变量
|
// 检查环境变量
|
||||||
if (!process.env.DATABASE_URL) {
|
// 注意:在 CI/Nuxt prerender 构建阶段,可能没有 DATABASE_URL
|
||||||
throw new Error('DATABASE_URL environment variable is not set');
|
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');
|
const isNeonDatabase = connectionString.includes('neon.tech') || connectionString.includes('neon.database.com');
|
||||||
|
|
||||||
// 根据数据库类型选择配置
|
// 检测 Serverless 部署环境
|
||||||
|
const isServerlessEnv = process.env.NETLIFY === 'true' || !!process.env.VERCEL;
|
||||||
|
|
||||||
|
// 根据数据库类型和部署环境选择配置
|
||||||
const getDatabaseConfig = () => {
|
const getDatabaseConfig = () => {
|
||||||
if (isNeonDatabase) {
|
if (isNeonDatabase || isServerlessEnv) {
|
||||||
// Neon Database Serverless 优化配置
|
// Neon Database / Serverless 环境 (Netlify/Vercel) 优化配置
|
||||||
return {
|
return {
|
||||||
max: 1, // Serverless 环境下每个实例保持最小连接数,利用 Neon 自身的连接池
|
max: 1, // Serverless 环境下每个实例保持最小连接数,利用平台自身的连接池
|
||||||
idle_timeout: 0, // 立即释放空闲连接,适应 Serverless 的快速冻结特性
|
idle_timeout: 1, // 尽快释放空闲连接,适应 Serverless 的快速冻结特性
|
||||||
connect_timeout: 10, // Neon 连接速度快,减少超时时间
|
connect_timeout: 10, // 减少连接超时时间
|
||||||
max_lifetime: 3600, // 连接最大生命周期(1小时)
|
max_lifetime: 3600, // 连接最大生命周期(1小时)
|
||||||
ssl: 'require', // Neon 默认需要 SSL
|
ssl: isNeonDatabase ? 'require' : (connectionString.includes('sslmode=require') || connectionString.includes('ssl=true') ? 'require' : false),
|
||||||
prepare: false, // 禁用预处理语句以提高兼容性
|
prepare: false, // 禁用预处理语句以提高兼容性
|
||||||
|
keep_alive: false, // 禁用 TCP keep-alive,避免 Serverless 环境下连接残留
|
||||||
transform: {
|
transform: {
|
||||||
undefined: null, // 将undefined转换为null
|
undefined: null, // 将undefined转换为null
|
||||||
},
|
},
|
||||||
@@ -41,11 +44,11 @@ const getDatabaseConfig = () => {
|
|||||||
debug: process.env.NODE_ENV === 'development' && process.env.DEBUG_SQL === 'true'
|
debug: process.env.NODE_ENV === 'development' && process.env.DEBUG_SQL === 'true'
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
// 标准 PostgreSQL 数据库配置
|
// 标准 PostgreSQL 数据库配置(本地开发 / 常规 Node Server)
|
||||||
return {
|
return {
|
||||||
max: process.env.NODE_ENV === 'production' ? 10 : 5, // 普通PostgreSQL可以支持更多连接
|
max: 10, // 普通PostgreSQL可以支持更多连接
|
||||||
idle_timeout: 20, // 增加空闲超时时间
|
idle_timeout: 20, // 空闲超时时间
|
||||||
connect_timeout: 30, // 增加连接超时时间以适应网络延迟
|
connect_timeout: 30, // 连接超时时间以适应网络延迟
|
||||||
max_lifetime: 3600, // 连接最大生命周期(1小时)
|
max_lifetime: 3600, // 连接最大生命周期(1小时)
|
||||||
ssl: connectionString.includes('sslmode=require') || connectionString.includes('ssl=true') ? 'require' : false,
|
ssl: connectionString.includes('sslmode=require') || connectionString.includes('ssl=true') ? 'require' : false,
|
||||||
prepare: 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
@@ -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';
|
import {relations, sql} from 'drizzle-orm';
|
||||||
|
|
||||||
// 枚举定义
|
// 枚举定义
|
||||||
@@ -350,12 +350,37 @@ export const systemSettings = pgTable('SystemSettings', {
|
|||||||
autoBackupConfig: text('autoBackupConfig'),
|
autoBackupConfig: text('autoBackupConfig'),
|
||||||
statisticsCode: text('statisticsCode'),
|
statisticsCode: text('statisticsCode'),
|
||||||
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
|
statisticsCodeEnabled: boolean('statisticsCodeEnabled').default(false).notNull(),
|
||||||
defaultTheme: text('defaultTheme').default('ClassicLight').notNull(),
|
defaultTheme: text('defaultTheme').default('ClassicDark').notNull(),
|
||||||
enabledThemes: text('enabledThemes').default('[\"ClassicLight\",\"ModernLight\"]').notNull(),
|
enabledThemes: text('enabledThemes').default('[\"System\",\"ClassicDark\",\"ClassicLight\",\"ModernLight\"]').notNull(),
|
||||||
enabledPlatforms: text('enabledPlatforms'),
|
enabledPlatforms: text('enabledPlatforms'),
|
||||||
platformOrder: text('platformOrder'),
|
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', {
|
export const songBlacklists = pgTable('SongBlacklist', {
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
@@ -595,6 +620,41 @@ export const userIdentities = pgTable('UserIdentity', {
|
|||||||
unq: unique().on(t.provider, t.providerUserId),
|
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', {
|
export const changelogEntries = pgTable('ChangelogEntry', {
|
||||||
id: serial('id').primaryKey(),
|
id: serial('id').primaryKey(),
|
||||||
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
createdAt: timestamp('createdAt').defaultNow().notNull(),
|
||||||
|
|||||||
@@ -133,7 +133,7 @@ const { showToast } = useToast()
|
|||||||
const config = useRuntimeConfig()
|
const config = useRuntimeConfig()
|
||||||
|
|
||||||
const hasOAuthProviders = computed(() => {
|
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)
|
const avatarError = ref(false)
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div class="info-content">
|
<div class="info-content">
|
||||||
<div class="logo-section">
|
<div class="logo-section">
|
||||||
<img
|
<img
|
||||||
alt="VoiceHub Logo"
|
alt="宝一外校园点歌系统 Logo"
|
||||||
class="brand-logo"
|
class="brand-logo"
|
||||||
:src="logo"
|
:src="logo"
|
||||||
width="160"
|
width="160"
|
||||||
@@ -14,11 +14,11 @@
|
|||||||
decoding="async"
|
decoding="async"
|
||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
>
|
>
|
||||||
<h1 v-if="siteTitle" class="brand-title">{{ siteTitle || 'VoiceHub' }}</h1>
|
<h1 v-if="siteTitle" class="brand-title">宝一外校园点歌系统</h1>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="isFirstLogin" class="welcome-message">
|
<div v-if="isFirstLogin" class="welcome-message">
|
||||||
<h2>欢迎使用VoiceHub</h2>
|
<h2>欢迎使用宝一外校园点歌系统</h2>
|
||||||
<p>为了保障您的账号安全,请设置一个新的密码</p>
|
<p>为了保障您的账号安全,请设置一个新的密码</p>
|
||||||
</div>
|
</div>
|
||||||
<div v-else class="security-message">
|
<div v-else class="security-message">
|
||||||
@@ -132,7 +132,7 @@ onMounted(async () => {
|
|||||||
<style scoped>
|
<style scoped>
|
||||||
.auth-layout {
|
.auth-layout {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0a0a0a;
|
background: var(--bg-primary, #ffffff);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
@@ -142,13 +142,14 @@ onMounted(async () => {
|
|||||||
.auth-container {
|
.auth-container {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
background: #111111;
|
background: var(--surface-primary, var(--bg-primary, #ffffff));
|
||||||
border-radius: 24px;
|
border-radius: 24px;
|
||||||
border: 1px solid #1f1f1f;
|
border: 1px solid var(--border-primary, #e5e7eb);
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: 1fr 1fr;
|
||||||
min-height: 600px;
|
min-height: 600px;
|
||||||
|
box-shadow: 0 10px 40px -10px rgba(15, 23, 42, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.info-section {
|
.info-section {
|
||||||
@@ -194,7 +195,7 @@ onMounted(async () => {
|
|||||||
font-size: 36px;
|
font-size: 36px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
margin: 0;
|
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-background-clip: text;
|
||||||
-webkit-text-fill-color: transparent;
|
-webkit-text-fill-color: transparent;
|
||||||
background-clip: text;
|
background-clip: text;
|
||||||
@@ -266,7 +267,7 @@ onMounted(async () => {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background: #111111;
|
background: var(--surface-primary, var(--bg-primary, #ffffff));
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-container {
|
.form-container {
|
||||||
@@ -285,13 +286,13 @@ onMounted(async () => {
|
|||||||
.form-header h2 {
|
.form-header h2 {
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #ffffff;
|
color: var(--text-primary, #1a2332);
|
||||||
margin: 0 0 8px 0;
|
margin: 0 0 8px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-header p {
|
.form-header p {
|
||||||
font-size: 16px;
|
font-size: 16px;
|
||||||
color: #888888;
|
color: var(--text-secondary, #4a5568);
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,10 +306,10 @@ onMounted(async () => {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 12px 20px;
|
padding: 12px 20px;
|
||||||
background: #1a1a1a;
|
background: var(--surface-secondary, var(--bg-secondary, #f7fafc));
|
||||||
border: 1px solid #2a2a2a;
|
border: 1px solid var(--border-primary, #e5e7eb);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: #cccccc;
|
color: var(--text-secondary, #4a5568);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
@@ -316,9 +317,9 @@ onMounted(async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.back-link:hover {
|
.back-link:hover {
|
||||||
background: #2a2a2a;
|
background: var(--surface-tertiary, var(--bg-tertiary, #edf2f7));
|
||||||
color: #ffffff;
|
color: var(--text-primary, #1a2332);
|
||||||
border-color: #3a3a3a;
|
border-color: var(--border-secondary, #cbd5e1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.back-link svg {
|
.back-link svg {
|
||||||
|
|||||||
+13
-13
@@ -23,14 +23,14 @@
|
|||||||
|
|
||||||
<!-- 主内容区域 -->
|
<!-- 主内容区域 -->
|
||||||
<main
|
<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
|
<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">
|
<div class="flex items-center gap-3">
|
||||||
<button
|
<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"
|
@click="toggleSidebar"
|
||||||
>
|
>
|
||||||
<Menu :size="20" />
|
<Menu :size="20" />
|
||||||
@@ -39,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
<button
|
<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"
|
@click="openOnboardingTutorial"
|
||||||
>
|
>
|
||||||
新手引导
|
新手引导
|
||||||
@@ -102,12 +102,12 @@
|
|||||||
<LazyAdminSchedulePrinter />
|
<LazyAdminSchedulePrinter />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 歌单卡片图片 -->
|
<!-- 导入酷狗:账号扫码绑定 + 按排期自动创建/同步酷狗歌单 -->
|
||||||
<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"
|
class="animate-in fade-in slide-in-from-bottom-4 duration-500 h-full"
|
||||||
>
|
>
|
||||||
<LazyAdminPlaylistCardPage />
|
<LazyAdminKuGouImporter />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 数据分析 -->
|
<!-- 数据分析 -->
|
||||||
@@ -319,7 +319,7 @@ const getPageTitle = () => {
|
|||||||
songs: '歌曲管理',
|
songs: '歌曲管理',
|
||||||
schedule: '排期管理',
|
schedule: '排期管理',
|
||||||
print: '打印排期',
|
print: '打印排期',
|
||||||
'playlist-card': '图片歌单',
|
'playlist-card': '导入酷狗',
|
||||||
users: '用户管理',
|
users: '用户管理',
|
||||||
blacklist: '黑名单管理',
|
blacklist: '黑名单管理',
|
||||||
'api-keys': 'API密钥管理',
|
'api-keys': 'API密钥管理',
|
||||||
@@ -343,7 +343,7 @@ const dynamicTitle = computed(() => {
|
|||||||
if (siteTitle && siteTitle.value) {
|
if (siteTitle && siteTitle.value) {
|
||||||
return `${currentPageTitle} | ${siteTitle.value}`
|
return `${currentPageTitle} | ${siteTitle.value}`
|
||||||
}
|
}
|
||||||
return `${currentPageTitle} | 校园广播站点歌系统`
|
return `${currentPageTitle} | 宝一外校园点歌系统`
|
||||||
})
|
})
|
||||||
|
|
||||||
// 监听activeTab变化,更新页面标题
|
// 监听activeTab变化,更新页面标题
|
||||||
@@ -548,8 +548,8 @@ onUnmounted(() => {
|
|||||||
.admin-layout {
|
.admin-layout {
|
||||||
display: flex;
|
display: flex;
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
background: #0a0a0a;
|
background: var(--bg-primary, #ffffff);
|
||||||
color: #ffffff;
|
color: var(--text-primary, #1a2332);
|
||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -561,11 +561,11 @@ onUnmounted(() => {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||||
background: #27272a;
|
background: var(--border-primary, #cbd5e1);
|
||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
}
|
}
|
||||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||||
background: #3f3f46;
|
background: var(--border-secondary, #94a3b8);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -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
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -25,7 +25,7 @@
|
|||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="form-title">{{ siteTitle ? siteTitle + ' | VoiceHub' : 'VoiceHub' }}</h1>
|
<h1 class="form-title">宝一外校园点歌系统</h1>
|
||||||
<div class="header-divider" />
|
<div class="header-divider" />
|
||||||
</div>
|
</div>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
@@ -312,7 +312,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
.icp-link:hover,
|
.icp-link:hover,
|
||||||
.voicehub-link:hover {
|
.voicehub-link:hover {
|
||||||
color: rgba(255, 255, 255, 0.8);
|
color: var(--text-secondary);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
|
|||||||
@@ -1,19 +1,19 @@
|
|||||||
<template>
|
<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
|
<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="max-w-[1000px] mx-auto flex items-center justify-between">
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-4">
|
||||||
<button
|
<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"
|
@click="goBack"
|
||||||
>
|
>
|
||||||
<ArrowLeft :size="20" />
|
<ArrowLeft :size="20" />
|
||||||
</button>
|
</button>
|
||||||
<div>
|
<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">
|
<p class="text-[10px] text-zinc-500 font-medium uppercase tracking-widest mt-0.5">
|
||||||
Notification Settings
|
Notification Settings
|
||||||
</p>
|
</p>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
|
|
||||||
<button
|
<button
|
||||||
:disabled="loading || saving"
|
: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"
|
@click="saveSettings"
|
||||||
>
|
>
|
||||||
<template v-if="saving"> <Loader2 :size="14" class="animate-spin" /> 保存中... </template>
|
<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 class="max-w-[1000px] mx-auto px-4">
|
||||||
<div v-if="loading" class="flex flex-col items-center justify-center py-32">
|
<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>
|
<p class="text-zinc-500 text-sm font-medium">加载设置中...</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else class="space-y-8">
|
<div v-else class="space-y-8">
|
||||||
<!-- 站内通知设置 -->
|
<!-- 站内通知设置 -->
|
||||||
<section :class="sectionClass">
|
<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-blue-500/10 rounded-xl">
|
<div class="p-2.5 bg-indigo-500/10 rounded-xl">
|
||||||
<Bell :size="20" class="text-blue-500" />
|
<Bell :size="20" class="text-indigo-600" />
|
||||||
</div>
|
</div>
|
||||||
<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>
|
<p class="text-xs text-zinc-500 mt-0.5">配置您希望在系统内接收的消息类型</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -54,14 +54,14 @@
|
|||||||
<!-- 歌曲被选中消息 -->
|
<!-- 歌曲被选中消息 -->
|
||||||
<div :class="itemClass">
|
<div :class="itemClass">
|
||||||
<div class="flex-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>
|
<p class="text-[11px] text-zinc-500 mt-1">当您投稿的歌曲被选中播放时通知您</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<input
|
<input
|
||||||
v-model="localSettings.songSelectedNotify"
|
v-model="localSettings.songSelectedNotify"
|
||||||
type="checkbox"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -69,14 +69,14 @@
|
|||||||
<!-- 歌曲已播放消息 -->
|
<!-- 歌曲已播放消息 -->
|
||||||
<div :class="itemClass">
|
<div :class="itemClass">
|
||||||
<div class="flex-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>
|
<p class="text-[11px] text-zinc-500 mt-1">当您投稿的歌曲播放完成时通知您</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<input
|
<input
|
||||||
v-model="localSettings.songPlayedNotify"
|
v-model="localSettings.songPlayedNotify"
|
||||||
type="checkbox"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -84,14 +84,14 @@
|
|||||||
<!-- 歌曲获得投票消息 -->
|
<!-- 歌曲获得投票消息 -->
|
||||||
<div :class="itemClass">
|
<div :class="itemClass">
|
||||||
<div class="flex-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>
|
<p class="text-[11px] text-zinc-500 mt-1">当您投稿的歌曲获得投票时通知您</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<input
|
<input
|
||||||
v-model="localSettings.songVotedNotify"
|
v-model="localSettings.songVotedNotify"
|
||||||
type="checkbox"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -99,14 +99,14 @@
|
|||||||
<!-- 系统通知 -->
|
<!-- 系统通知 -->
|
||||||
<div :class="itemClass">
|
<div :class="itemClass">
|
||||||
<div class="flex-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>
|
<p class="text-[11px] text-zinc-500 mt-1">接收系统重要通知和公告</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="shrink-0">
|
<div class="shrink-0">
|
||||||
<input
|
<input
|
||||||
v-model="localSettings.systemNotify"
|
v-model="localSettings.systemNotify"
|
||||||
type="checkbox"
|
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>
|
||||||
</div>
|
</div>
|
||||||
@@ -114,7 +114,7 @@
|
|||||||
<!-- 投票阈值设置 -->
|
<!-- 投票阈值设置 -->
|
||||||
<div :class="[itemClass, 'md:col-span-1']">
|
<div :class="[itemClass, 'md:col-span-1']">
|
||||||
<div class="flex-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>
|
<p class="text-[11px] text-zinc-500 mt-1">当投票数达到此阈值时才发送通知</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -123,16 +123,16 @@
|
|||||||
type="number"
|
type="number"
|
||||||
max="100"
|
max="100"
|
||||||
min="1"
|
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>
|
</div>
|
||||||
|
|
||||||
<!-- 通知刷新间隔 -->
|
<!-- 通知刷新间隔 -->
|
||||||
<div :class="[itemClass, 'md:col-span-1']">
|
<div :class="[itemClass, 'md:col-span-1']">
|
||||||
<div class="flex-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>
|
<p class="text-[11px] text-zinc-500 mt-1">设置系统自动检查新通知的时间间隔</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
@@ -142,9 +142,9 @@
|
|||||||
max="300"
|
max="300"
|
||||||
min="30"
|
min="30"
|
||||||
step="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
|
>{{ localSettings.refreshInterval }}s</span
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
@@ -154,12 +154,12 @@
|
|||||||
|
|
||||||
<!-- 社交账号绑定 -->
|
<!-- 社交账号绑定 -->
|
||||||
<section :class="sectionClass">
|
<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">
|
<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>
|
||||||
<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>
|
<p class="text-xs text-zinc-500 mt-0.5">绑定您的社交账号以接收实时推送通知</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -168,14 +168,14 @@
|
|||||||
<!-- 邮箱绑定 -->
|
<!-- 邮箱绑定 -->
|
||||||
<div v-if="smtpEnabled" :class="cardClass">
|
<div v-if="smtpEnabled" :class="cardClass">
|
||||||
<div class="flex items-center gap-3 mb-4">
|
<div class="flex items-center gap-3 mb-4">
|
||||||
<div class="p-2 bg-zinc-950 rounded-lg border border-zinc-800">
|
<div class="p-2 bg-white rounded-lg border border-zinc-200">
|
||||||
<Mail :size="16" class="text-zinc-400" />
|
<Mail :size="16" class="text-zinc-500" />
|
||||||
</div>
|
</div>
|
||||||
<h3 class="text-sm font-bold text-zinc-200">邮箱消息通知</h3>
|
<h3 class="text-sm font-bold text-zinc-800">邮箱消息通知</h3>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p
|
<p
|
||||||
@@ -183,14 +183,14 @@
|
|||||||
>
|
>
|
||||||
当前绑定邮箱
|
当前绑定邮箱
|
||||||
</p>
|
</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>
|
||||||
<div
|
<div
|
||||||
:class="[
|
:class="[
|
||||||
'px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider',
|
'px-2 py-0.5 rounded-full text-[10px] font-black uppercase tracking-wider',
|
||||||
emailVerified
|
emailVerified
|
||||||
? 'bg-emerald-500/10 text-emerald-500'
|
? 'bg-emerald-500/10 text-emerald-600'
|
||||||
: 'bg-amber-500/10 text-amber-500'
|
: 'bg-amber-500/10 text-amber-600'
|
||||||
]"
|
]"
|
||||||
>
|
>
|
||||||
{{ emailVerified ? '已验证' : '待验证' }}
|
{{ emailVerified ? '已验证' : '待验证' }}
|
||||||
@@ -207,11 +207,11 @@
|
|||||||
:disabled="bindingEmail"
|
:disabled="bindingEmail"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="请输入邮箱地址"
|
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
|
<button
|
||||||
:disabled="!newEmail || bindingEmail"
|
: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"
|
@click="bindEmail"
|
||||||
>
|
>
|
||||||
{{ bindingEmail ? '请稍候...' : '立即绑定' }}
|
{{ bindingEmail ? '请稍候...' : '立即绑定' }}
|
||||||
@@ -222,10 +222,10 @@
|
|||||||
<!-- 待验证状态 -->
|
<!-- 待验证状态 -->
|
||||||
<div v-else-if="!emailVerified" class="space-y-4 pt-2">
|
<div v-else-if="!emailVerified" class="space-y-4 pt-2">
|
||||||
<div
|
<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" />
|
<AlertCircle :size="14" class="text-indigo-600 shrink-0 mt-0.5" />
|
||||||
<p class="text-[11px] text-zinc-500 leading-relaxed">
|
<p class="text-[11px] text-zinc-600 leading-relaxed">
|
||||||
验证码已发送至您的邮箱,请在 5 分钟内完成验证。若未收到邮件,请检查垃圾箱。
|
验证码已发送至您的邮箱,请在 5 分钟内完成验证。若未收到邮件,请检查垃圾箱。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -237,10 +237,10 @@
|
|||||||
maxlength="6"
|
maxlength="6"
|
||||||
placeholder="输入 6 位数字验证码"
|
placeholder="输入 6 位数字验证码"
|
||||||
:class="[
|
: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
|
emailCodeError
|
||||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
? '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"
|
@input="handleEmailCodeInput"
|
||||||
@keydown="handleEmailCodeKeydown"
|
@keydown="handleEmailCodeKeydown"
|
||||||
@@ -248,21 +248,21 @@
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
:disabled="bindingEmail || emailCode.length !== 6"
|
: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"
|
@click="verifyEmailCode"
|
||||||
>
|
>
|
||||||
{{ bindingEmail ? '验证中...' : '确认验证' }}
|
{{ bindingEmail ? '验证中...' : '确认验证' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="resendingEmail"
|
: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"
|
@click="resendVerificationEmail"
|
||||||
>
|
>
|
||||||
{{ resendingEmail ? '发送中...' : '重新发送' }}
|
{{ resendingEmail ? '发送中...' : '重新发送' }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<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"
|
@click="changeEmail"
|
||||||
>
|
>
|
||||||
更换邮箱地址
|
更换邮箱地址
|
||||||
@@ -273,14 +273,14 @@
|
|||||||
<!-- 已验证状态 -->
|
<!-- 已验证状态 -->
|
||||||
<div v-else class="flex gap-2 pt-2">
|
<div v-else class="flex gap-2 pt-2">
|
||||||
<button
|
<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"
|
@click="changeEmail"
|
||||||
>
|
>
|
||||||
更换邮箱
|
更换邮箱
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="unbindingEmail"
|
: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"
|
@click="unbindEmail"
|
||||||
>
|
>
|
||||||
{{ unbindingEmail ? '正在解绑...' : '解绑邮箱' }}
|
{{ unbindingEmail ? '正在解绑...' : '解绑邮箱' }}
|
||||||
@@ -290,34 +290,34 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- MeoW 账号绑定 -->
|
<!-- 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 justify-between mb-4">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex items-center gap-3">
|
||||||
<div class="p-2 bg-blue-500/10 rounded-lg border border-blue-500/20">
|
<div class="p-2 bg-indigo-500/10 rounded-lg border border-indigo-500/20">
|
||||||
<Smartphone :size="16" class="text-blue-500" />
|
<Smartphone :size="16" class="text-indigo-600" />
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div
|
<div
|
||||||
v-if="localSettings.meowUserId"
|
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 class="flex items-center justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p
|
<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
|
当前绑定 ID
|
||||||
</p>
|
</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 }}
|
{{ localSettings.meowUserId }}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<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>
|
</div>
|
||||||
@@ -337,11 +337,11 @@
|
|||||||
:disabled="binding"
|
:disabled="binding"
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="请输入 MeoW 用户 ID"
|
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
|
<button
|
||||||
:disabled="!meowUserId || binding"
|
: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"
|
@click="sendVerificationCode"
|
||||||
>
|
>
|
||||||
{{ binding ? '发送中...' : '发送验证码' }}
|
{{ binding ? '发送中...' : '发送验证码' }}
|
||||||
@@ -351,12 +351,12 @@
|
|||||||
<!-- 第二步:输入验证码 -->
|
<!-- 第二步:输入验证码 -->
|
||||||
<div v-else class="space-y-4">
|
<div v-else class="space-y-4">
|
||||||
<div
|
<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" />
|
<AlertCircle :size="14" class="text-indigo-600 shrink-0 mt-0.5" />
|
||||||
<p class="text-[11px] text-zinc-500 leading-relaxed">
|
<p class="text-[11px] text-zinc-600 leading-relaxed">
|
||||||
验证码已发送至 MeoW ID:
|
验证码已发送至 MeoW ID:
|
||||||
<span class="font-bold text-zinc-200">{{ meowUserId }}</span
|
<span class="font-bold text-zinc-800">{{ meowUserId }}</span
|
||||||
>,请在客户端查收。
|
>,请在客户端查收。
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
@@ -368,10 +368,10 @@
|
|||||||
maxlength="6"
|
maxlength="6"
|
||||||
placeholder="输入 6 位验证码"
|
placeholder="输入 6 位验证码"
|
||||||
:class="[
|
: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
|
verificationCodeError
|
||||||
? 'border-rose-500 shadow-[0_0_15px_rgba(244,63,94,0.1)]'
|
? '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"
|
@input="handleVerificationCodeInput"
|
||||||
@keydown="handleVerificationCodeKeydown"
|
@keydown="handleVerificationCodeKeydown"
|
||||||
@@ -379,14 +379,14 @@
|
|||||||
<div class="grid grid-cols-2 gap-2">
|
<div class="grid grid-cols-2 gap-2">
|
||||||
<button
|
<button
|
||||||
:disabled="binding || verificationCode.length !== 6"
|
: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"
|
@click="verifyAndBind"
|
||||||
>
|
>
|
||||||
{{ binding ? '验证中...' : '确认绑定' }}
|
{{ binding ? '验证中...' : '确认绑定' }}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
:disabled="binding"
|
: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"
|
@click="cancelVerification"
|
||||||
>
|
>
|
||||||
取消
|
取消
|
||||||
@@ -399,7 +399,7 @@
|
|||||||
<!-- 已绑定状态 -->
|
<!-- 已绑定状态 -->
|
||||||
<div v-else class="pt-2">
|
<div v-else class="pt-2">
|
||||||
<button
|
<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"
|
@click="showUnbindConfirm"
|
||||||
>
|
>
|
||||||
解除 MeoW 账号绑定
|
解除 MeoW 账号绑定
|
||||||
@@ -452,11 +452,11 @@ const { siteTitle, smtpEnabled, initSiteConfig } = useSiteConfig()
|
|||||||
const { showToast } = useToast()
|
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 =
|
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 =
|
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)
|
const loading = ref(true)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
fetchpriority="high"
|
fetchpriority="high"
|
||||||
>
|
>
|
||||||
</div>
|
</div>
|
||||||
<h1 class="form-title">{{ siteTitle ? siteTitle + ' | VoiceHub' : 'VoiceHub' }}</h1>
|
<h1 class="form-title">宝一外校园点歌系统</h1>
|
||||||
<div class="header-divider" />
|
<div class="header-divider" />
|
||||||
</div>
|
</div>
|
||||||
<ClientOnly>
|
<ClientOnly>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="review-page">
|
<div class="review-page">
|
||||||
<Head>
|
<Head>
|
||||||
<Title>我的年度回顾 - VoiceHub</Title>
|
<Title>我的年度回顾 - 宝一外校园点歌系统</Title>
|
||||||
<Meta name="theme-color" content="#000000" />
|
<Meta name="theme-color" content="#000000" />
|
||||||
<Meta
|
<Meta
|
||||||
name="viewport"
|
name="viewport"
|
||||||
@@ -211,8 +211,8 @@ const handleTouchEnd = (e) => {
|
|||||||
.review-page {
|
.review-page {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background-color: #000;
|
background-color: var(--bg-primary, #111111);
|
||||||
color: #fff;
|
color: var(--text-primary, #ffffff);
|
||||||
z-index: 9999;
|
z-index: 9999;
|
||||||
font-family:
|
font-family:
|
||||||
'MiSans',
|
'MiSans',
|
||||||
@@ -230,7 +230,7 @@ const handleTouchEnd = (e) => {
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
background-color: #000;
|
background-color: var(--bg-primary, #111111);
|
||||||
z-index: 50;
|
z-index: 50;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -285,7 +285,7 @@ const handleTouchEnd = (e) => {
|
|||||||
.empty .empty-icon-circle {
|
.empty .empty-icon-circle {
|
||||||
width: 5rem;
|
width: 5rem;
|
||||||
height: 5rem;
|
height: 5rem;
|
||||||
background: #111827;
|
background: var(--bg-secondary, #111827);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -364,7 +364,7 @@ const handleTouchEnd = (e) => {
|
|||||||
|
|
||||||
.indicator-dot.active {
|
.indicator-dot.active {
|
||||||
height: 1.5rem;
|
height: 1.5rem;
|
||||||
background: #fff;
|
background: var(--card-bg, #fff);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 滚动提示 */
|
/* 滚动提示 */
|
||||||
|
|||||||
+2
-1
@@ -2,7 +2,8 @@ export const getProviderDisplayName = (provider: string): string => {
|
|||||||
const map: Record<string, string> = {
|
const map: Record<string, string> = {
|
||||||
github: 'GitHub',
|
github: 'GitHub',
|
||||||
casdoor: 'Casdoor',
|
casdoor: 'Casdoor',
|
||||||
google: 'Google'
|
google: 'Google',
|
||||||
|
aggregate: '微信'
|
||||||
}
|
}
|
||||||
return map[provider.toLowerCase()] || provider.charAt(0).toUpperCase() + provider.slice(1)
|
return map[provider.toLowerCase()] || provider.charAt(0).toUpperCase() + provider.slice(1)
|
||||||
}
|
}
|
||||||
|
|||||||
+28
-33
@@ -133,7 +133,7 @@ export default defineNuxtConfig({
|
|||||||
github: !!process.env.GITHUB_CLIENT_ID,
|
github: !!process.env.GITHUB_CLIENT_ID,
|
||||||
casdoor: !!process.env.CASDOOR_CLIENT_ID,
|
casdoor: !!process.env.CASDOOR_CLIENT_ID,
|
||||||
google: !!process.env.GOOGLE_CLIENT_ID,
|
google: !!process.env.GOOGLE_CLIENT_ID,
|
||||||
aggregate: !!process.env.AGGREGATE_OAUTH_APP_ID
|
aggregate: false
|
||||||
},
|
},
|
||||||
siteTitle: process.env.NUXT_PUBLIC_SITE_TITLE || '校园广播站点歌系统',
|
siteTitle: process.env.NUXT_PUBLIC_SITE_TITLE || '校园广播站点歌系统',
|
||||||
siteLogo: process.env.NUXT_PUBLIC_SITE_LOGO || '',
|
siteLogo: process.env.NUXT_PUBLIC_SITE_LOGO || '',
|
||||||
@@ -156,8 +156,8 @@ export default defineNuxtConfig({
|
|||||||
themeColor: process.env.PWA_THEME_COLOR || '#111111'
|
themeColor: process.env.PWA_THEME_COLOR || '#111111'
|
||||||
},
|
},
|
||||||
// 主题配置
|
// 主题配置
|
||||||
defaultTheme: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'ClassicLight',
|
defaultTheme: process.env.NUXT_PUBLIC_DEFAULT_THEME || 'ClassicDark',
|
||||||
enabledThemes: (process.env.NUXT_PUBLIC_ENABLED_THEMES || 'ClassicLight,ModernLight')
|
enabledThemes: (process.env.NUXT_PUBLIC_ENABLED_THEMES || 'System,ClassicDark,ClassicLight,ModernLight')
|
||||||
.split(',')
|
.split(',')
|
||||||
.map((s) => s.trim())
|
.map((s) => s.trim())
|
||||||
.filter(Boolean),
|
.filter(Boolean),
|
||||||
@@ -192,7 +192,7 @@ export default defineNuxtConfig({
|
|||||||
{ name: 'theme-color', content: '#111111' },
|
{ name: 'theme-color', content: '#111111' },
|
||||||
{
|
{
|
||||||
name: 'x-default-theme',
|
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-capable', content: 'yes' },
|
||||||
{ name: 'apple-mobile-web-app-status-bar-style', content: 'black-translucent' },
|
{ 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"]');`,
|
` var meta=document.querySelector('meta[name="x-default-theme"]');`,
|
||||||
` if(meta && meta.content){s=meta.content}`,
|
` if(meta && meta.content){s=meta.content}`,
|
||||||
` }catch(e){s=null}`,
|
` }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'}`,
|
` 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)}`,
|
` 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: {
|
nitro: {
|
||||||
compressPublicAssets: true,
|
compressPublicAssets: false, // 禁用公共资产压缩,防止构建时内存溢出
|
||||||
preset: process.env.VERCEL
|
preset: process.env.VERCEL
|
||||||
? 'vercel'
|
? 'vercel'
|
||||||
: process.env.NETLIFY
|
: process.env.NETLIFY
|
||||||
? 'netlify'
|
? 'netlify'
|
||||||
: process.env.NITRO_PRESET || 'node-server',
|
: process.env.NITRO_PRESET || 'node-server',
|
||||||
|
// 明确关闭 prerender / 爬取链接,避免 Node 端不必要的预渲染浪费内存和依赖数据库环境
|
||||||
|
prerender: {
|
||||||
|
crawlLinks: false,
|
||||||
|
routes: []
|
||||||
|
},
|
||||||
|
minify: false, // 【绝对不要设为 true】服务端代码压缩会产生极高内存峰值,导致 Linux 环境 OOM 137
|
||||||
|
sourceMap: false,
|
||||||
// 增强错误处理和稳定性
|
// 增强错误处理和稳定性
|
||||||
experimental: {
|
experimental: {
|
||||||
wasm: true,
|
wasm: true,
|
||||||
asyncContext: true
|
asyncContext: true
|
||||||
},
|
},
|
||||||
|
// 强制把巨型 Node native 库外置,防止 Nitro 打包时撑爆内存 (OOM 137)
|
||||||
|
externals: {
|
||||||
|
external: [
|
||||||
|
'drizzle-orm',
|
||||||
|
'postgres'
|
||||||
|
]
|
||||||
|
},
|
||||||
timing: true,
|
timing: true,
|
||||||
// 增加请求超时时间
|
// 增加请求超时时间
|
||||||
routeRules: {
|
routeRules: {
|
||||||
@@ -388,15 +402,12 @@ export default defineNuxtConfig({
|
|||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 2000,
|
chunkSizeWarningLimit: 2000,
|
||||||
cssCodeSplit: true,
|
cssCodeSplit: true,
|
||||||
|
sourcemap: false,
|
||||||
|
target: 'esnext',
|
||||||
|
reportCompressedSize: false,
|
||||||
rollupOptions: {
|
rollupOptions: {
|
||||||
maxParallelFileOps: 2,
|
maxParallelFileOps: 1,
|
||||||
output: {
|
cache: false
|
||||||
manualChunks(id) {
|
|
||||||
if (id.includes('node_modules/lucide-vue-next')) {
|
|
||||||
return 'lucide'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
@@ -418,30 +429,14 @@ export default defineNuxtConfig({
|
|||||||
include: ['drizzle-orm'],
|
include: ['drizzle-orm'],
|
||||||
exclude: ['@applemusic-like-lyrics/vue', '@applemusic-like-lyrics/lyric']
|
exclude: ['@applemusic-like-lyrics/vue', '@applemusic-like-lyrics/lyric']
|
||||||
},
|
},
|
||||||
build: {
|
// SSR配置: Vercel/Netlify serverless 需要 noExternal 把 drizzle/postgres inline;
|
||||||
sourcemap: false,
|
// Node-server 环境直接从 node_modules require 即可, 避免打包 inline 导致 OOM
|
||||||
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: {
|
ssr: {
|
||||||
noExternal: process.env.NETLIFY
|
noExternal: process.env.NETLIFY
|
||||||
? ['drizzle-orm', 'postgres']
|
? ['drizzle-orm', 'postgres']
|
||||||
: process.env.VERCEL
|
: process.env.VERCEL
|
||||||
? []
|
? []
|
||||||
: ['drizzle-orm', 'postgres']
|
: []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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-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">
|
<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-100">鍥剧墖姝屽崟</h2>
|
||||||
<p class="text-sm text-zinc-500">选择日期生成广播排期卡片并分享</p>
|
<p class="text-sm text-zinc-500">閫夋嫨鏃ユ湡鐢熸垚骞挎挱鎺掓湡鍗$墖骞跺垎浜?/p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-8">
|
<div class="flex flex-col gap-8">
|
||||||
<!-- 设置面板 -->
|
<!-- 璁剧疆闈㈡澘 -->
|
||||||
<div class="w-full flex flex-col gap-6">
|
<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">
|
<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">
|
<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>
|
</h3>
|
||||||
|
|
||||||
<div class="flex flex-col gap-5 items-end">
|
<div class="flex flex-col gap-5 items-end">
|
||||||
<!-- 日期选择 -->
|
<!-- 鏃ユ湡閫夋嫨 -->
|
||||||
<div class="space-y-2 flex-1 w-full">
|
<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
|
<AdminDatePicker
|
||||||
:modelValue="selectedDate"
|
:modelValue="selectedDate"
|
||||||
@update:modelValue="selectedDate = $event"
|
@update:modelValue="selectedDate = $event"
|
||||||
@@ -32,16 +32,16 @@
|
|||||||
@click="showModal = true"
|
@click="showModal = true"
|
||||||
>
|
>
|
||||||
<ImageIcon class="w-4 h-4" />
|
<ImageIcon class="w-4 h-4" />
|
||||||
{{ loading ? '加载中...' : (schedules.length === 0 ? '该日期无排期' : '生成卡片') }}
|
{{ loading ? '鍔犺浇涓?..' : (schedules.length === 0 ? '璇ユ棩鏈熸棤鎺掓湡' : '鐢熸垚鍗$墖') }}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 预览区 -->
|
<!-- 棰勮鍖?-->
|
||||||
<div class="w-full flex flex-col gap-4">
|
<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">
|
<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 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">
|
<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>
|
<span class="text-blue-500 font-bold w-6">{{ index + 1 }}.</span>
|
||||||
@@ -54,7 +54,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="!loading" class="flex flex-col items-center justify-center py-20 bg-zinc-900/20 border border-zinc-800/50 rounded-xl border-dashed">
|
<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" />
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
<AdminPlaylistCardGenerator
|
<AdminPlaylistCardGenerator
|
||||||
:show="showModal"
|
:show="showModal"
|
||||||
:songs="schedules"
|
:songs="schedules"
|
||||||
:default-title="`${selectedDate} 校园广播歌单`"
|
:default-title="`${selectedDate} 鏍″洯骞挎挱姝屽崟`"
|
||||||
@close="showModal = false"
|
@close="showModal = false"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -100,7 +100,7 @@ const fetchSchedules = async () => {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to fetch schedules:', error)
|
console.error('Failed to fetch schedules:', error)
|
||||||
if (window.$showNotification) {
|
if (window.$showNotification) {
|
||||||
window.$showNotification('获取排期失败', 'error')
|
window.$showNotification('鑾峰彇鎺掓湡澶辫触', 'error')
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
Generated
+85
-1
@@ -84,7 +84,8 @@
|
|||||||
"@nuxt/eslint": "^1.17.0",
|
"@nuxt/eslint": "^1.17.0",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"prettier": "^3.9.6",
|
"prettier": "^3.9.6",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vue-tsc": "^3.3.11"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=22.11.0"
|
"node": ">=22.11.0"
|
||||||
@@ -7925,6 +7926,35 @@
|
|||||||
"vue": "^3.0.0"
|
"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": {
|
"node_modules/@vue-macros/common": {
|
||||||
"version": "3.1.4",
|
"version": "3.1.4",
|
||||||
"resolved": "https://registry.npmmirror.com/@vue-macros/common/-/common-3.1.4.tgz",
|
"resolved": "https://registry.npmmirror.com/@vue-macros/common/-/common-3.1.4.tgz",
|
||||||
@@ -8104,6 +8134,22 @@
|
|||||||
"integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==",
|
"integrity": "sha512-Fkac7lUdGReh6pVOi3AYPRGe82LQqRmAfThW7RRligOAP0ZA/Z1z9XLHDM9dv34pV2HRc79DK8uKPeG2fLnA/g==",
|
||||||
"license": "MIT"
|
"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": {
|
"node_modules/@vue/reactivity": {
|
||||||
"version": "3.5.42",
|
"version": "3.5.42",
|
||||||
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz",
|
"resolved": "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.42.tgz",
|
||||||
@@ -8252,6 +8298,13 @@
|
|||||||
"url": "https://github.com/sponsors/epoberezkin"
|
"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": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "5.0.1",
|
"version": "5.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
|
||||||
@@ -16839,6 +16892,13 @@
|
|||||||
"node": ">= 0.8"
|
"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": {
|
"node_modules/path-exists": {
|
||||||
"version": "4.0.0",
|
"version": "4.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
|
"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"
|
"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": {
|
"node_modules/vue": {
|
||||||
"version": "3.5.42",
|
"version": "3.5.42",
|
||||||
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz",
|
"resolved": "https://registry.npmmirror.com/vue/-/vue-3.5.42.tgz",
|
||||||
@@ -21764,6 +21831,23 @@
|
|||||||
"vue": "^3.5.0"
|
"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": {
|
"node_modules/wasm-media-encoders": {
|
||||||
"version": "0.7.0",
|
"version": "0.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/wasm-media-encoders/-/wasm-media-encoders-0.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/wasm-media-encoders/-/wasm-media-encoders-0.7.0.tgz",
|
||||||
|
|||||||
+2
-1
@@ -112,6 +112,7 @@
|
|||||||
"@nuxt/eslint": "^1.17.0",
|
"@nuxt/eslint": "^1.17.0",
|
||||||
"eslint": "^9.39.5",
|
"eslint": "^9.39.5",
|
||||||
"prettier": "^3.9.6",
|
"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
@@ -1,19 +1,19 @@
|
|||||||
<svg width="154" height="85" viewBox="0 0 154 85" fill="none" xmlns="http://www.w3.org/2000/svg">
|
<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="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="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="#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="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="#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="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="#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="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="#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="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="#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="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="#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="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="#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="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="#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="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="#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="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="#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="#F9F9F9" fill-opacity="0.4"/>
|
<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="white" 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="#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="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="#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="#6E6E71"/>
|
<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="white" fill-opacity="0.4"/>
|
<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="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="#52525b" fill-opacity="0.85"/>
|
||||||
</svg>
|
</svg>
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
@@ -25,7 +25,7 @@ export default defineEventHandler(async (event) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!['SONG', 'KEYWORD'].includes(type)) {
|
if (!['SONG', 'KEYWORD', 'LANGUAGE', 'GENRE'].includes(type)) {
|
||||||
throw createError({
|
throw createError({
|
||||||
statusCode: 400,
|
statusCode: 400,
|
||||||
message: '无效的黑名单类型'
|
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: '绑定成功,可以直接去同步歌单了',
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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
Reference in New Issue
Block a user