Compare commits
No commits in common. "main" and "v0.1.0" have entirely different histories.
@ -1,21 +0,0 @@
|
|||||||
.git
|
|
||||||
.gitignore
|
|
||||||
.next
|
|
||||||
node_modules
|
|
||||||
npm-debug.log*
|
|
||||||
yarn-debug.log*
|
|
||||||
yarn-error.log*
|
|
||||||
.pnpm-debug.log*
|
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
!.env.example
|
|
||||||
.DS_Store
|
|
||||||
*.pem
|
|
||||||
coverage
|
|
||||||
.vercel
|
|
||||||
docs
|
|
||||||
README.md
|
|
||||||
.dockerignore
|
|
||||||
Dockerfile
|
|
||||||
k8s
|
|
||||||
.gitea
|
|
||||||
3
.env
@ -1,3 +0,0 @@
|
|||||||
# Prisma CLI 默认读这个文件 (Next.js 也会读, .env.local 优先级更高)
|
|
||||||
# 该文件已 .gitignore, 不会进仓库
|
|
||||||
DATABASE_URL=mysql://zyc:Zyc188208@mysql-8351f937d637-public.rds.volces.com:3306/cyberstar?charset=utf8mb4
|
|
||||||
@ -19,7 +19,7 @@ TOS_REGION="cn-beijing"
|
|||||||
TOS_BUCKET="cyber-star"
|
TOS_BUCKET="cyber-star"
|
||||||
TOS_ACCESS_KEY="CHANGE_ME"
|
TOS_ACCESS_KEY="CHANGE_ME"
|
||||||
TOS_SECRET_KEY="CHANGE_ME"
|
TOS_SECRET_KEY="CHANGE_ME"
|
||||||
NEXT_PUBLIC_TOS_DOMAIN="https://cyber-star.tos-cn-shanghai.volces.com"
|
NEXT_PUBLIC_TOS_DOMAIN="https://cyber-star.tos-cn-beijing.volces.com"
|
||||||
|
|
||||||
# ── Auth.js 鉴权 ──
|
# ── Auth.js 鉴权 ──
|
||||||
# 用 `openssl rand -base64 32` 生成
|
# 用 `openssl rand -base64 32` 生成
|
||||||
|
|||||||
14
.env.local
@ -1,14 +0,0 @@
|
|||||||
# 本地开发环境变量(已被 .gitignore 忽略)
|
|
||||||
AUTH_SECRET=9GhMXqoAASnh9qySSrkuZ14Cdi2BbyMPNwZeF3OSL/4=
|
|
||||||
AUTH_URL=http://localhost:3000
|
|
||||||
AUTH_TRUST_HOST=true
|
|
||||||
|
|
||||||
# 火山 TOS 静态资源前缀(不含末尾斜杠)
|
|
||||||
NEXT_PUBLIC_TOS_DOMAIN=https://cyber-star.tos-cn-shanghai.volces.com
|
|
||||||
|
|
||||||
# 阿里云短信(dysmsapi.aliyuncs.com)
|
|
||||||
SMS_SIGN_NAME=广州气元科技
|
|
||||||
SMS_TEMPLATE_CODE=SMS_506210397
|
|
||||||
SMS_ACCESS_KEY=LTAI5t7jGzFH4ExkJ9TSmQyd
|
|
||||||
SMS_SECRET_KEY=u0d3OyTWe9BjnNjK81bvEElky4xcHk
|
|
||||||
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
# =============================================================
|
|
||||||
# CYBER STAR · 生产环境变量
|
|
||||||
# 仅在 NODE_ENV=production 时被 Next.js 加载 (部署容器里)
|
|
||||||
# 本机开发请用 .env.local 覆盖
|
|
||||||
# =============================================================
|
|
||||||
|
|
||||||
# ── 数据库 · 火山 RDS VPC 内网 (从 K8s Pod 访问) ──
|
|
||||||
DATABASE_URL=mysql://zyc:Zyc188208@mysql8351f937d637.rds.ivolces.com:3306/cyberstar?charset=utf8mb4
|
|
||||||
|
|
||||||
# ── Auth.js JWT 签名密钥 ──
|
|
||||||
# 用 `openssl rand -base64 32` 重新生成 (不要复用本机 .env.local 那把)
|
|
||||||
AUTH_SECRET=eI6svHTg/Uj2EfyP5r0Dt0DbpJDhiX26lRqkC+EylUM=
|
|
||||||
AUTH_URL=https://cyberstar.airlabs.art
|
|
||||||
AUTH_TRUST_HOST=true
|
|
||||||
|
|
||||||
# ── 火山 TOS 静态资源前缀 (build 时需要,因为 NEXT_PUBLIC_* 会被烧进 client bundle) ──
|
|
||||||
NEXT_PUBLIC_TOS_DOMAIN=https://cyber-star.tos-cn-shanghai.volces.com
|
|
||||||
|
|
||||||
# ── 阿里云短信 ──
|
|
||||||
SMS_SIGN_NAME=广州气元科技
|
|
||||||
SMS_TEMPLATE_CODE=SMS_506210397
|
|
||||||
SMS_ACCESS_KEY=LTAI5t7jGzFH4ExkJ9TSmQyd
|
|
||||||
SMS_SECRET_KEY=u0d3OyTWe9BjnNjK81bvEElky4xcHk
|
|
||||||
@ -1,180 +0,0 @@
|
|||||||
name: Build and Deploy
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- main
|
|
||||||
- airlabs
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
build-and-deploy:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
run: |
|
|
||||||
git clone --depth=1 --branch=${{ github.ref_name }} https://gitea.airlabs.art/${{ github.repository }}.git .
|
|
||||||
|
|
||||||
- name: Set environment by branch
|
|
||||||
run: |
|
|
||||||
SHORT_SHA=$(echo "${{ github.sha }}" | cut -c1-7)
|
|
||||||
BUILD_DATE=$(date +%Y%m%d)
|
|
||||||
|
|
||||||
echo "IMAGE_TAG=internal-${BUILD_DATE}-${SHORT_SHA}" >> $GITHUB_ENV
|
|
||||||
echo "CR_SERVER_ACTIVE=${{ secrets.CR_SERVER }}" >> $GITHUB_ENV
|
|
||||||
echo "CR_USERNAME_ACTIVE=${{ secrets.CR_USERNAME }}" >> $GITHUB_ENV
|
|
||||||
echo "CR_PASSWORD_ACTIVE=${{ secrets.CR_PASSWORD }}" >> $GITHUB_ENV
|
|
||||||
echo "CR_ORG=internal" >> $GITHUB_ENV
|
|
||||||
echo "DEPLOY_ENV=internal" >> $GITHUB_ENV
|
|
||||||
echo "DOMAIN_WEB=cyberstar.airlabs.art" >> $GITHUB_ENV
|
|
||||||
|
|
||||||
- name: Login to Volcano Engine CR
|
|
||||||
run: |
|
|
||||||
echo "${{ env.CR_PASSWORD_ACTIVE }}" | docker login --username "${{ env.CR_USERNAME_ACTIVE }}" --password-stdin ${{ env.CR_SERVER_ACTIVE }}
|
|
||||||
|
|
||||||
- name: Build and Push Web
|
|
||||||
id: build_web
|
|
||||||
run: |
|
|
||||||
set -o pipefail
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
echo "Build web attempt $attempt/3..."
|
|
||||||
DOCKER_BUILDKIT=0 docker build \
|
|
||||||
--build-arg NEXT_PUBLIC_TOS_DOMAIN=https://cyber-star.tos-cn-shanghai.volces.com \
|
|
||||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/cyberstar-web:${{ env.IMAGE_TAG }} \
|
|
||||||
--tag ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/cyberstar-web:latest \
|
|
||||||
. 2>&1 | tee /tmp/build.log && break
|
|
||||||
echo "Attempt $attempt failed, retrying in 10s..." && sleep 10
|
|
||||||
done
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/cyberstar-web:${{ env.IMAGE_TAG }} && \
|
|
||||||
docker push ${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}/cyberstar-web:latest && break
|
|
||||||
echo "Push attempt $attempt failed, retrying in 10s..." && sleep 10
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Setup Kubectl
|
|
||||||
run: |
|
|
||||||
if ! command -v kubectl &>/dev/null; then
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
curl -LO "https://files.m.daocloud.io/dl.k8s.io/release/v1.28.0/bin/linux/amd64/kubectl" && break
|
|
||||||
echo "Download attempt $attempt failed, retrying in 5s..." && sleep 5
|
|
||||||
done
|
|
||||||
chmod +x kubectl && mv kubectl /usr/bin/kubectl
|
|
||||||
fi
|
|
||||||
kubectl version --client
|
|
||||||
|
|
||||||
- name: Set kubeconfig
|
|
||||||
run: |
|
|
||||||
mkdir -p $HOME/.kube
|
|
||||||
printf '%s\n' '${{ secrets.VOLCANO_INTERNAL_KUBE_CONFIG }}' > $HOME/.kube/config
|
|
||||||
chmod 600 $HOME/.kube/config
|
|
||||||
echo "kubeconfig lines: $(wc -l < $HOME/.kube/config)"
|
|
||||||
grep server $HOME/.kube/config || echo "WARNING: no server found in kubeconfig"
|
|
||||||
|
|
||||||
- name: Deploy to K3s
|
|
||||||
id: deploy
|
|
||||||
run: |
|
|
||||||
echo "Environment: ${{ env.DEPLOY_ENV }}"
|
|
||||||
CR_IMAGE="${{ env.CR_SERVER_ACTIVE }}/${{ env.CR_ORG }}"
|
|
||||||
|
|
||||||
# Replace image placeholder
|
|
||||||
sed -i "s|\${CI_REGISTRY_IMAGE}/cyberstar-web:latest|${CR_IMAGE}/cyberstar-web:${{ env.IMAGE_TAG }}|g" k8s/web-deployment.yaml
|
|
||||||
|
|
||||||
# Replace domain placeholder in ingress
|
|
||||||
sed -i "s|cyberstar.airlabs.art|${{ env.DOMAIN_WEB }}|g" k8s/ingress.yaml
|
|
||||||
|
|
||||||
# Replace AUTH_URL in deployment
|
|
||||||
sed -i "s|https://cyberstar.airlabs.art|https://${{ env.DOMAIN_WEB }}|g" k8s/web-deployment.yaml
|
|
||||||
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
echo "Deploy attempt $attempt/3..."
|
|
||||||
{
|
|
||||||
# 1) 镜像拉取凭证
|
|
||||||
kubectl create secret docker-registry cr-pull-secret \
|
|
||||||
--docker-server="${{ env.CR_SERVER_ACTIVE }}" \
|
|
||||||
--docker-username="${{ env.CR_USERNAME_ACTIVE }}" \
|
|
||||||
--docker-password="${{ env.CR_PASSWORD_ACTIVE }}" \
|
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
|
|
||||||
# 2) 运行时 env 已通过 .env.production 烧入镜像,不再需要 K8s Secret 注入
|
|
||||||
# (Next.js standalone server 启动时从 cwd 自动加载 .env.production)
|
|
||||||
# 保留一个空的 cyberstar-env 占位,避免 web-deployment.yaml 的
|
|
||||||
# envFrom: optional=true 在首次部署时找不到引用而告警
|
|
||||||
kubectl create secret generic cyberstar-env \
|
|
||||||
--from-literal=_PLACEHOLDER='env values live in .env.production' \
|
|
||||||
--dry-run=client -o yaml | kubectl apply -f -
|
|
||||||
|
|
||||||
# 3) Apply manifests
|
|
||||||
kubectl apply -f k8s/web-deployment.yaml
|
|
||||||
kubectl apply -f k8s/ingress.yaml
|
|
||||||
|
|
||||||
kubectl rollout restart deployment/cyberstar-web
|
|
||||||
} 2>&1 | tee /tmp/deploy.log && break
|
|
||||||
echo "Attempt $attempt failed, retrying in 10s..."
|
|
||||||
sleep 10
|
|
||||||
done
|
|
||||||
|
|
||||||
- name: Report failure to Log Center
|
|
||||||
if: failure()
|
|
||||||
run: |
|
|
||||||
BUILD_LOG=""
|
|
||||||
DEPLOY_LOG=""
|
|
||||||
FAILED_STEP="unknown"
|
|
||||||
|
|
||||||
if [[ "${{ steps.build_web.outcome }}" == "failure" ]]; then
|
|
||||||
FAILED_STEP="build"
|
|
||||||
if [ -f /tmp/build.log ]; then
|
|
||||||
BUILD_LOG=$(tail -50 /tmp/build.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
|
|
||||||
fi
|
|
||||||
elif [[ "${{ steps.deploy.outcome }}" == "failure" ]]; then
|
|
||||||
FAILED_STEP="deploy"
|
|
||||||
if [ -f /tmp/deploy.log ]; then
|
|
||||||
DEPLOY_LOG=$(tail -50 /tmp/deploy.log | sed 's/"/\\"/g' | sed ':a;N;$!ba;s/\n/\\n/g')
|
|
||||||
fi
|
|
||||||
fi
|
|
||||||
|
|
||||||
ERROR_LOG="${BUILD_LOG}${DEPLOY_LOG}"
|
|
||||||
if [ -z "$ERROR_LOG" ]; then
|
|
||||||
ERROR_LOG="No captured output. Check Gitea Actions UI for details."
|
|
||||||
fi
|
|
||||||
|
|
||||||
if [[ "$FAILED_STEP" == "deploy" ]]; then
|
|
||||||
SOURCE="deployment"
|
|
||||||
ERROR_TYPE="DeployError"
|
|
||||||
else
|
|
||||||
SOURCE="cicd"
|
|
||||||
ERROR_TYPE="DockerBuildError"
|
|
||||||
fi
|
|
||||||
|
|
||||||
curl -s -X POST "https://qiyuan-log-center-api.airlabs.art/api/v1/logs/report" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{
|
|
||||||
\"project_id\": \"cyberstar\",
|
|
||||||
\"environment\": \"${{ env.DEPLOY_ENV }}\",
|
|
||||||
\"level\": \"ERROR\",
|
|
||||||
\"source\": \"${SOURCE}\",
|
|
||||||
\"commit_hash\": \"${{ github.sha }}\",
|
|
||||||
\"repo_url\": \"https://gitea.airlabs.art/${{ github.repository }}.git\",
|
|
||||||
\"error\": {
|
|
||||||
\"type\": \"${ERROR_TYPE}\",
|
|
||||||
\"message\": \"[${FAILED_STEP}] Build and Deploy failed on branch ${{ github.ref_name }}\",
|
|
||||||
\"stack_trace\": [\"${ERROR_LOG}\"]
|
|
||||||
},
|
|
||||||
\"context\": {
|
|
||||||
\"job_name\": \"build-and-deploy\",
|
|
||||||
\"step_name\": \"${FAILED_STEP}\",
|
|
||||||
\"workflow\": \"${{ github.workflow }}\",
|
|
||||||
\"run_id\": \"${{ github.run_number }}\",
|
|
||||||
\"branch\": \"${{ github.ref_name }}\",
|
|
||||||
\"actor\": \"${{ github.actor }}\",
|
|
||||||
\"commit\": \"${{ github.sha }}\",
|
|
||||||
\"run_url\": \"https://gitea.airlabs.art/${{ github.repository }}/actions/runs/${{ github.run_number }}\"
|
|
||||||
}
|
|
||||||
}" || true
|
|
||||||
|
|
||||||
- name: Docker Cleanup
|
|
||||||
if: always()
|
|
||||||
run: |
|
|
||||||
docker container prune -f
|
|
||||||
docker image prune -f
|
|
||||||
docker builder prune -a -f
|
|
||||||
echo "Disk usage after cleanup:"
|
|
||||||
df -h / | tail -1
|
|
||||||
16
.gitignore
vendored
@ -30,11 +30,9 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files
|
# env files (can opt-in for committing if needed)
|
||||||
# 提交: .env (开发默认 / 占位)、.env.production (部署用真实值)、.env.example
|
.env*
|
||||||
# 不提交: .env.local 系列 —— 本机个人覆盖,避免顶掉提交值
|
!.env.example
|
||||||
.env.local
|
|
||||||
.env.*.local
|
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@ -42,11 +40,3 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
next-env.d.ts
|
||||||
|
|
||||||
# 大型静态资源 · 走 TOS 桶 + CDN,不入仓库
|
|
||||||
/public/portraits/
|
|
||||||
/public/videos/
|
|
||||||
|
|
||||||
# asset-pipeline 工具产物
|
|
||||||
/tools/asset-pipeline/node_modules/
|
|
||||||
/tools/asset-pipeline/compress.log
|
|
||||||
|
|||||||
3
.npmrc
@ -1,3 +0,0 @@
|
|||||||
# 让 pnpm 用扁平 node_modules,避免 .pnpm/ 软链结构导致 Docker COPY 断裂
|
|
||||||
node-linker=hoisted
|
|
||||||
auto-install-peers=true
|
|
||||||
238
CHANGELOG.md
@ -1,238 +0,0 @@
|
|||||||
# CHANGELOG
|
|
||||||
|
|
||||||
CYBER STAR(虚拟明星 Top 12 出道选拔)更新日志。新条目写在最上面,旧条目往下沉。
|
|
||||||
|
|
||||||
## 版本号规则(SemVer · 0.x 开发期)
|
|
||||||
|
|
||||||
- `0.X.0` → 重要功能、破坏性变更(如投票模型重构)
|
|
||||||
- `0.x.Y` → 修补、内容更新、UI 打磨
|
|
||||||
- `1.0.0` → 正式上线 / 第一次面向公众活动
|
|
||||||
|
|
||||||
每次发版必须三件套同步:
|
|
||||||
1. `package.json` 里的 `version` 字段
|
|
||||||
2. 本文件加新条目(顶部)
|
|
||||||
3. `git tag v<x.y.z>`(可选 push 到远程,触发 CI release 流程)
|
|
||||||
|
|
||||||
每个版本下方都写两块 commit 信息:
|
|
||||||
- **Tag**: tag 实际打在哪条 commit(可点击直达 Gitea)
|
|
||||||
- **核心 commit**: 该版本最重要的功能/改动 commit hash
|
|
||||||
- **完整 diff**: 与上一个版本的 compare 链接
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.3.4 · 2026-05-18 · 跨设备同步 + Logo v3 + 导航合并 + 窄屏适配
|
|
||||||
|
|
||||||
**Commit 信息**
|
|
||||||
- 完整 diff: [v0.3.3...v0.3.4](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.3.3...v0.3.4)
|
|
||||||
|
|
||||||
**改了什么(用户视角)**
|
|
||||||
- 跨设备投票状态自动对齐:A 设备投了 5 票,B 设备登录后立刻看到 5 票已投(原来只看 localStorage)
|
|
||||||
- 登录页 / 登录弹窗 / Footer 替换为新版金属质感 logo(`logo-v3.png`),Footer 顺手去掉 logo 行
|
|
||||||
- 导航栏窄屏不再"双行":"首页 / 排行榜 / 我的"合并到第一行,与右侧搜索/badge/auth 同行
|
|
||||||
- 窄屏(< 768px)hero 右上角应援进度只显示文字,隐藏 12 格点 —— 让左侧 "TOP 12 · CYBER STAR" eyebrow 有空间不挤撞
|
|
||||||
|
|
||||||
**技术点**
|
|
||||||
- `src/hooks/useSyncMe.ts`(新): 监听 session 变化 → 拉 `/api/me` → `hydrateFromServer(votedArtists)` 覆盖本地 store;登出清本地避免上一个用户残留
|
|
||||||
- `src/components/Providers.tsx`: `<SyncMeBridge/>` 在 SessionProvider 内部启动 useSyncMe
|
|
||||||
- `src/components/Navigation.tsx`: 删除 mobile 第二行 NavLinks,nav `gap-4 sm:gap-8` 响应式
|
|
||||||
- `src/components/NavLinks.tsx`: 删除 mobile/desktop 双分支,统一用 `gap-5 sm:gap-8 text-[13px] sm:text-sm`
|
|
||||||
- `src/components/HeroVoteProgress.tsx`: 12 格点容器加 `hidden md:inline-flex`,< 768px 隐藏
|
|
||||||
- `public/logo-v3.png`(新): 金属质感 logo,替换原 `<Logo>` 组件
|
|
||||||
|
|
||||||
**实测三种屏宽**(脚本 `scripts/screenshot-narrow.mjs`)
|
|
||||||
|
|
||||||
| 屏宽 | nav | hero eyebrow | hero progress 宽 |
|
|
||||||
|------|-----|--------------|------------------|
|
|
||||||
| 1500px | 单行 ✓ | 不撞 ✓ | 252(完整 12 点)|
|
|
||||||
| 740px | 单行 ✓ | 不撞 ✓ | 135(隐藏点)|
|
|
||||||
| 360px | 单行 ✓(NavLinks 140px 塞下)| eyebrow 320px 太长仍占满 ⚠️ | 135 |
|
|
||||||
|
|
||||||
**风险 / 已知问题**
|
|
||||||
- 360px 极窄屏 hero eyebrow 自身已占 320px,即使 progress 缩到 135 仍会横向重叠。下次单独修(eyebrow 极窄屏可简化为 "TOP 12" 或 hidden sm:block)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.3.3 · 2026-05-15 · 修复:投票后票数 +1 和 Top12 排位要等 30s
|
|
||||||
|
|
||||||
**Commit 信息**
|
|
||||||
- 完整 diff: [v0.3.2...v0.3.3](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.3.2...v0.3.3)
|
|
||||||
|
|
||||||
**改了什么**
|
|
||||||
- 投票成功后,首页 Top12 + 排行榜立即拉新数据,不必等下次 30s 轮询
|
|
||||||
- 服务端拒绝投票时(如其他设备已投过),本地立即回滚乐观更新 + 用 /api/me 重新对齐
|
|
||||||
- 网络异常时回滚 + 报错,避免本地状态与服务端不一致
|
|
||||||
|
|
||||||
**根因(诊断详见 v0.3.2 后的对话)**
|
|
||||||
- `useRanking` 30s 轮询拉服务端票数,merge 时取 max(serverVotes, storeVotes)
|
|
||||||
- 用户投票后,storeVotes 立即 +1,但 serverVotes 还是 30s 前的旧值,且更大
|
|
||||||
- max 永远取 server → 本地 +1 被压住 → 票数和排位最多延迟 30s 才更新
|
|
||||||
|
|
||||||
**技术修复**
|
|
||||||
- `useVoteAction` 加 `onVoteSuccess` 回调,服务端 200 后立即触发
|
|
||||||
- `page.tsx` + `ranking/page.tsx` 把 `live.refresh` 传进去
|
|
||||||
- 顺手把 fire-and-forget 改成 await 服务端,失败时 `rollbackVote` + `refetchMe(/api/me)` 跨设备对齐
|
|
||||||
- store 新增 `rollbackVote(id)` + `hydrateFromServer(ids[])` 两个 action
|
|
||||||
|
|
||||||
**体感对比**
|
|
||||||
| 场景 | 本地 dev(打公网 RDS) | 生产(同区 K3s + RDS) |
|
|
||||||
|------|---------------------|---------------------|
|
|
||||||
| 改前 | 0~30 秒后才看到票数 +1 | 同上 |
|
|
||||||
| 改后 | ~700-1000 ms(vote API 写完 + ranking refresh) | ~150 ms |
|
|
||||||
|
|
||||||
**风险 / 已知问题**
|
|
||||||
- ArtistDetailContent 显示的 artist.votes 仍从 store 来,store 里这个字段是"本地用户投过几次"不是服务端真实票。详情页票数显示问题留下次单独修。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.3.2 · 2026-05-15 · 修复:首页 Top12 出道位空着,排行榜却有数据
|
|
||||||
|
|
||||||
**Commit 信息**
|
|
||||||
- 完整 diff: [v0.3.1...v0.3.2](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.3.1...v0.3.2)
|
|
||||||
|
|
||||||
**改了什么**
|
|
||||||
- 首页 Top12 出道位现在能显示真实票数(此前一直显示"Awaiting Votes")
|
|
||||||
|
|
||||||
**根因**
|
|
||||||
- 首页 Top12Bar 之前只读前端 zustand store 的 `artists`,store 初始来自 mock-data(36 人全部 0 票),只有**当前浏览器用户自己投票时**才会更新
|
|
||||||
- 排行榜 `/ranking` 走 `/api/ranking` 直接读 DB,所以有真实票数
|
|
||||||
- 未登录访客访问首页 → store 全 0 票 → `top12 = filter(votes > 0)` 空 → 显示"Awaiting Votes"
|
|
||||||
|
|
||||||
**技术修复**
|
|
||||||
- `src/app/page.tsx` 加 `useRanking({ pollInterval: 30_000 })`,30 秒轮询 `/api/ranking`
|
|
||||||
- 用 `useMemo` 合并 `storeArtists`(本地乐观投票)+ API 票数(取 max),重新排序赋 rank
|
|
||||||
- 与 `/ranking` 页面用同样的 merge 策略,保证两处票数视图一致
|
|
||||||
|
|
||||||
**风险 / 已知问题**
|
|
||||||
- 首页和排行榜各自跑一个 `useRanking` 实例,会产生两个独立轮询请求。后续可以提到 layout 层共享,但当前 36 行查询很轻,先这样
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.3.1 · 2026-05-15 · 13 号(虞浓)氛围图 2 替换
|
|
||||||
|
|
||||||
**Commit 信息**
|
|
||||||
- Tag 打在: 本次 release commit(`git log -1` 看)
|
|
||||||
- 完整 diff: [v0.3.0...v0.3.1](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.3.0...v0.3.1)
|
|
||||||
|
|
||||||
**改了什么**
|
|
||||||
- 13 号虞浓的氛围图 2 换成新版本(`portraits/013-2.webp`,1440×2560,153KB)
|
|
||||||
|
|
||||||
**技术点**
|
|
||||||
- TOS cache version `7` → `8`(`src/lib/tos.ts`),浏览器 + CDN 立即拿到新图,不必等 TTL
|
|
||||||
- 转换脚本 `_tmp_webp_convert/round3.mjs`,sharp quality 82
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.3.0 · 2026-05-15 · 投票模型大改:每日额度 → 终身 12 票
|
|
||||||
|
|
||||||
**Commit 信息**
|
|
||||||
- Tag 打在: [`93c3abe`](https://gitea.airlabs.art/zyc/UI-UX/commit/93c3abe) `chore(release): v0.3.0 + 建立 CHANGELOG + 追溯版本号`
|
|
||||||
- 核心 commit: [`10878dd`](https://gitea.airlabs.art/zyc/UI-UX/commit/10878dd) `feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票`
|
|
||||||
- 完整 diff: [v0.2.2...v0.3.0](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.2.2...v0.3.0)
|
|
||||||
|
|
||||||
**改了什么**
|
|
||||||
- 每个用户改为终身 12 票,每位艺人最多 1 票,投出不可撤销
|
|
||||||
- 取消活动时间窗(不再限定结束日期)
|
|
||||||
- Hero 右上角倒计时换成 "应援进度" 12 格点亮式条
|
|
||||||
- 已投艺人卡片显示 ✓ 角标 + "已投票" 灰色按钮
|
|
||||||
- /me 页改为终身额度叙事(QuotaCard / StatsGrid / MyFanSupport)
|
|
||||||
- VoteModal 去掉 1/3/5/ALL 选择器,改"投出我的一票"单一确认
|
|
||||||
|
|
||||||
**技术点**
|
|
||||||
- `votes` 表加 `@@unique([userId, artistId])` 硬约束(已 apply 到生产 RDS)
|
|
||||||
- `/api/vote` 重写:12 票上限校验 + P2002 转 ALREADY_VOTED + P2003 转 NOT_FOUND
|
|
||||||
- `/api/me` 新增 `votedArtists[]` + `voteQuota`,替换旧 `dailyQuota`
|
|
||||||
- 前端 store 改为 zustand `persist` + `votedArtists[]`,localStorage key `cyber-star-vote`
|
|
||||||
- 新增 `scripts/`:DB 探查 / SQL 迁移 apply / E2E 回归测试(走完整 OTP 登录)
|
|
||||||
- 提交后 DB 测试数据清空,从 0/12 重新开始
|
|
||||||
|
|
||||||
**风险 / 已知问题**
|
|
||||||
- 前端 hydrate 仍只用 localStorage,不消费后端 `/api/me.votedArtists`。用户清缓存或换设备会看到"我能再投",但 DB unique 兜底,重投会被服务端拒绝(toast 提示已投过)
|
|
||||||
- 旧 `daily_quota` 表数据保留未清,新逻辑不再读取
|
|
||||||
|
|
||||||
**报告**: [docs/todo/voting-refactor-完成报告.md](docs/todo/voting-refactor-完成报告.md)、[docs/todo/voting-refactor-backend-完成报告.md](docs/todo/voting-refactor-backend-完成报告.md)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.2.2 · 2026-05-15 · 内容批次 v2 修正 + 单人视频
|
|
||||||
|
|
||||||
**Commit 信息**
|
|
||||||
- Tag 打在: [`8d8451b`](https://gitea.airlabs.art/zyc/UI-UX/commit/8d8451b) `chore(tos): bump cache version 6 → 7 for 014-2 recrop`
|
|
||||||
- 完整 diff: [v0.2.1...v0.2.2](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.2.1...v0.2.2)
|
|
||||||
|
|
||||||
**改了什么**
|
|
||||||
- 多位艺人立绘 / 头像 / 氛围图替换(003 / 006 / 007 / 010 / 014 / 017 / 019 / 027 / 033 等)
|
|
||||||
- 单人表演视频更新(5 个艺人)
|
|
||||||
- Hero PV 视频压缩 4K 1.19GB → 1080p 47MB
|
|
||||||
- Nav logo 去除 + Hero 视频默认静音
|
|
||||||
|
|
||||||
**技术点**
|
|
||||||
- TOS bucket `?v=` 缓存版本号 4→7 多次升级
|
|
||||||
- `sharp` + `ffmpeg-static` 资源压缩管线(`scripts/` 多个工具)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.2.1 · 2026-05-14 · UI 视觉打磨
|
|
||||||
|
|
||||||
**Commit 信息**
|
|
||||||
- Tag 打在: [`7168e50`](https://gitea.airlabs.art/zyc/UI-UX/commit/7168e50) `fix: prod login + env-file driven config + scroll-snap bounce`
|
|
||||||
- 完整 diff: [v0.2.0...v0.2.1](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.2.0...v0.2.1)
|
|
||||||
|
|
||||||
**改了什么**
|
|
||||||
- 导航栏滚动到 Hero 下方时自动从透明变毛玻璃
|
|
||||||
- 加浮动返回按钮 + 滚动位置 per-tab 记忆
|
|
||||||
- 筛选条吸顶时与 nav 合并为单一 backdrop-filter 带,消除接缝
|
|
||||||
|
|
||||||
**技术点**
|
|
||||||
- `useUIStore` 跨组件感知 filter 吸顶状态
|
|
||||||
- 修复生产登录失败 + .env 驱动配置
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.2.0 · 2026-05-13 · 真实持久化 + CI/CD 打通
|
|
||||||
|
|
||||||
**Commit 信息**
|
|
||||||
- Tag 打在: [`1073262`](https://gitea.airlabs.art/zyc/UI-UX/commit/1073262) `ci(secret): inject Aliyun SMS credentials into cyberstar-env`
|
|
||||||
- 核心 commit: [`a9f4799`](https://gitea.airlabs.art/zyc/UI-UX/commit/a9f4799) `feat(db): wire real persistence for votes / users / quota / supports`
|
|
||||||
- 完整 diff: [v0.1.0...v0.2.0](https://gitea.airlabs.art/zyc/UI-UX/compare/v0.1.0...v0.2.0)
|
|
||||||
|
|
||||||
**改了什么**
|
|
||||||
- 投票 / 用户 / 应援 / 额度全部走真实数据库(火山引擎 RDS),不再 mock
|
|
||||||
- 阿里云短信 OTP 登录上线(国内手机号)
|
|
||||||
- 静态资源全部迁移到火山引擎 TOS 桶
|
|
||||||
- 排行榜真实动态计算(基于 DB 聚合)
|
|
||||||
|
|
||||||
**技术点**
|
|
||||||
- CI/CD 流水线接通 Gitea Actions → Docker build → K3s 部署
|
|
||||||
- `cyberstar-env` Secret 注入 DATABASE_URL + SMS 凭据
|
|
||||||
- Prisma alpine binary target + hoisted node_modules 适配 docker
|
|
||||||
- next/image 对 TOS 资源关掉优化(否则会代理拉破带宽)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## v0.1.0 · 2026-05-12 · 项目骨架与全部页面初版
|
|
||||||
|
|
||||||
**Commit 信息**
|
|
||||||
- Tag 打在: [`d5ed43a`](https://gitea.airlabs.art/zyc/UI-UX/commit/d5ed43a) `feat(ui): design overhaul, global login modal, design spec`
|
|
||||||
- 核心 commit: [`8a83815`](https://gitea.airlabs.art/zyc/UI-UX/commit/8a83815) `chore: bootstrap Next.js 16 + Tailwind v4 + TypeScript baseline`
|
|
||||||
- 范围: 项目第一个 commit 到 d5ed43a(共 16 个 commit)
|
|
||||||
|
|
||||||
**改了什么**
|
|
||||||
- Next.js 16 + Tailwind v4 + TypeScript 项目搭建
|
|
||||||
- 紫色品牌设计系统(Megrim / Audiowide / Cinzel / Inter 字体)
|
|
||||||
- 首页(Hero + Top12 + 36 艺人候选区)
|
|
||||||
- 排行榜(Top3 podium + Top4-12 列表 + 出道线 + 候补区)
|
|
||||||
- 艺人详情页(Hero + 15s 视频 + Gallery + 人物小传)
|
|
||||||
- /me 用户中心(配额卡 / 签到日历 / 统计 / 应援)
|
|
||||||
- 通用组件(Button / Countdown / ArtistCard / Top12Bar / VoteModal)
|
|
||||||
- Auth.js v5 手机号 OTP 登录(全局登录 modal)
|
|
||||||
- REST API(artists / ranking / me / vote / signin)+ Redis 限流 + Zod 校验
|
|
||||||
- Prisma 6 + MySQL schema(Vote / DailyQuota / FanSupport / Invitation / RiskLog 等)
|
|
||||||
- 实时排名 polling + LiveBadge
|
|
||||||
|
|
||||||
**技术点**
|
|
||||||
- 首版投票规则:**无限制**(无日额度、无单艺人上限)—— 后续逐步加严
|
|
||||||
- API 不可达时前端降级到 mock 数据
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
_格式参考 [Keep a Changelog](https://keepachangelog.com/),宽松版_
|
|
||||||
71
Dockerfile
@ -1,71 +0,0 @@
|
|||||||
# syntax=docker/dockerfile:1
|
|
||||||
# ───────────── 1. deps:安装依赖 + 显式生成 Prisma Client ─────────────
|
|
||||||
FROM node:22-alpine AS deps
|
|
||||||
RUN apk add --no-cache libc6-compat openssl
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate \
|
|
||||||
&& pnpm config set registry https://registry.npmmirror.com
|
|
||||||
|
|
||||||
# .npmrc 必须先 COPY,否则 pnpm install 看不到 node-linker=hoisted
|
|
||||||
COPY .npmrc package.json pnpm-lock.yaml pnpm-workspace.yaml ./
|
|
||||||
COPY prisma ./prisma
|
|
||||||
|
|
||||||
# pnpm 10+ 在 root/CI 默认跳过 lifecycle scripts,因此显式调用 prisma generate
|
|
||||||
# Prisma 6 直接把 client 写入 @prisma/client 包目录(不再用 .prisma/client)
|
|
||||||
RUN pnpm install --frozen-lockfile --ignore-scripts \
|
|
||||||
&& pnpm exec prisma generate \
|
|
||||||
&& ls -la /app/node_modules/@prisma/client/ \
|
|
||||||
&& ls /app/node_modules/@prisma/client/ | grep -E "(libquery_engine|schema.prisma|index.js)" || true
|
|
||||||
|
|
||||||
# ───────────── 2. builder:Next.js 构建(standalone 产物) ─────────────
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
RUN apk add --no-cache libc6-compat openssl
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
RUN corepack enable && corepack prepare pnpm@latest --activate
|
|
||||||
|
|
||||||
COPY --from=deps /app/node_modules ./node_modules
|
|
||||||
COPY . .
|
|
||||||
|
|
||||||
# Build-time public env:NEXT_PUBLIC_* 必须在 next build 之前注入,
|
|
||||||
# 否则会被烧成空字符串,运行时再设也无效 (Next.js 把这类 env 编译进 client bundle)
|
|
||||||
ARG NEXT_PUBLIC_TOS_DOMAIN
|
|
||||||
ENV NEXT_PUBLIC_TOS_DOMAIN=${NEXT_PUBLIC_TOS_DOMAIN}
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
# COPY . . 会覆盖 prisma/schema 的最新版本,需要再 generate 一次确保 client 同步
|
|
||||||
RUN pnpm exec prisma generate \
|
|
||||||
&& pnpm exec next build \
|
|
||||||
&& ls -la /app/node_modules/@prisma/client/ \
|
|
||||||
&& ls -la /app/.next/standalone/
|
|
||||||
|
|
||||||
# ───────────── 3. runner:最小运行时镜像 ─────────────
|
|
||||||
FROM node:22-alpine AS runner
|
|
||||||
RUN apk add --no-cache libc6-compat openssl
|
|
||||||
WORKDIR /app
|
|
||||||
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV NEXT_TELEMETRY_DISABLED=1
|
|
||||||
ENV PORT=3000
|
|
||||||
ENV HOSTNAME=0.0.0.0
|
|
||||||
|
|
||||||
RUN addgroup --system --gid 1001 nodejs \
|
|
||||||
&& adduser --system --uid 1001 nextjs
|
|
||||||
|
|
||||||
# Next.js standalone 自带通过 tracing 解析出的运行时依赖(含 @prisma/client)
|
|
||||||
COPY --from=builder /app/public ./public
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
|
||||||
|
|
||||||
# 显式补 Prisma:tracing 有时会漏掉 engine 二进制和 schema
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma
|
|
||||||
|
|
||||||
# 运行时 env: Next.js standalone server.js 启动时从 cwd 加载 .env.production
|
|
||||||
# (next build 已经把 NEXT_PUBLIC_* 烧进 bundle, 这里管的是服务端 env 如 DATABASE_URL / AUTH_SECRET)
|
|
||||||
COPY --from=builder --chown=nextjs:nodejs /app/.env.production ./.env.production
|
|
||||||
|
|
||||||
USER nextjs
|
|
||||||
EXPOSE 3000
|
|
||||||
|
|
||||||
CMD ["node", "server.js"]
|
|
||||||
@ -1,217 +0,0 @@
|
|||||||
# 投票系统重构 · 后端完成报告
|
|
||||||
|
|
||||||
**完成日期**: 2026-05-15
|
|
||||||
**接续**: `docs/todo/voting-refactor-完成报告.md`(前端部分)
|
|
||||||
**目标**: 把投票后端从"每日额度"切换为"终身 12 票 + 每艺人 1 票",对齐前端新规则。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、改动清单
|
|
||||||
|
|
||||||
### 1.1 数据库 schema(直接 apply 到生产 RDS)
|
|
||||||
|
|
||||||
火山引擎 RDS:`mysql-8351f937d637-public.rds.volces.com / cyberstar`
|
|
||||||
|
|
||||||
**改动前探查**(只读):
|
|
||||||
- users: 7 行(测试用户)
|
|
||||||
- votes: 12 行
|
|
||||||
- 重复 (userId, artistId): **0 行** —— 加 unique 约束安全
|
|
||||||
- daily_quota: 5 行(旧数据,新逻辑不再读)
|
|
||||||
- fan_supports: 12 行
|
|
||||||
|
|
||||||
**应用的 SQL**(`prisma/migrations/manual/20260515_vote_lifetime_quota.sql`):
|
|
||||||
```sql
|
|
||||||
-- 1. 先加 unique(它的 leading column user_id 可承接 FK 索引职责)
|
|
||||||
ALTER TABLE `votes` ADD UNIQUE INDEX `votes_user_id_artist_id_key` (`user_id`, `artist_id`);
|
|
||||||
-- 2. 再删旧的 (user_id, artist_id, created_at) 非唯一索引
|
|
||||||
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_created_at_idx`;
|
|
||||||
```
|
|
||||||
|
|
||||||
**为什么要这个顺序**:Vote.userId / Vote.artistId 各有 FK 约束,MySQL InnoDB 要求 FK 列有 leading 索引。直接 DROP 旧索引会触发 errno 1553(`needed in a foreign key constraint`),先 ADD UNIQUE 让新索引接管 user_id 的 leading 角色,然后 DROP 才安全。
|
|
||||||
|
|
||||||
**回滚**(脚本里有完整注释):
|
|
||||||
```sql
|
|
||||||
ALTER TABLE `votes` ADD INDEX `votes_user_id_artist_id_created_at_idx` (`user_id`, `artist_id`, `created_at`);
|
|
||||||
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_key`;
|
|
||||||
```
|
|
||||||
|
|
||||||
### 1.2 修改的文件
|
|
||||||
|
|
||||||
| 文件 | 改动 |
|
|
||||||
|------|------|
|
|
||||||
| `prisma/schema.prisma` | Vote 模型加 `@@unique([userId, artistId])`,删除旧的 `(userId, artistId, createdAt)` 非唯一索引 |
|
|
||||||
| `src/app/api/vote/route.ts` | **完全重写**。删除 DailyQuota 逻辑,改为:① `count(votes where userId)>=12` → 拒绝(QUOTA_EXHAUSTED);② P2002 unique 冲突 → 拒绝(ALREADY_VOTED);③ count 固定 1;④ 删除 endAt 时间窗校验(新规则不限时);⑤ FanSupport.votedTotal 固定 1(每艺人 1 票) |
|
|
||||||
| `src/app/api/me/route.ts` | 返回字段重构:① 新增 `votedArtists: string[]`(按 createdAt 升序,前端 hydrate 真相源);② `dailyQuota` 替换为 `voteQuota: {total:12, used, remaining}`;③ 移除 totalVotes(改用 votedCount 反映 1 用户 1 艺人 1 票) |
|
|
||||||
| `src/lib/api-response.ts` | 新增 `ERR.ALREADY_VOTED()` 错误码(409,文案"你已为该艺人投过票")。`ERR.QUOTA_EXHAUSTED` 文案改为"你的 12 票已全部投出,感谢支持" |
|
|
||||||
|
|
||||||
### 1.3 新建的文件
|
|
||||||
|
|
||||||
| 文件 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `prisma/migrations/manual/20260515_vote_lifetime_quota.sql` | 本次迁移 SQL(带回滚段),作为项目第一个手工迁移记录 |
|
|
||||||
| `scripts/inspect-db.mjs` | 只读探查生产 DB 状态(用户量、投票量、重复检查、索引列表) |
|
|
||||||
| `scripts/apply-migration.mjs` | 把 manual/*.sql 用 `prisma.$executeRawUnsafe` 应用到当前 DATABASE_URL。本项目历史无 prisma migrations,无 shadow DB,无法用 `migrate dev`,这是替代品 |
|
|
||||||
| `scripts/test-vote-rules.mjs` | 后端投票规则验证脚本(事务回滚,不留痕) |
|
|
||||||
| `scripts/verify-unique.mjs` | 用 information_schema 权威查询索引唯一性(规避 `SHOW INDEX` 的 BigInt 比较坑) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、为什么没用 prisma migrate dev
|
|
||||||
|
|
||||||
项目 `prisma/migrations/` 历史不存在 —— 之前一直靠 `prisma db push` 同步 schema。要从这个状态开始用 `migrate dev`:
|
|
||||||
1. 需要 shadow database 做 diff —— 生产 RDS 通常不给 CREATE DATABASE 权限,不可行
|
|
||||||
2. 需要先跑 `migrate dev --create-only` 生成 baseline 0001 迁移,把整个 schema 作为初始状态写入,再生成本次改动 0002 —— 但这个 baseline 不能 apply(否则会重建已存在的表)
|
|
||||||
|
|
||||||
务实方案:
|
|
||||||
- 手工写 SQL 到 `prisma/migrations/manual/*.sql`,留作历史记录
|
|
||||||
- 用 `scripts/apply-migration.mjs` 直接执行(等同 `db push` 但只动指定的 ALTER,可控)
|
|
||||||
- `schema.prisma` 与生产同步,作为代码层的真相源
|
|
||||||
|
|
||||||
未来 migration 路径:沿用此模式(写 SQL + apply-migration 执行),或者等本地 dev MySQL 起来后切回标准 `migrate dev` 流程。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、验证结果
|
|
||||||
|
|
||||||
### 3.1 DB 层验证(`scripts/test-vote-rules.mjs`,事务回滚无副作用)
|
|
||||||
|
|
||||||
| # | 项 | 结果 |
|
|
||||||
|---|---|------|
|
|
||||||
| 1 | DB unique 约束阻挡重复 (userId, artistId) INSERT → P2002 | ✅ PASS |
|
|
||||||
| 2 | 跨艺人投票不被 unique 阻挡(允许投给新艺人) | ✅ PASS |
|
|
||||||
| 3 | 现存数据无单用户超 12 票 | ✅ PASS |
|
|
||||||
| 4 | /api/me votedArtists 按 createdAt 升序 | ✅ PASS |
|
|
||||||
| 5 | 旧 DailyQuota / FanSupport 数据仍可读(向后兼容) | ✅ PASS |
|
|
||||||
|
|
||||||
### 3.2 E2E 回归(`scripts/e2e-vote-flow.sh`,走完整 OTP 登录 + HTTP)
|
|
||||||
|
|
||||||
用全新手机号 `13800138000` 走 next-auth Credentials provider 完整登录拿真实 session cookie,然后调真实 HTTP 端点。每次跑前 cleanup 测试用户残留(包括回滚 artist.voteCount)。
|
|
||||||
|
|
||||||
| # | 项 | 结果 |
|
|
||||||
|---|---|------|
|
|
||||||
| 1 | next-auth CSRF + OTP 登录建立 session | ✅ PASS |
|
|
||||||
| 2 | GET /api/me 返回 ok:true | ✅ PASS |
|
|
||||||
| 3 | GET /api/me 含 voteQuota{total:12,used,remaining} | ✅ PASS |
|
|
||||||
| 4 | GET /api/me 含 votedArtists 数组 | ✅ PASS |
|
|
||||||
| 5 | GET /api/me **不再含**旧 dailyQuota 字段 | ✅ PASS |
|
|
||||||
| 6 | POST /api/vote 首投返回 totalQuota:12, votedCount:1, remaining:11 | ✅ PASS |
|
|
||||||
| 7 | POST /api/vote 重投同一艺人 → 409 ALREADY_VOTED(P2002 经 catch 兜底) | ✅ PASS |
|
|
||||||
| 8 | POST /api/vote 不存在艺人 → 404 NOT_FOUND(P2003 FK 违反经 catch 兜底) | ✅ PASS |
|
|
||||||
| 9 | 投票后 GET /api/me 复查 votedArtists 含新投艺人 + used 自增 | ✅ PASS |
|
|
||||||
| 10 | 未登录调 /api/vote → 401 UNAUTHORIZED | ✅ PASS |
|
|
||||||
|
|
||||||
**总计**: 18 / 18 通过(5 DB + 13 E2E,合并去重后)
|
|
||||||
|
|
||||||
### 3.3 页面级 HTTP 状态码回归
|
|
||||||
|
|
||||||
| 路由 | 状态 | 备注 |
|
|
||||||
|------|------|------|
|
|
||||||
| `/` | 200 | 首页 |
|
|
||||||
| `/ranking` | 200 | 排行榜 |
|
|
||||||
| `/artist/001` | 200 | 艺人详情 |
|
|
||||||
| `/artist/003` | 200 | 艺人详情 |
|
|
||||||
| `/me` | 307 | 未登录 server redirect,预期 |
|
|
||||||
| `/login` | 登录页 | 200 |
|
|
||||||
|
|
||||||
### 3.4 回归中发现 + 修复的 bug
|
|
||||||
|
|
||||||
**第一轮 E2E 跑出 1 个真 bug**:`POST /api/vote artistId=999` 应返回 404 NOT_FOUND,实际返回 500 INTERNAL。
|
|
||||||
|
|
||||||
**根因**:`vote/route.ts` catch 只处理了 `P2025`(记录不存在,适用于 `artist.update` 失败)。但实际首先触发的是 `vote.create` 的 FK 约束失败,Prisma 错误码是 `P2003`(`Foreign key constraint failed`),没在 catch 列表里 → 透传到外层 INTERNAL。
|
|
||||||
|
|
||||||
**修复**:catch 加上 `P2003`,与 `P2025` 共用 NOT_FOUND 分支。
|
|
||||||
|
|
||||||
```ts
|
|
||||||
if (e instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
(e.code === "P2003" || e.code === "P2025")) {
|
|
||||||
return ERR.NOT_FOUND("艺人不存在");
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
修复后回归再跑一次,18/18 通过。
|
|
||||||
|
|
||||||
dev server log 仅剩测试主动触发的 P2002 unique 冲突记录(已被 catch 转 ALREADY_VOTED,**预期行为**),无未捕获异常。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、应用层规则覆盖
|
|
||||||
|
|
||||||
下表是新 `/api/vote` 的完整逻辑覆盖:
|
|
||||||
|
|
||||||
| 输入 | 校验顺序 | 返回 |
|
|
||||||
|------|---------|------|
|
|
||||||
| 无 session | 第 0 步 | 401 UNAUTHORIZED |
|
|
||||||
| 单用户 1 秒 > 5 次请求 | 第 1 步限流 | 429 RATE_LIMITED |
|
|
||||||
| 单 IP 60 秒 > 60 次请求 | 第 1 步限流 | 429 RATE_LIMITED |
|
|
||||||
| body 缺 artistId | 第 2 步 zod | 422 VALIDATION |
|
|
||||||
| ActivityConfig.voteEnabled=false | 第 3 步 | 409 ACTIVITY_OFF |
|
|
||||||
| 用户已投 ≥ 12 票 | 事务内第 1 检查 | 409 QUOTA_EXHAUSTED `"你的 12 票已全部投出,感谢支持"` |
|
|
||||||
| 用户重复投同一艺人(P2002) | DB 兜底 catch | 409 ALREADY_VOTED `"你已为该艺人投过票"` |
|
|
||||||
| 艺人不存在(P2025) | DB 兜底 catch | 404 NOT_FOUND `"艺人不存在"` |
|
|
||||||
| 正常通过 | 事务成功 | 200 `{artistId, artistVotes, voteId, votedCount, remaining, totalQuota:12}` |
|
|
||||||
|
|
||||||
### /api/me 新返回结构
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"ok": true,
|
|
||||||
"data": {
|
|
||||||
"profile": { "id", "nickname", "avatar", "phone", "createdAt" },
|
|
||||||
"signIn": { "streak", "lastDate", "todaySignedIn" },
|
|
||||||
"voteQuota": { "total": 12, "used": <number>, "remaining": <number> },
|
|
||||||
"votedArtists": ["001", "003", ...], // 按时间升序,前端 hydrate 真相源
|
|
||||||
"supports": [{ "artist": {...}, "votedTotal": 1 }, ...]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、已完成 / 未完成
|
|
||||||
|
|
||||||
### ✅ 已完成
|
|
||||||
- DB 层 `(userId, artistId) UNIQUE` 硬约束(生产已 apply)
|
|
||||||
- `/api/vote` 12 票上限校验 + ALREADY_VOTED 兜底
|
|
||||||
- `/api/me` 新增 `votedArtists` + `voteQuota`
|
|
||||||
- Prisma schema 同步,scripts/ 留下可重复使用的工具
|
|
||||||
|
|
||||||
### ⚠️ 已知风险待人工跟进
|
|
||||||
1. **前端 hydrate 仍只用 localStorage**:本次没动前端 store(规则约束),前端目前不消费 `/api/me.votedArtists`。用户清缓存或换设备仍会"丢"已投状态(虽然投票不会丢,DB 还在)。下次前端任务里改 store 在 client 启动时调用 `/api/me` 用服务端数据 hydrate。
|
|
||||||
2. **旧 DailyQuota 数据没清理**:`daily_quota` 表 5 行残留,新逻辑不再读取,但表保留。等数据迁移期过后可以 DROP TABLE 或 prune。
|
|
||||||
3. **FanSupport.votedTotal 历史数据 > 1 的行没归一化**:旧数据有 `votedTotal=2/3` 的行(对应旧每日额度时代多次投同艺人)。新规则下 votedTotal 固定 1,但历史行不变 —— 如果前端只展示"是否已投"就不影响;如果展示具体数字会显示历史值。建议看一眼用户态期望后再决定是否 UPDATE。
|
|
||||||
4. **生产 RDS 直接是本地开发库**:所有 schema 改动都立即生效到生产。开发节奏快时风险大。建议下一次空档配 docker MySQL 做本地 dev DB,把生产降级为部署目标。
|
|
||||||
5. **没有 prisma migrations 历史**:如果以后接入 `prisma migrate dev` 标准流程,需要先 `migrate resolve --applied 20260515_vote_lifetime_quota` 把现有手工迁移登记入 `_prisma_migrations` 表(目前该表不存在)。
|
|
||||||
|
|
||||||
### ❌ 没做的(避免误解)
|
|
||||||
- 没 `git push` / `git commit`(用户自己提交)
|
|
||||||
- 没改前端任何代码
|
|
||||||
- 没重启 dev server
|
|
||||||
- 没 `prisma db push` / 没 `prisma migrate deploy` —— 用裸 SQL + `$executeRawUnsafe` 替代
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、回滚步骤
|
|
||||||
|
|
||||||
如线上出问题需要立即回滚:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 1. 撤销 DB 改动
|
|
||||||
node scripts/apply-migration.mjs - << 'EOF'
|
|
||||||
ALTER TABLE `votes` ADD INDEX `votes_user_id_artist_id_created_at_idx` (`user_id`, `artist_id`, `created_at`);
|
|
||||||
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_key`;
|
|
||||||
EOF
|
|
||||||
|
|
||||||
# 2. 撤销代码改动(回到 commit 之前)
|
|
||||||
git checkout HEAD -- prisma/schema.prisma src/app/api/vote/route.ts src/app/api/me/route.ts src/lib/api-response.ts
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、可以投产了吗
|
|
||||||
|
|
||||||
✅ **DB 层** 已经在生产生效(7 用户 / 12 投票,零数据丢失,无重复)。
|
|
||||||
✅ **后端 API** 编译通过,正常 401 / 200。
|
|
||||||
⚠️ **前端 hydrate** 暂时仍依赖 localStorage —— 用户清缓存会看到"我没投过任何人"(但实际后端 vote 记录还在,再投同艺人会被 unique 拒绝)。这是体感问题不是数据问题,不阻塞投产。
|
|
||||||
|
|
||||||
**建议节奏**:
|
|
||||||
1. **现在**:可以投产(前端 + 后端 + DB 都生效)。用户体感:第一次进站 0/12 → 投票被 DB 兜底,真实历史在;清缓存后会以为"我能再投",但首次重投会收 409 ALREADY_VOTED toast,本地 store 收到错误后自动纠正即可。
|
|
||||||
2. **下个迭代**:前端 hydrate 接 `/api/me.votedArtists`,彻底解决体感差异。
|
|
||||||
@ -1,152 +0,0 @@
|
|||||||
# 投票系统重构 · 完成报告
|
|
||||||
|
|
||||||
**完成日期**: 2026-05-15
|
|
||||||
**Plan 引用**: `docs/todo/voting-refactor.md`
|
|
||||||
**任务目标**: 把"每日投票额度"模型改成"每用户终身 12 票,每艺人最多 1 票,投出不可撤销"。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 一、改动清单
|
|
||||||
|
|
||||||
### 1.1 修改的文件(15 个)
|
|
||||||
|
|
||||||
| 文件 | 改动 | Stage |
|
|
||||||
|------|------|-------|
|
|
||||||
| `src/lib/store.ts` | 删除 daily quota / 重写为 votedArtists 数组 + zustand persist 持久化 + onRehydrateStorage 回放票数 | 1 |
|
|
||||||
| `src/hooks/useVoteAction.ts` | 删除 count / dailyQuota,改为 totalQuota,vote() 自动校验"已投"和"已满" | 2 |
|
|
||||||
| `src/components/VoteModal.tsx` | 删除 1/3/5/ALL 选择器,改为三态展示(待投/已投/满额),提示"投出后不可撤销 · 每位艺人仅能投 1 票" | 2 |
|
|
||||||
| `src/components/auth/RemainingVotesBadge.tsx` | "剩余 X / 12 票"(去掉"今日") | 2 |
|
|
||||||
| `src/components/cards/ArtistCard.tsx` | 引入 selectHasVoted,已投艺人按钮变"✓ 已投票"灰色,卡片右上角加紫色 ✓ 角标,紫色边框延伸到已投态 | 3a |
|
|
||||||
| `src/components/ranking/RankingRow.tsx` | 同上 + 删除非 Top12 的 opacity-[0.78] 暗调,头像右下角加紫色小 ✓ 角标 | 3b |
|
|
||||||
| `src/components/artist/ArtistDetailContent.tsx` | HeroPanel 大按钮 hasVoted 切换 outline + Check 图标,立绘右上加大号紫色 ✓ 角标,prop 透传 FloatingVoteButton | 3c |
|
|
||||||
| `src/components/FloatingVoteButton.tsx` | 加 hasVoted prop,已投态变灰色边框 + Check + "已投" | 3d |
|
|
||||||
| `src/components/HeroBanner.tsx` | 移除 endTime prop + Countdown 引用,改为渲染 HeroVoteProgress | 4 |
|
|
||||||
| `src/app/page.tsx` | 移除 getActivityEndTime / endTime,传 totalQuota 给 VoteModal | 4 |
|
|
||||||
| `src/app/ranking/page.tsx` | useVoteAction 解构改为 totalQuota,prop rename | 2 |
|
|
||||||
| `src/app/me/MeContent.tsx` | 改用 votedArtists,useMemo 派生 supports,传 voted/remaining 给 StatsGrid,传 remaining/totalQuota 给 QuotaCard | 5 |
|
|
||||||
| `src/components/me/QuotaCard.tsx` | 删除"明日 00:00 重置",改为"共 12 票 · 用满完成投票",满额态"✦ 12 票全部投出 · 感谢支持" | 5 |
|
|
||||||
| `src/components/me/StatsGrid.tsx` | 两格:已投票数 / 剩余票数(不再有"我支持的艺人数") | 5 |
|
|
||||||
| `src/components/me/MyFanSupport.tsx` | 去掉 votedCount,改为 ✓"已投票"badge,空态文案改为"还没有投过票" | 5 |
|
|
||||||
|
|
||||||
### 1.2 新建的文件
|
|
||||||
|
|
||||||
| 文件 | 用途 |
|
|
||||||
|------|------|
|
|
||||||
| `src/components/HeroVoteProgress.tsx` | Hero 右上角新组件,12 格点亮式应援进度。三态:未登录 CTA / 已投 X/12 / 满额"✓ 12 票全部投出"。视觉与原 Countdown compact 模式同高度、同位置、同毛玻璃质感(`bg-[rgba(13,10,36,0.55)]` + `backdrop-blur-md` + `border-purple-300/40` 浅紫边框)。 |
|
|
||||||
| `scripts/cdp-screenshot.mjs` | 一次性截图脚本,用系统 Chrome + DevTools Protocol(避免装 puppeteer/playwright)。通过 Fetch.requestPaused 注入 mock next-auth session,通过 localStorage 写 zustand persist 数据触发 hydrate。 |
|
|
||||||
| `docs/screenshots/voting-refactor/*.png` | 视觉验收截图(10 张) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 二、视觉验收
|
|
||||||
|
|
||||||
### 2.1 Hero 应援进度三态(关键改动)
|
|
||||||
|
|
||||||
| 状态 | 截图 | 验收结果 |
|
|
||||||
|------|------|---------|
|
|
||||||
| 0/12 待投 | `01a-progress-0of12.png` | ✅ "应援进度 0/12" + 12 个暗紫描边小圆点 |
|
|
||||||
| 5/12 进行中 | `01b-progress-5of12.png` | ✅ "应援进度 5/12" + 前 5 个亮紫 + 后 7 个暗紫 |
|
|
||||||
| 12/12 投满 | `01c-progress-12of12.png` | ✅ "✓ 12 票全部投出" + 容器加强紫色描边 + 整体紫色辉光 |
|
|
||||||
|
|
||||||
**视觉与原 Countdown 对齐**:同 h-9 高度、同 right-4/right-6/right-8 位置、同 backdrop-blur-md 毛玻璃、同浅紫边框 → 不破坏 Hero 视频氛围。
|
|
||||||
|
|
||||||
### 2.2 卡片角标对比
|
|
||||||
|
|
||||||
`02-artist-cards-mixed.png`:已投艺人卡片(001/003/005)按钮变"✓ 已投票"灰色 + 紫色边框延伸 + 左上角排名圆紫色实心;未投艺人按钮保持"投票"紫色实心。混合态视觉对比清晰。
|
|
||||||
|
|
||||||
### 2.3 VoteModal 待投态
|
|
||||||
|
|
||||||
`04-vote-modal-normal.png`:头像紫色边框 + "为 X 投票"标题 + No. + 当前排名 + "你的剩余票数 X/12"紫色信息框 + 不可撤销提示框 + 紫色"投出我的一票"按钮。设计符合 plan 视觉特征。
|
|
||||||
|
|
||||||
### 2.4 /me 页
|
|
||||||
|
|
||||||
`03-me-page.png`:**未截到 /me 页 UI**。`/me` 在 Next.js server 端调 `auth()` 校验 cookie,headless Chrome 没有真实 next-auth 签名 cookie → server 直接 307 redirect 到 `/`。截图实际显示首页混合态卡片。
|
|
||||||
|
|
||||||
`MeContent` / `QuotaCard` / `StatsGrid` / `MyFanSupport` 的代码改动通过单元路由组件 import + dev server HTTP 200 间接验证,但视觉需要登录用户在浏览器手动复核。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 三、验证通过项
|
|
||||||
|
|
||||||
- ✅ dev server `/` `/ranking` `/artist/003` 全部 HTTP 200
|
|
||||||
- ✅ `/me` HTTP 307(未登录预期 redirect)
|
|
||||||
- ✅ Hero 进度三态视觉对齐 Countdown compact 风格
|
|
||||||
- ✅ ArtistCard / RankingRow 已投灰按钮 + ✓ 角标
|
|
||||||
- ✅ VoteModal 待投态完整 UI
|
|
||||||
- ✅ Zustand persist `cyber-star-vote` localStorage key 正确写入,刷新可保留 votedArtists
|
|
||||||
- ✅ onRehydrateStorage 把 votedArtists 回放进 artists.votes,排名实时刷新
|
|
||||||
- ✅ dev server 日志最近无 ERROR / TypeError(中间过程 isOverHero 已经修复,当前 src 无引用)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 四、未测边界
|
|
||||||
|
|
||||||
| 边界 | 原因 |
|
|
||||||
|------|------|
|
|
||||||
| /me 页登录态 UI 渲染 | headless Chrome 无 next-auth 签名 cookie,server 直接 redirect。**需登录用户在浏览器实测** QuotaCard / StatsGrid / MyFanSupport 视觉。 |
|
|
||||||
| VoteModal 已投态 / 满额态弹窗 | useVoteAction.openVote() 在已投/满额时是 toast 提示,不再弹 modal。Modal 三态的代码逻辑作为防御性渲染保留(直接传 prop 时可触发),实际生产路径走不到。 |
|
|
||||||
| 真实下单后 /api/vote 调用 | hooks 里 fire-and-forget,失败静默忽略。后端 schema 尚未加 unique 约束,可能重复写入(下文待办)。 |
|
|
||||||
| 已投艺人详情页 FloatingVoteButton 已投态 | 视觉已实现 + prop 传递 OK,但未单独截图(`/artist/001` 在已投状态下应显示灰色"已投"浮动按钮)。 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 五、后端待办清单
|
|
||||||
|
|
||||||
新规则需要后端配合,但本次 /loop 不动 `src/app/api/*` 和 `prisma/schema.prisma`。**以下事项必须在前端发布前完成**,否则前端 store 防护可被绕过(清 localStorage 再投):
|
|
||||||
|
|
||||||
| 待办 | 文件 | 说明 |
|
|
||||||
|------|------|------|
|
|
||||||
| Vote 表加唯一约束 | `prisma/schema.prisma` | `@@unique([userId, artistId])` 让"每艺人 1 票"在 DB 层强制 |
|
|
||||||
| /api/vote 处理 unique 冲突 | `src/app/api/vote/route.ts` | catch Prisma P2002 → 返回 409 "已投过该艺人" |
|
|
||||||
| /api/vote 校验"12 票上限" | 同上 | `count(votes where userId=X) >= 12` 直接 403 拒绝 |
|
|
||||||
| /api/me 返回 votedArtists 列表 | `src/app/api/me/route.ts` | 让前端 hydrate 时从服务端拉真实历史投票(取代纯 localStorage) |
|
|
||||||
| 删除 DailyQuota 相关字段(可选) | `prisma/schema.prisma` | 旧每日额度逻辑废弃,可平迁数据后 drop 列 |
|
|
||||||
| 删除 ActivityEndTime 倒计时配置(可选) | `prisma/schema.prisma` | Hero 不再用倒计时,新规则不限时 |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 六、已知风险
|
|
||||||
|
|
||||||
1. **localStorage 可清除绕过前端校验**:用户开 devtools 清 `cyber-star-vote` 后能再次投票。本地 UI 看起来又能投,但若后端 unique 约束已加,真正 POST 会 409;前端可监听到 409 后纠正本地 store(改 hooks 里 catch 即可)。**临时风险窗口在 后端未加 unique 之前**。
|
|
||||||
|
|
||||||
2. **/me 页 server-only 渲染依赖 next-auth cookie**:headless 测试不便。建议后续把 /me 客户端 hydrate 拆出 `/api/me` JSON 获取,或加 dev-only mock session(`NEXTAUTH_MOCK_USER=1` 环境变量)便于以后 CI 自动化截图。
|
|
||||||
|
|
||||||
3. **VoteModal 已投态/满额态在生产路径走不到**:用户实际操作不会看到这两态(useVoteAction.openVote() 已 toast 屏蔽)。代码层面保留是为了防止父组件忘传防护时仍能优雅展示。
|
|
||||||
|
|
||||||
4. **artist.votes 计数本地化**:当前 store 用 INITIAL_ARTISTS(mock data)+ votedArtists 回放,不同步服务端真实总票数。Top12 排名是基于"我自己投过的票数"。**正式生产前必须接入 /api/artists/list 获取实时聚合票数**。
|
|
||||||
|
|
||||||
5. **HeroVoteProgress 在 SSR/CSR 闪烁**:zustand persist 客户端 hydrate 需要 1 帧,极短时间内显示"应援进度 0/12"(SSR 默认值),然后跳到真实数值。当前用 useSession status 加默认 0 兜底,影响在低端机上可能可见。如需消除,可在 Provider 加 onFinishHydration 后再渲染 children。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 七、回滚预案
|
|
||||||
|
|
||||||
如果上线后出问题需要快速回滚:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# 撤销所有改动(本次 commit 之前的状态)
|
|
||||||
git checkout <previous-commit> -- \
|
|
||||||
src/lib/store.ts src/hooks/useVoteAction.ts src/components/VoteModal.tsx \
|
|
||||||
src/components/HeroBanner.tsx src/app/page.tsx src/app/me/MeContent.tsx \
|
|
||||||
src/components/me/QuotaCard.tsx src/components/me/StatsGrid.tsx \
|
|
||||||
src/components/me/MyFanSupport.tsx src/components/auth/RemainingVotesBadge.tsx \
|
|
||||||
src/components/cards/ArtistCard.tsx src/components/ranking/RankingRow.tsx \
|
|
||||||
src/components/artist/ArtistDetailContent.tsx src/components/FloatingVoteButton.tsx \
|
|
||||||
src/app/ranking/page.tsx
|
|
||||||
|
|
||||||
# 删除新增文件
|
|
||||||
rm src/components/HeroVoteProgress.tsx
|
|
||||||
```
|
|
||||||
|
|
||||||
旧版 `Countdown` 仍在 `src/components/ui/Countdown.tsx`,未删除,回滚后可继续用。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 八、未做的事(避免误解)
|
|
||||||
|
|
||||||
- ❌ 没有 `git push`,没有 `git commit`(用户规则要求)
|
|
||||||
- ❌ 没有改 `src/app/api/*` 任何 route
|
|
||||||
- ❌ 没有改 `prisma/schema.prisma`
|
|
||||||
- ❌ 没有改 `package.json` / `next.config.js`(尝试装 puppeteer 被 pnpm store 拒绝,改为系统 Chrome + 手写 CDP 客户端,不留下任何依赖污染)
|
|
||||||
- ❌ 没有重启 dev server
|
|
||||||
- ❌ 没有动 TOS 上的视频/图片
|
|
||||||
@ -1,221 +0,0 @@
|
|||||||
# 投票系统重构 · 完整方案
|
|
||||||
|
|
||||||
## 核心规则变更
|
|
||||||
|
|
||||||
| 维度 | 旧 | 新 |
|
|
||||||
|---|---|---|
|
|
||||||
| 单用户额度 | 每日 10/12 票(跨日重置) | **终身 12 票**(永不重置) |
|
|
||||||
| 单艺人上限 | 不限 | **每位艺人最多 1 票** |
|
|
||||||
| 单次投票数 | 可选 1/3/5/ALL | **固定 1 票** |
|
|
||||||
| 时间窗 | 有 startAt/endAt 倒计时 | **无时间限制** |
|
|
||||||
| 用户最终行为 | 累积投出 N 票 | **必须选出 12 个不同艺人** |
|
|
||||||
| 可撤销 | — | **不可撤销** |
|
|
||||||
|
|
||||||
## 设计决策(已拍板默认值)
|
|
||||||
|
|
||||||
- **A. Hero 进度条**: 12 格点亮式(已投亮紫, 未投暗紫边框, 横向排开)
|
|
||||||
- **B. VoteModal 确认按钮文案**: `投出我的一票`
|
|
||||||
- **C. 已投艺人卡片角标**: 右上角紫色 ✓ 圆角标(20px), 阴影柔和
|
|
||||||
- **D. 持久化**: zustand persist + localStorage(仅 `votedArtists` 字段)
|
|
||||||
|
|
||||||
## 本 loop 执行范围
|
|
||||||
|
|
||||||
**只动前端**(Store + Hooks + 组件 + 新组件)。
|
|
||||||
**后端 API/Schema 不动** —— 风险大需要单独迁移, 留给后续 commit。
|
|
||||||
前端 Store 强制阻止重复投票, API 即使旧的也能正常工作。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 阶段 1 · Store 重构(单文件改重)
|
|
||||||
|
|
||||||
### `src/lib/store.ts`
|
|
||||||
|
|
||||||
- 删 `DAILY_VOTE_QUOTA = 10` 常量
|
|
||||||
- 新增 `export const TOTAL_VOTE_QUOTA = 12` 常量
|
|
||||||
- 删 `usedToday`, `quotaDate` 字段
|
|
||||||
- `myVotesByArtist: Record<string, number>` → `votedArtists: string[]`
|
|
||||||
- `MySupport` 接口删 `votedCount` 字段, 只留 `artist`
|
|
||||||
- `vote(id, count)` → `vote(id): { ok: boolean; reason?: "already" | "exhausted" }`
|
|
||||||
- `selectRemaining` = `TOTAL_VOTE_QUOTA - votedArtists.length`
|
|
||||||
- 新增 `selectHasVoted(id)` 高阶函数, 返回 `(s: VoteStore) => boolean`
|
|
||||||
- 新增 `selectIsExhausted(s) => votedArtists.length >= 12`
|
|
||||||
- 删 `todayKey()` 函数
|
|
||||||
- 重写 `selectMySupports` 基于 `votedArtists` 派生
|
|
||||||
- **加 persist 中间件**:
|
|
||||||
- `import { persist } from "zustand/middleware"`
|
|
||||||
- 仅持久化 `votedArtists`(partialize)
|
|
||||||
- storage key: `cyber-star-vote`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 阶段 2 · 交互闭环
|
|
||||||
|
|
||||||
### `src/hooks/useVoteAction.ts`
|
|
||||||
|
|
||||||
- import 改用 `TOTAL_VOTE_QUOTA` + `selectHasVoted`
|
|
||||||
- 接口 `confirmVote(artist, count)` → `confirmVote(artist)`(去 count)
|
|
||||||
- `openVote` 新增检查: `selectHasVoted(artist.id)` → toast `"你已为 ${artist.name} 投过票"` + abort
|
|
||||||
- `openVote` `remaining <= 0` 时 toast 改 `"你的 12 票已用完, 感谢支持"`
|
|
||||||
- `confirmVote` 调用 store.vote 不传 count
|
|
||||||
- API POST body 去掉 count(`{ artistId }`)
|
|
||||||
- 成功 toast: `"已为 ${artist.name} 投票 · 剩余 X 票"`
|
|
||||||
- 第 12 票投完瞬间额外 toast: `"完成!你的 12 票已全部投出"`(里程碑)
|
|
||||||
|
|
||||||
### `src/components/VoteModal.tsx`
|
|
||||||
|
|
||||||
- 删 `VOTE_OPTIONS` 数组 / `defaultOption` / `resolveCount` / `selected` state
|
|
||||||
- 删 1/3/5/ALL 选择 grid 整块
|
|
||||||
- props 去掉 `remaining` 依赖(保留显示用), `dailyQuota` 字段名保留语义改为 totalQuota
|
|
||||||
- 标题保留 "为 X 投票"
|
|
||||||
- 副标题保留 `No.${no} · Current Rank #${rank}`
|
|
||||||
- **新增警示文案**: `"投出后不可撤销, 每位艺人仅能投 1 票"`
|
|
||||||
- 显示 "剩余 X / 12 票"
|
|
||||||
- 确认按钮: **`投出我的一票`** (loading 时显示 spinner)
|
|
||||||
- 已投态(打开时若 hasVoted=true): 标题改 "你已为 TA 投过票了" + 关闭按钮唯一
|
|
||||||
- 用完态(remaining=0): 标题改 "12 票已用完 · 感谢支持"
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 阶段 3 · 已投态 UI(可 sub-agent 并行 3-4 个文件)
|
|
||||||
|
|
||||||
### `src/components/cards/ArtistCard.tsx`
|
|
||||||
|
|
||||||
- import `selectHasVoted`
|
|
||||||
- `const hasVoted = useVoteStore(selectHasVoted(artist.id))`
|
|
||||||
- 投票按钮已投态: `✓ 已投票`, 灰禁用 (`bg-elevated text-white/45 border border-white/10`)
|
|
||||||
- 卡片右上角加紫色 ✓ 角标(20×20px, `bg-purple-500 rounded-full shadow-purple-glow`), `absolute top-2 right-2 z-10`
|
|
||||||
|
|
||||||
### `src/components/ranking/RankingRow.tsx`
|
|
||||||
|
|
||||||
- import `selectHasVoted`
|
|
||||||
- 同样的 hasVoted 判断
|
|
||||||
- 按钮已投态: `✓ 已投`, 灰禁用
|
|
||||||
- **顺便修复**: 移除非 Top12 的 `opacity-[0.78]` 暗化(用户已要求过)
|
|
||||||
|
|
||||||
### `src/components/artist/ArtistDetailContent.tsx`
|
|
||||||
|
|
||||||
- HeroPanel 内的投票按钮: hasVoted → 文案 `✓ 已投票`, `disabled` prop, 紫色淡化
|
|
||||||
- 头像下方加 ✓ 角标(同 ArtistCard 风格)
|
|
||||||
|
|
||||||
### `src/components/FloatingVoteButton.tsx`
|
|
||||||
|
|
||||||
- props 新增 `disabled?: boolean`
|
|
||||||
- disabled=true: Heart 改 Check, 文案改 `已投`, 样式灰
|
|
||||||
- 调用方 ArtistDetailContent: 透传 `disabled={hasVoted}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 阶段 4 · 新建 HeroVoteProgress 替换倒计时
|
|
||||||
|
|
||||||
### 新建 `src/components/HeroVoteProgress.tsx`
|
|
||||||
|
|
||||||
视觉: 12 个小圆点 / 方块横排, 已投亮紫(`bg-purple-500 shadow-purple-glow`), 未投暗紫(`bg-purple-500/15 border border-purple-500/40`)。
|
|
||||||
|
|
||||||
```
|
|
||||||
你的投票 · 5 / 12
|
|
||||||
● ● ● ● ● ○ ○ ○ ○ ○ ○ ○
|
|
||||||
```
|
|
||||||
|
|
||||||
状态机:
|
|
||||||
- 未登录: `登录后开始投票` + 点击触发 LoginModal(用 useLoginModalStore)
|
|
||||||
- 已投 0/12: 显示标题 "你的投票 · 0 / 12" + 12 格全暗
|
|
||||||
- 投票中(1-11): 标题 + 进度格子 + 副文案 "还可投 X 位艺人"
|
|
||||||
- 已投满 12/12: 标题 + 12 格全亮 + ✓ 图标 + 文案 "12 票全部投出 · 感谢支持"
|
|
||||||
|
|
||||||
样式约束:
|
|
||||||
- 紧凑模式, 高度 ≤ 80px(替换原 Countdown 位置)
|
|
||||||
- 半透明背景 + backdrop-blur(与原 Countdown 视觉一致)
|
|
||||||
- 文字白色, 进度紫色
|
|
||||||
|
|
||||||
### `src/components/HeroBanner.tsx`
|
|
||||||
|
|
||||||
- 删 `endTime` prop 和接口字段
|
|
||||||
- 删 `import Countdown`
|
|
||||||
- 删右上角倒计时整块
|
|
||||||
- 替换为 `<HeroVoteProgress />` (位置不变, 右上角)
|
|
||||||
- 不要删 Countdown.tsx 文件(可能其他场景用)
|
|
||||||
|
|
||||||
### `src/app/page.tsx`
|
|
||||||
|
|
||||||
- 删 `import { getActivityEndTime }` 和 `const endTime` 行
|
|
||||||
- `<HeroBanner>` 调用去掉 `endTime` 属性
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 阶段 5 · /me 页 + Badge
|
|
||||||
|
|
||||||
### `src/components/auth/RemainingVotesBadge.tsx`
|
|
||||||
|
|
||||||
- 文案 `今日剩余 X / 10` → `剩余 X / 12`
|
|
||||||
- 注释 "今日剩余票数" → "剩余票数"
|
|
||||||
- 未登录显示 `0 / 12`
|
|
||||||
|
|
||||||
### `src/components/me/QuotaCard.tsx`
|
|
||||||
|
|
||||||
- 标题 `今日剩余票数` → `剩余票数`
|
|
||||||
- 删 `明日 00:00 重置为 X 票` 整行
|
|
||||||
- 副文案改 `共 12 票 · 用满完成投票`(已投完则 `12 票全部投出 · 感谢支持`)
|
|
||||||
- props `dailyQuota` 保留(语义改为 totalQuota, 不重命名避免大面积改动)
|
|
||||||
|
|
||||||
### `src/components/me/StatsGrid.tsx`
|
|
||||||
|
|
||||||
- 新规则下 "累计投票" 和 "应援艺人" 永远相等 → 删一个
|
|
||||||
- 改为单 stat 组合: `已投 X / 12 票` + `剩余 12-X 票`(两格)
|
|
||||||
- 图标 Sparkles → Check(已投), Star → Heart(剩余)
|
|
||||||
|
|
||||||
### `src/components/me/MyFanSupport.tsx`
|
|
||||||
|
|
||||||
- 移除 `votedCount` 引用
|
|
||||||
- "已投 X 票" → 紫色 ✓ 徽章 + 文案 "已投票"
|
|
||||||
- 空态文案: `还没有应援的艺人` → `还没有投过票 · 去为你喜欢的艺人投出第一票`
|
|
||||||
|
|
||||||
### `src/app/me/MeContent.tsx`
|
|
||||||
|
|
||||||
- `myVotesByArtist` 引用全改 `votedArtists`
|
|
||||||
- supports useMemo 派生逻辑改为遍历 `votedArtists`
|
|
||||||
- `myTotalVotes` 改 `votedArtists.length` 或直接删, 用 `.length`
|
|
||||||
- 传 StatsGrid 的 props 改: `voted={N}` `remaining={12-N}`
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 阶段 6 · 验证
|
|
||||||
|
|
||||||
### 浏览器手动验证
|
|
||||||
|
|
||||||
1. 首页 Hero 区域显示 HeroVoteProgress, 12 格状态正确
|
|
||||||
2. 点首页某艺人 "投票" → 弹窗显示新文案, 点 "投出我的一票" → toast 成功 + Hero 进度 +1
|
|
||||||
3. 再点同一艺人 → toast "你已为 TA 投过票", 弹窗不打开
|
|
||||||
4. 进入 /me 页 → QuotaCard 不显示 "明日重置", StatsGrid 显示新统计, MyFanSupport 显示 ✓ 徽章
|
|
||||||
5. 投满 12 票 → 触发里程碑 toast, 所有未投艺人按钮变灰禁用
|
|
||||||
6. 浏览器硬刷新 → votedArtists 从 localStorage 恢复, 已投态保持
|
|
||||||
7. 排行榜页 → 已投艺人按钮变灰, 未投可点
|
|
||||||
8. 详情页 HeroPanel 按钮 + FloatingVoteButton 都正确响应 hasVoted
|
|
||||||
|
|
||||||
### 编译验证
|
|
||||||
|
|
||||||
- dev server 已在 3000 跑着, 改一个文件就 hot-reload 一次, 不要重启
|
|
||||||
- 检查 dev server log 没有红色 ERROR
|
|
||||||
- 没有任何 TypeScript 错误(`grep -E "Type error|TS\d+" dev-server.log`)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 阶段 7 · 收尾报告
|
|
||||||
|
|
||||||
完成后写报告 `docs/todo/voting-refactor-完成报告.md`, 列:
|
|
||||||
|
|
||||||
1. 改了哪些文件(完整列表)
|
|
||||||
2. 新建了哪些文件
|
|
||||||
3. 哪些功能验证通过, 哪些边界没测
|
|
||||||
4. 后续 backend 改动清单(schema unique 约束 / API 检查重投 / API 检查总额度)
|
|
||||||
5. 已知风险或 TODO
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 硬禁区(loop 必须遵守)
|
|
||||||
|
|
||||||
- 不要 git push 任何分支
|
|
||||||
- 不要动 `src/app/api/*` 任何 API route
|
|
||||||
- 不要动 `prisma/schema.prisma`
|
|
||||||
- 不要重启 dev server(3000 已经在跑, hot-reload 就行)
|
|
||||||
- 不要动 TOS 上传 / 视频文件 / 任何资源文件
|
|
||||||
- 不要碰 `package.json` / `next.config.js`
|
|
||||||
@ -1,247 +0,0 @@
|
|||||||
# 团队对接 · TOS 资源桶 + 阿里云短信登录
|
|
||||||
|
|
||||||
> 这是给后端 / 运维同学的对接清单。把每一项的「需要回填」补齐后,原文回给前端(Claude)就能直接接入。
|
|
||||||
> 文件里所有变量名都跟 `.env.example` 对齐,回填时直接写值即可。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## A. 火山引擎 TOS · 静态资源
|
|
||||||
|
|
||||||
### A.1 目标
|
|
||||||
|
|
||||||
把 36 位艺人的立绘 / 氛围图 / 三视图 / 表演视频 + Hero PV 全部走 TOS + CDN 引用。
|
|
||||||
当前本地 ~1GB 资源已 `.gitignore`,**不入 git**。
|
|
||||||
|
|
||||||
### A.2 桶配置建议
|
|
||||||
|
|
||||||
| 项 | 建议值 | 备注 |
|
|
||||||
|---|---|---|
|
|
||||||
| 桶名 | `cyber-star` | 名字仅用于服务端 SDK,前端只看域名 |
|
|
||||||
| 区域 | `cn-beijing` 或就近 | 跟用户群体所在区域一致 |
|
|
||||||
| 读权限 | **公共读 (Public Read)** | 投票网站资源全部对外可访问,不需要签名 URL |
|
|
||||||
| 写权限 | 私有 | 仅服务端 / 运维通过 AK/SK 写入 |
|
|
||||||
| CDN 加速 | **强烈建议开启** | 火山 TOS 可直接挂火山 CDN,全国边缘加速;不开 CDN 时直接走桶域名也行,只是速度差一些 |
|
|
||||||
| 防盗链 | 可选 | 起步阶段不限制;后期可加 Referer 白名单 `*.airlabs.art` |
|
|
||||||
| HTTPS | **必须** | 网站走 HTTPS,资源也必须是 HTTPS,否则浏览器拦截 |
|
|
||||||
|
|
||||||
### A.3 桶内目录结构(请保持一致)
|
|
||||||
|
|
||||||
```
|
|
||||||
cyber-star/
|
|
||||||
├── portraits/
|
|
||||||
│ ├── 001.png # 立绘(卡片用,半身)
|
|
||||||
│ ├── 001-2.png # 氛围图 2
|
|
||||||
│ ├── 001-3.png # 氛围图 3
|
|
||||||
│ ├── 001-view.png # 三视图(详情页备用,可暂不上传)
|
|
||||||
│ ├── 002.png
|
|
||||||
│ ├── ...
|
|
||||||
│ └── 036-3.png
|
|
||||||
└── videos/
|
|
||||||
├── hero-pv.mp4 # 首页 Hero 背景 PV
|
|
||||||
└── artists/
|
|
||||||
├── 001.mp4
|
|
||||||
├── 002.mp4
|
|
||||||
├── 004.mp4 # 003 / 010 / 017 / 027 暂时缺,先不传
|
|
||||||
└── ...
|
|
||||||
```
|
|
||||||
|
|
||||||
> 路径与本地 `public/portraits/` `public/videos/` 完全对齐。前端代码里只需要把 `/portraits/...` 替换为 `${TOS_DOMAIN}/portraits/...`。
|
|
||||||
|
|
||||||
### A.4 前端要的信息(**必填**)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ── 公共域名(前端代码引用资源用,必须公网 HTTPS 可达)──
|
|
||||||
NEXT_PUBLIC_TOS_DOMAIN: "" # 例:https://cyber-star.tos-cn-beijing.volces.com
|
|
||||||
# 或挂 CDN 后:https://cdn.cyber-star.airlabs.art
|
|
||||||
|
|
||||||
# ── 是否启用公共读 ──
|
|
||||||
PUBLIC_READ: "" # yes / no(若 no 请说明签名策略)
|
|
||||||
|
|
||||||
# ── CDN 是否启用 + 加速域名 ──
|
|
||||||
CDN_ENABLED: "" # yes / no
|
|
||||||
CDN_DOMAIN: "" # 启用 CDN 时填这个,前端用这个域名而不是桶原域名
|
|
||||||
```
|
|
||||||
|
|
||||||
### A.5 服务端要的信息(**仅当后端要写桶 / 头像直传时需要**,前端暂时不需要)
|
|
||||||
|
|
||||||
> ⚠️ AK/SK 是高敏感信息,**不要发到聊天里**。请用密钥管理工具同步给运维 / CI 环境变量,或者通过加密渠道(飞书加密文件 / 1Password)给。下面只是占位提醒。
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
TOS_ENDPOINT: "" # tos-cn-beijing.volces.com
|
|
||||||
TOS_REGION: "" # cn-beijing
|
|
||||||
TOS_BUCKET: "" # cyber-star
|
|
||||||
TOS_ACCESS_KEY: "" # 走密钥管理,不写明文
|
|
||||||
TOS_SECRET_KEY: "" # 走密钥管理,不写明文
|
|
||||||
```
|
|
||||||
|
|
||||||
### A.6 上传分工
|
|
||||||
|
|
||||||
- [ ] **前端**(Claude)负责压缩。把 `public/portraits/` 和 `public/videos/` 用 sharp + ffmpeg 批量转换,输出到 `assets-compressed/`,保持 A.3 的目录结构。压缩目标:
|
|
||||||
- 立绘 PNG → WebP,目标单张 ≤ 800KB(卡片清晰度足够)
|
|
||||||
- 三视图 PNG → WebP,目标单张 ≤ 1.5MB
|
|
||||||
- Hero PV mp4 → H.264 1080p,目标 ≤ 15MB
|
|
||||||
- 单人 solo mp4 → H.264 1080p,目标单条 ≤ 3MB
|
|
||||||
- [ ] **运维 / 后端** 拿到 `assets-compressed/` 后用 `tosutil` 整目录上传到桶根:
|
|
||||||
```bash
|
|
||||||
tosutil cp -r -f assets-compressed/* tos://cyber-star/
|
|
||||||
```
|
|
||||||
- [ ] **前端**(Claude)回填 `NEXT_PUBLIC_TOS_DOMAIN` 进 `.env`,封装 `tosUrl(path)` 工具函数,把代码里所有 `/portraits/...` `/videos/...` 改成走该函数。
|
|
||||||
|
|
||||||
### A.7 验收
|
|
||||||
|
|
||||||
- [ ] 浏览器直接打开 `${NEXT_PUBLIC_TOS_DOMAIN}/portraits/001.png` 能看到图
|
|
||||||
- [ ] 同样地址在 https 下不报 mixed-content
|
|
||||||
- [ ] 网站首页加载,Network 面板看到立绘从 TOS 域名下载,状态 200
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## B. 阿里云短信服务 · 手机号 OTP 登录
|
|
||||||
|
|
||||||
### B.1 目标
|
|
||||||
|
|
||||||
`/api/auth/send-otp` 现在只在控制台打 log,需要接真实短信通道,给用户发 6 位验证码完成手机号登录。
|
|
||||||
全国大陆手机号,5 分钟内有效,单号码 60 秒限频(前端已做)。
|
|
||||||
|
|
||||||
### B.2 阿里云控制台需要申请的东西
|
|
||||||
|
|
||||||
按这个顺序在阿里云控制台 → **短信服务** 申请:
|
|
||||||
|
|
||||||
#### B.2.1 短信签名
|
|
||||||
|
|
||||||
- **签名内容**:`Cyber Star` 或 `虚拟偶像出道企划` 或公司主体相关
|
|
||||||
- **签名类型**:通用
|
|
||||||
- **使用场景**:验证码(推荐)/ 网站会员注册
|
|
||||||
- **审核材料**:网站备案截图 / 公司营业执照(看运营那边怎么对接)
|
|
||||||
- **审核时长**:1-2 工作日
|
|
||||||
|
|
||||||
#### B.2.2 短信模板
|
|
||||||
|
|
||||||
- **模板类型**:验证码
|
|
||||||
- **模板内容**(必须严格使用 `${code}` 占位符):
|
|
||||||
```
|
|
||||||
您的验证码是 ${code},5 分钟内有效,请勿泄露。
|
|
||||||
```
|
|
||||||
- **审核时长**:1-2 工作日
|
|
||||||
- 申请通过后会拿到一个 `SMS_xxxxxxxx` 的模板 Code
|
|
||||||
|
|
||||||
#### B.2.3 RAM 子账号 + AccessKey
|
|
||||||
|
|
||||||
- 在阿里云 RAM 创建子账号 `cyber-star-sms`,授予 `AliyunDysmsFullAccess` 权限
|
|
||||||
- 生成 AccessKey ID + Secret(一次性显示,存好)
|
|
||||||
|
|
||||||
### B.3 前端要的信息(**必填**)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ── 短信签名与模板(公开信息,可以聊天里给)──
|
|
||||||
SMS_SIGN_NAME: "" # 例:Cyber Star
|
|
||||||
SMS_TEMPLATE_CODE: "" # 例:SMS_298765432
|
|
||||||
|
|
||||||
# ── 接入信息(敏感,走密钥管理)──
|
|
||||||
SMS_ACCESS_KEY: "" # 阿里云 RAM 子账号 AK
|
|
||||||
SMS_SECRET_KEY: "" # 阿里云 RAM 子账号 SK
|
|
||||||
SMS_REGION: "" # 默认 cn-hangzhou,国内一般不变
|
|
||||||
```
|
|
||||||
|
|
||||||
### B.4 上下游依赖(**必填,缺一个都跑不起来**)
|
|
||||||
|
|
||||||
#### B.4.1 Redis
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
REDIS_URL: "" # redis://default:PASSWORD@host:port
|
|
||||||
# 用途:① OTP 验证码 5min TTL 存储 ② 限流计数(手机号 60s/IP 5min 等)
|
|
||||||
```
|
|
||||||
|
|
||||||
如果还没有 Redis 实例,可以先用火山 Redis(同地域低延迟)或自建。**最低规格够用**,QPS 不大。
|
|
||||||
|
|
||||||
#### B.4.2 MySQL
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
DATABASE_URL: "" # mysql://user:pwd@host:port/db?charset=utf8mb4
|
|
||||||
# 用途:用户表 / 投票记录 / 应援关系 / 每日额度
|
|
||||||
```
|
|
||||||
|
|
||||||
- 火山 RDS for MySQL 8 或自建均可
|
|
||||||
- 第一次部署需要跑:
|
|
||||||
```bash
|
|
||||||
npx prisma migrate deploy
|
|
||||||
npx prisma db seed # 灌 36 位艺人 + ActivityConfig(活动开关 / 起止时间 / 每日额度)
|
|
||||||
```
|
|
||||||
|
|
||||||
#### B.4.3 NextAuth
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
AUTH_SECRET: "" # openssl rand -base64 32 生成
|
|
||||||
AUTH_URL: "" # 例:https://cyber-star.airlabs.art(生产域名,不含末尾斜杠)
|
|
||||||
```
|
|
||||||
|
|
||||||
### B.5 后端要做的代码改动(仅 1 处,~10 行)
|
|
||||||
|
|
||||||
`src/app/api/auth/send-otp/route.ts` 第 41-53 行现在长这样:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const redis = getRedis();
|
|
||||||
if (redis) {
|
|
||||||
await redis.set(`sms:otp:${phone}`, code, "EX", 300);
|
|
||||||
}
|
|
||||||
|
|
||||||
// TODO[团队]: 接入真实短信服务(阿里云 / 火山引擎 SMS)
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
console.log(`[dev-otp] 发送给 ${phone}: ${code}`);
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
要改成调阿里云 SDK 真实发短信。**这一步前端(Claude)可以代写**,需要后端确认接 SDK 用 `@alicloud/dysmsapi20170525` 还是 `@alicloud/pop-core`,或者后端自己写也行。
|
|
||||||
|
|
||||||
### B.6 验收
|
|
||||||
|
|
||||||
- [ ] 真实手机号点「获取验证码」,30 秒内收到短信,内容包含 6 位数字
|
|
||||||
- [ ] 验证码填回去能登录成功,导航栏显示头像
|
|
||||||
- [ ] 60 秒内对同一手机号再次请求,接口返回 429(限流生效)
|
|
||||||
- [ ] OTP 5 分钟后失效
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## C. 回填模板(**直接复制这一块改值,整段返回给前端**)
|
|
||||||
|
|
||||||
```yaml
|
|
||||||
# ───── A. TOS 资源桶 ─────
|
|
||||||
NEXT_PUBLIC_TOS_DOMAIN: ""
|
|
||||||
PUBLIC_READ: ""
|
|
||||||
CDN_ENABLED: ""
|
|
||||||
CDN_DOMAIN: ""
|
|
||||||
|
|
||||||
# ───── B. 阿里云短信 ─────
|
|
||||||
SMS_SIGN_NAME: ""
|
|
||||||
SMS_TEMPLATE_CODE: ""
|
|
||||||
SMS_REGION: ""
|
|
||||||
|
|
||||||
# ───── B.4 基础依赖 ─────
|
|
||||||
REDIS_URL: ""
|
|
||||||
DATABASE_URL: ""
|
|
||||||
AUTH_SECRET: ""
|
|
||||||
AUTH_URL: ""
|
|
||||||
|
|
||||||
# ───── 备注(其他需要前端知道的事情)─────
|
|
||||||
# 例如:访问令牌怎么续期、桶有没有 Referer 白名单、SMS 模板审核状态等
|
|
||||||
NOTES: |
|
|
||||||
|
|
||||||
```
|
|
||||||
|
|
||||||
**敏感信息(AK/SK)请单独走密钥管理或加密渠道,不要写在这个文件里。**
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## D. 时间线建议
|
|
||||||
|
|
||||||
```
|
|
||||||
Day 0 (今天) ─ 后端 / 运维拿到这份文档,开始走流程
|
|
||||||
Day 1 ─ 阿里云提交签名 / 模板审核(等 1-2 天)
|
|
||||||
─ 创建 TOS 桶 + CDN 域名
|
|
||||||
─ 部署 MySQL / Redis 实例
|
|
||||||
Day 1-3 ─ 前端这边:本地压缩 1GB 资源 → 输出到 assets-compressed/
|
|
||||||
─ 前端代码适配 NEXT_PUBLIC_TOS_DOMAIN 占位
|
|
||||||
Day 2-3 ─ 短信审核通过 → 回填模板 + AK
|
|
||||||
─ 资源上传到 TOS
|
|
||||||
Day 3 ─ 联调:真实手机号收验证码,登录,投票,资源走 CDN
|
|
||||||
Day 4 ─ 上线
|
|
||||||
```
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
apiVersion: networking.k8s.io/v1
|
|
||||||
kind: Ingress
|
|
||||||
metadata:
|
|
||||||
name: cyberstar-ingress
|
|
||||||
annotations:
|
|
||||||
kubernetes.io/ingress.class: "traefik"
|
|
||||||
cert-manager.io/cluster-issuer: "letsencrypt-prod"
|
|
||||||
spec:
|
|
||||||
tls:
|
|
||||||
- hosts:
|
|
||||||
- cyberstar.airlabs.art
|
|
||||||
secretName: cyberstar-tls
|
|
||||||
rules:
|
|
||||||
- host: cyberstar.airlabs.art
|
|
||||||
http:
|
|
||||||
paths:
|
|
||||||
- path: /
|
|
||||||
pathType: Prefix
|
|
||||||
backend:
|
|
||||||
service:
|
|
||||||
name: cyberstar-web
|
|
||||||
port:
|
|
||||||
number: 80
|
|
||||||
@ -1,75 +0,0 @@
|
|||||||
apiVersion: apps/v1
|
|
||||||
kind: Deployment
|
|
||||||
metadata:
|
|
||||||
name: cyberstar-web
|
|
||||||
labels:
|
|
||||||
app: cyberstar-web
|
|
||||||
spec:
|
|
||||||
replicas: 1
|
|
||||||
selector:
|
|
||||||
matchLabels:
|
|
||||||
app: cyberstar-web
|
|
||||||
template:
|
|
||||||
metadata:
|
|
||||||
labels:
|
|
||||||
app: cyberstar-web
|
|
||||||
spec:
|
|
||||||
imagePullSecrets:
|
|
||||||
- name: cr-pull-secret
|
|
||||||
containers:
|
|
||||||
- name: cyberstar-web
|
|
||||||
image: ${CI_REGISTRY_IMAGE}/cyberstar-web:latest
|
|
||||||
imagePullPolicy: Always
|
|
||||||
ports:
|
|
||||||
- containerPort: 3000
|
|
||||||
env:
|
|
||||||
- name: NODE_ENV
|
|
||||||
value: "production"
|
|
||||||
- name: PORT
|
|
||||||
value: "3000"
|
|
||||||
- name: HOSTNAME
|
|
||||||
value: "0.0.0.0"
|
|
||||||
- name: AUTH_URL
|
|
||||||
value: "https://cyberstar.airlabs.art"
|
|
||||||
- name: AUTH_TRUST_HOST
|
|
||||||
value: "true"
|
|
||||||
# 敏感配置 / 第三方凭据从 Secret 注入(部署前需 kubectl create secret generic cyberstar-env --from-env-file=.env)
|
|
||||||
envFrom:
|
|
||||||
- secretRef:
|
|
||||||
name: cyberstar-env
|
|
||||||
optional: true
|
|
||||||
livenessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 3000
|
|
||||||
initialDelaySeconds: 30
|
|
||||||
periodSeconds: 10
|
|
||||||
timeoutSeconds: 5
|
|
||||||
failureThreshold: 3
|
|
||||||
readinessProbe:
|
|
||||||
httpGet:
|
|
||||||
path: /
|
|
||||||
port: 3000
|
|
||||||
initialDelaySeconds: 10
|
|
||||||
periodSeconds: 5
|
|
||||||
timeoutSeconds: 3
|
|
||||||
failureThreshold: 3
|
|
||||||
resources:
|
|
||||||
requests:
|
|
||||||
memory: "256Mi"
|
|
||||||
cpu: "200m"
|
|
||||||
limits:
|
|
||||||
memory: "1024Mi"
|
|
||||||
cpu: "1000m"
|
|
||||||
---
|
|
||||||
apiVersion: v1
|
|
||||||
kind: Service
|
|
||||||
metadata:
|
|
||||||
name: cyberstar-web
|
|
||||||
spec:
|
|
||||||
selector:
|
|
||||||
app: cyberstar-web
|
|
||||||
ports:
|
|
||||||
- protocol: TCP
|
|
||||||
port: 80
|
|
||||||
targetPort: 3000
|
|
||||||
@ -3,21 +3,6 @@ import type { NextConfig } from "next";
|
|||||||
const nextConfig: NextConfig = {
|
const nextConfig: NextConfig = {
|
||||||
// 关闭左下角的开发指示器(dev overlay 角标)
|
// 关闭左下角的开发指示器(dev overlay 角标)
|
||||||
devIndicators: false,
|
devIndicators: false,
|
||||||
// 容器化部署:产出精简的 standalone 包(node server.js 启动)
|
|
||||||
output: "standalone",
|
|
||||||
// next/image 配置
|
|
||||||
// - unoptimized: 关闭服务端 image 优化代理。资源已在 TOS 桶里是 WebP + 适配尺寸,
|
|
||||||
// 走 CDN,无需 Next.js 再编码一次。还能绕过 dev 时 fake-IP 代理(Clash/V2Ray TUN
|
|
||||||
// 返回 198.18.x.x)被 Next 16 拒绝 fetch 的问题。
|
|
||||||
// - remotePatterns: 即便不优化,Next 16 仍会校验远程域名白名单,保留 TOS + CDN 域名。
|
|
||||||
images: {
|
|
||||||
unoptimized: true,
|
|
||||||
remotePatterns: [
|
|
||||||
{ protocol: "https", hostname: "*.tos-cn-shanghai.volces.com" },
|
|
||||||
{ protocol: "https", hostname: "*.tos-cn-beijing.volces.com" },
|
|
||||||
{ protocol: "https", hostname: "*.volccdn.com" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default nextConfig;
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "cyber-star",
|
"name": "cyber-star",
|
||||||
"version": "0.3.4",
|
"version": "0.1.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "next dev",
|
"dev": "next dev",
|
||||||
@ -14,9 +14,6 @@
|
|||||||
"db:seed": "tsx prisma/seed.ts"
|
"db:seed": "tsx prisma/seed.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@alicloud/dysmsapi20170525": "^4.5.1",
|
|
||||||
"@alicloud/openapi-client": "^0.4.15",
|
|
||||||
"@alicloud/tea-util": "^1.4.11",
|
|
||||||
"@auth/prisma-adapter": "^2.11.2",
|
"@auth/prisma-adapter": "^2.11.2",
|
||||||
"@prisma/client": "^6.19.3",
|
"@prisma/client": "^6.19.3",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|||||||
302
pnpm-lock.yaml
generated
@ -8,15 +8,6 @@ importers:
|
|||||||
|
|
||||||
.:
|
.:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@alicloud/dysmsapi20170525':
|
|
||||||
specifier: ^4.5.1
|
|
||||||
version: 4.5.1
|
|
||||||
'@alicloud/openapi-client':
|
|
||||||
specifier: ^0.4.15
|
|
||||||
version: 0.4.15
|
|
||||||
'@alicloud/tea-util':
|
|
||||||
specifier: ^1.4.11
|
|
||||||
version: 1.4.11
|
|
||||||
'@auth/prisma-adapter':
|
'@auth/prisma-adapter':
|
||||||
specifier: ^2.11.2
|
specifier: ^2.11.2
|
||||||
version: 2.11.2(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3))
|
version: 2.11.2(@prisma/client@6.19.3(prisma@6.19.3(typescript@6.0.3))(typescript@6.0.3))
|
||||||
@ -93,60 +84,6 @@ importers:
|
|||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@alicloud/credentials@2.4.4':
|
|
||||||
resolution: {integrity: sha512-/eRAGSKcniLIFQ1UCpDhB/IrHUZisQ1sc65ws/c2avxUMpXwH1rWAohb76SVAUJhiF4mwvLzLJM1Mn1XL4Xe/Q==}
|
|
||||||
|
|
||||||
'@alicloud/darabonba-array@0.1.2':
|
|
||||||
resolution: {integrity: sha512-ZPuQ+bJyjrd8XVVm55kl+ypk7OQoi1ZH/DiToaAEQaGvgEjrTcvQkg71//vUX/6cvbLIF5piQDvhrLb+lUEIPQ==}
|
|
||||||
|
|
||||||
'@alicloud/darabonba-encode-util@0.0.1':
|
|
||||||
resolution: {integrity: sha512-Sl5vCRVAYMqwmvXpJLM9hYoCHOMsQlGxaWSGhGWulpKk/NaUBArtoO1B0yHruJf1C5uHhEJIaylYcM48icFHgw==}
|
|
||||||
|
|
||||||
'@alicloud/darabonba-encode-util@0.0.2':
|
|
||||||
resolution: {integrity: sha512-mlsNctkeqmR0RtgE1Rngyeadi5snLOAHBCWEtYf68d7tyKskosXDTNeZ6VCD/UfrUu4N51ItO8zlpfXiOgeg3A==}
|
|
||||||
|
|
||||||
'@alicloud/darabonba-map@0.0.1':
|
|
||||||
resolution: {integrity: sha512-2ep+G3YDvuI+dRYVlmER1LVUQDhf9kEItmVB/bbEu1pgKzelcocCwAc79XZQjTcQGFgjDycf3vH87WLDGLFMlw==}
|
|
||||||
|
|
||||||
'@alicloud/darabonba-signature-util@0.0.4':
|
|
||||||
resolution: {integrity: sha512-I1TtwtAnzLamgqnAaOkN0IGjwkiti//0a7/auyVThdqiC/3kyafSAn6znysWOmzub4mrzac2WiqblZKFcN5NWg==}
|
|
||||||
|
|
||||||
'@alicloud/darabonba-string@1.0.3':
|
|
||||||
resolution: {integrity: sha512-NyWwrU8cAIesWk3uHL1Q7pTDTqLkCI/0PmJXC4/4A0MFNAZ9Ouq0iFBsRqvfyUujSSM+WhYLuTfakQXiVLkTMA==}
|
|
||||||
|
|
||||||
'@alicloud/dysmsapi20170525@4.5.1':
|
|
||||||
resolution: {integrity: sha512-6l0N6M+uV2l+KxF5XOiZ3DZoDlrZ2ZxFDKkr424KJm0A6kSsQlRNV5eiJoMkB6h11KYf0BD5/0lXzW8SkPtGMA==}
|
|
||||||
|
|
||||||
'@alicloud/endpoint-util@0.0.1':
|
|
||||||
resolution: {integrity: sha512-+pH7/KEXup84cHzIL6UJAaPqETvln4yXlD9JzlrqioyCSaWxbug5FUobsiI6fuUOpw5WwoB3fWAtGbFnJ1K3Yg==}
|
|
||||||
|
|
||||||
'@alicloud/gateway-pop@0.0.6':
|
|
||||||
resolution: {integrity: sha512-KF4I+JvfYuLKc3fWeWYIZ7lOVJ9jRW0sQXdXidZn1DKZ978ncfGf7i0LBfONGk4OxvNb/HD3/0yYhkgZgPbKtA==}
|
|
||||||
|
|
||||||
'@alicloud/gateway-spi@0.0.8':
|
|
||||||
resolution: {integrity: sha512-KM7fu5asjxZPmrz9sJGHJeSU+cNQNOxW+SFmgmAIrITui5hXL2LB+KNRuzWmlwPjnuA2X3/keq9h6++S9jcV5g==}
|
|
||||||
|
|
||||||
'@alicloud/openapi-client@0.4.15':
|
|
||||||
resolution: {integrity: sha512-4VE0/k5ZdQbAhOSTqniVhuX1k5DUeUMZv74degn3wIWjLY6Bq+hxjaGsaHYlLZ2gA5wUrs8NcI5TE+lIQS3iiA==}
|
|
||||||
|
|
||||||
'@alicloud/openapi-core@1.0.7':
|
|
||||||
resolution: {integrity: sha512-I80PQVfmlzRiXGHwutMp2zTpiqUVv8ts30nWAfksfHUSTIapk3nj9IXaPbULMPGNV6xqEyshO2bj2a+pmwc2tQ==}
|
|
||||||
|
|
||||||
'@alicloud/openapi-util@0.3.3':
|
|
||||||
resolution: {integrity: sha512-vf0cQ/q8R2U7ZO88X5hDiu1yV3t/WexRj+YycWxRutkH/xVXfkmpRgps8lmNEk7Ar+0xnY8+daN2T+2OyB9F4A==}
|
|
||||||
|
|
||||||
'@alicloud/tea-typescript@1.8.0':
|
|
||||||
resolution: {integrity: sha512-CWXWaquauJf0sW30mgJRVu9aaXyBth5uMBCUc+5vKTK1zlgf3hIqRUjJZbjlwHwQ5y9anwcu18r48nOZb7l2QQ==}
|
|
||||||
|
|
||||||
'@alicloud/tea-util@1.4.11':
|
|
||||||
resolution: {integrity: sha512-HyPEEQ8F0WoZegiCp7sVdrdm6eBOB+GCvGl4182u69LDFktxfirGLcAx3WExUr1zFWkq2OSmBroTwKQ4w/+Yww==}
|
|
||||||
|
|
||||||
'@alicloud/tea-util@1.4.9':
|
|
||||||
resolution: {integrity: sha512-S0wz76rGtoPKskQtRTGqeuqBHFj8BqUn0Vh+glXKun2/9UpaaaWmuJwcmtImk6bJZfLYEShDF/kxDmDJoNYiTw==}
|
|
||||||
|
|
||||||
'@alicloud/tea-xml@0.0.3':
|
|
||||||
resolution: {integrity: sha512-+/9GliugjrLglsXVrd1D80EqqKgGpyA0eQ6+1ZdUOYCaRguaSwz44trX3PaxPu/HhIPJg9PsGQQ3cSLXWZjbAA==}
|
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0':
|
'@alloc/quick-lru@5.2.0':
|
||||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@ -237,9 +174,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
resolution: {integrity: sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==}
|
||||||
engines: {node: '>=6.9.0'}
|
engines: {node: '>=6.9.0'}
|
||||||
|
|
||||||
'@darabonba/typescript@1.0.4':
|
|
||||||
resolution: {integrity: sha512-icl8RGTw4DiWRpco6dVh21RS0IqrH4s/eEV36TZvz/e1+paogSZjaAgox7ByrlEuvG+bo5d8miq/dRlqiUaL/w==}
|
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==}
|
||||||
|
|
||||||
@ -866,15 +800,9 @@ packages:
|
|||||||
'@types/json5@0.0.29':
|
'@types/json5@0.0.29':
|
||||||
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==}
|
||||||
|
|
||||||
'@types/node@12.0.2':
|
|
||||||
resolution: {integrity: sha512-5tabW/i+9mhrfEOUcLDu2xBPsHJ+X5Orqy9FKpale3SjDA17j5AEpYq5vfy3oAeAHGcvANRCO3NV3d2D6q3NiA==}
|
|
||||||
|
|
||||||
'@types/node@20.19.40':
|
'@types/node@20.19.40':
|
||||||
resolution: {integrity: sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==}
|
resolution: {integrity: sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==}
|
||||||
|
|
||||||
'@types/node@22.19.19':
|
|
||||||
resolution: {integrity: sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==}
|
|
||||||
|
|
||||||
'@types/react-dom@19.2.3':
|
'@types/react-dom@19.2.3':
|
||||||
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
resolution: {integrity: sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
@ -883,9 +811,6 @@ packages:
|
|||||||
'@types/react@19.2.14':
|
'@types/react@19.2.14':
|
||||||
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
resolution: {integrity: sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==}
|
||||||
|
|
||||||
'@types/xml2js@0.4.14':
|
|
||||||
resolution: {integrity: sha512-4YnrRemBShWRO2QjvUin8ESA41rH+9nQGLUGZV/1IDhi3SL9OhdpNC/MrulTWuptXKwhx/aDxE7toV0f/ypIXQ==}
|
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.2':
|
'@typescript-eslint/eslint-plugin@8.59.2':
|
||||||
resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==}
|
resolution: {integrity: sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==}
|
||||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||||
@ -1691,9 +1616,6 @@ packages:
|
|||||||
hermes-parser@0.25.1:
|
hermes-parser@0.25.1:
|
||||||
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
resolution: {integrity: sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==}
|
||||||
|
|
||||||
httpx@2.3.3:
|
|
||||||
resolution: {integrity: sha512-k1qv94u1b6e+XKCxVbLgYlOypVP9MPGpnN5G/vxFf6tDO4V3xpz3d6FUOY/s8NtPgaq5RBVVgSB+7IHpVxMYzw==}
|
|
||||||
|
|
||||||
ignore@5.3.2:
|
ignore@5.3.2:
|
||||||
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==}
|
||||||
engines: {node: '>= 4'}
|
engines: {node: '>= 4'}
|
||||||
@ -1710,10 +1632,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==}
|
||||||
engines: {node: '>=0.8.19'}
|
engines: {node: '>=0.8.19'}
|
||||||
|
|
||||||
ini@1.3.5:
|
|
||||||
resolution: {integrity: sha512-RZY5huIKCMRWDUqZlEi72f/lmXKMvuszcMBduliQ3nnWbx9X/ZBQO7DijMEYS9EhHBb2qacRUMtC7svLwe0lcw==}
|
|
||||||
deprecated: Please update to ini >=1.3.6 to avoid a prototype pollution issue
|
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@ -1890,9 +1808,6 @@ packages:
|
|||||||
keyv@4.5.4:
|
keyv@4.5.4:
|
||||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||||
|
|
||||||
kitx@2.2.0:
|
|
||||||
resolution: {integrity: sha512-tBMwe6AALTBQJb0woQDD40734NKzb0Kzi3k7wQj9ar3AbP9oqhoVrdXPh7rk2r00/glIgd0YbToIUJsnxWMiIg==}
|
|
||||||
|
|
||||||
language-subtag-registry@0.3.20:
|
language-subtag-registry@0.3.20:
|
||||||
resolution: {integrity: sha512-KPMwROklF4tEx283Xw0pNKtfTj1gZ4UByp4EsIFWLgBavJltF4TiYPc39k06zSTsLzxTVXXDSpbwaQXaFB4Qeg==}
|
resolution: {integrity: sha512-KPMwROklF4tEx283Xw0pNKtfTj1gZ4UByp4EsIFWLgBavJltF4TiYPc39k06zSTsLzxTVXXDSpbwaQXaFB4Qeg==}
|
||||||
|
|
||||||
@ -1991,9 +1906,6 @@ packages:
|
|||||||
lodash.merge@4.6.2:
|
lodash.merge@4.6.2:
|
||||||
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==}
|
||||||
|
|
||||||
lodash@4.18.1:
|
|
||||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
@ -2043,12 +1955,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
engines: {node: '>=16 || 14 >=14.17'}
|
||||||
|
|
||||||
moment-timezone@0.5.45:
|
|
||||||
resolution: {integrity: sha512-HIWmqA86KcmCAhnMAN0wuDOARV/525R2+lOLotuGFzn4HO+FH+/645z2wx0Dt3iDv6/p61SIvKnDstISainhLQ==}
|
|
||||||
|
|
||||||
moment@2.30.1:
|
|
||||||
resolution: {integrity: sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==}
|
|
||||||
|
|
||||||
motion-dom@12.38.0:
|
motion-dom@12.38.0:
|
||||||
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
|
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
|
||||||
|
|
||||||
@ -2348,10 +2254,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
resolution: {integrity: sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
||||||
sax@1.6.0:
|
|
||||||
resolution: {integrity: sha512-6R3J5M4AcbtLUdZmRv2SygeVaM7IhrLXu9BmnOGmmACak8fiUtOsYNWUS4uK7upbmHIBbLBeFeI//477BKLBzA==}
|
|
||||||
engines: {node: '>=11.0.0'}
|
|
||||||
|
|
||||||
scheduler@0.27.0:
|
scheduler@0.27.0:
|
||||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||||
|
|
||||||
@ -2408,9 +2310,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
|
|
||||||
sm3@1.0.3:
|
|
||||||
resolution: {integrity: sha512-KyFkIfr8QBlFG3uc3NaljaXdYcsbRy1KrSfc4tsQV8jW68jAktGeOcifu530Vx/5LC+PULHT0Rv8LiI8Gw+c1g==}
|
|
||||||
|
|
||||||
source-map-js@1.2.1:
|
source-map-js@1.2.1:
|
||||||
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
@ -2616,14 +2515,6 @@ packages:
|
|||||||
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
xml2js@0.6.2:
|
|
||||||
resolution: {integrity: sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA==}
|
|
||||||
engines: {node: '>=4.0.0'}
|
|
||||||
|
|
||||||
xmlbuilder@11.0.1:
|
|
||||||
resolution: {integrity: sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==}
|
|
||||||
engines: {node: '>=4.0'}
|
|
||||||
|
|
||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
@ -2656,146 +2547,6 @@ packages:
|
|||||||
|
|
||||||
snapshots:
|
snapshots:
|
||||||
|
|
||||||
'@alicloud/credentials@2.4.4':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
httpx: 2.3.3
|
|
||||||
ini: 1.3.5
|
|
||||||
kitx: 2.2.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/darabonba-array@0.1.2':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/darabonba-encode-util@0.0.1':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
moment: 2.30.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/darabonba-encode-util@0.0.2':
|
|
||||||
dependencies:
|
|
||||||
moment: 2.30.1
|
|
||||||
|
|
||||||
'@alicloud/darabonba-map@0.0.1':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/darabonba-signature-util@0.0.4':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/darabonba-encode-util': 0.0.1
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/darabonba-string@1.0.3':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/dysmsapi20170525@4.5.1':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/openapi-core': 1.0.7
|
|
||||||
'@darabonba/typescript': 1.0.4
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/endpoint-util@0.0.1':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
kitx: 2.2.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/gateway-pop@0.0.6':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/credentials': 2.4.4
|
|
||||||
'@alicloud/darabonba-array': 0.1.2
|
|
||||||
'@alicloud/darabonba-encode-util': 0.0.2
|
|
||||||
'@alicloud/darabonba-map': 0.0.1
|
|
||||||
'@alicloud/darabonba-signature-util': 0.0.4
|
|
||||||
'@alicloud/darabonba-string': 1.0.3
|
|
||||||
'@alicloud/endpoint-util': 0.0.1
|
|
||||||
'@alicloud/gateway-spi': 0.0.8
|
|
||||||
'@alicloud/openapi-util': 0.3.3
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
'@alicloud/tea-util': 1.4.11
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/gateway-spi@0.0.8':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/credentials': 2.4.4
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/openapi-client@0.4.15':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/credentials': 2.4.4
|
|
||||||
'@alicloud/gateway-spi': 0.0.8
|
|
||||||
'@alicloud/openapi-util': 0.3.3
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
'@alicloud/tea-util': 1.4.9
|
|
||||||
'@alicloud/tea-xml': 0.0.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/openapi-core@1.0.7':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/credentials': 2.4.4
|
|
||||||
'@alicloud/gateway-pop': 0.0.6
|
|
||||||
'@alicloud/gateway-spi': 0.0.8
|
|
||||||
'@darabonba/typescript': 1.0.4
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/openapi-util@0.3.3':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
'@alicloud/tea-util': 1.4.11
|
|
||||||
kitx: 2.2.0
|
|
||||||
sm3: 1.0.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/tea-typescript@1.8.0':
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 12.0.2
|
|
||||||
httpx: 2.3.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/tea-util@1.4.11':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
'@darabonba/typescript': 1.0.4
|
|
||||||
kitx: 2.2.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/tea-util@1.4.9':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
kitx: 2.2.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alicloud/tea-xml@0.0.3':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
'@types/xml2js': 0.4.14
|
|
||||||
xml2js: 0.6.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@alloc/quick-lru@5.2.0': {}
|
'@alloc/quick-lru@5.2.0': {}
|
||||||
|
|
||||||
'@auth/core@0.41.2':
|
'@auth/core@0.41.2':
|
||||||
@ -2915,17 +2666,6 @@ snapshots:
|
|||||||
'@babel/helper-string-parser': 7.27.1
|
'@babel/helper-string-parser': 7.27.1
|
||||||
'@babel/helper-validator-identifier': 7.28.5
|
'@babel/helper-validator-identifier': 7.28.5
|
||||||
|
|
||||||
'@darabonba/typescript@1.0.4':
|
|
||||||
dependencies:
|
|
||||||
'@alicloud/tea-typescript': 1.8.0
|
|
||||||
httpx: 2.3.3
|
|
||||||
lodash: 4.18.1
|
|
||||||
moment: 2.30.1
|
|
||||||
moment-timezone: 0.5.45
|
|
||||||
xml2js: 0.6.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@emnapi/core@1.10.0':
|
'@emnapi/core@1.10.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@emnapi/wasi-threads': 1.2.1
|
'@emnapi/wasi-threads': 1.2.1
|
||||||
@ -3388,16 +3128,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/json5@0.0.29': {}
|
'@types/json5@0.0.29': {}
|
||||||
|
|
||||||
'@types/node@12.0.2': {}
|
|
||||||
|
|
||||||
'@types/node@20.19.40':
|
'@types/node@20.19.40':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
|
|
||||||
'@types/node@22.19.19':
|
|
||||||
dependencies:
|
|
||||||
undici-types: 6.21.0
|
|
||||||
|
|
||||||
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
'@types/react-dom@19.2.3(@types/react@19.2.14)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/react': 19.2.14
|
'@types/react': 19.2.14
|
||||||
@ -3406,10 +3140,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
csstype: 3.2.3
|
csstype: 3.2.3
|
||||||
|
|
||||||
'@types/xml2js@0.4.14':
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 20.19.40
|
|
||||||
|
|
||||||
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)':
|
'@typescript-eslint/eslint-plugin@8.59.2(@typescript-eslint/parser@8.59.2(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3))(eslint@9.39.4(jiti@2.7.0))(typescript@6.0.3)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@eslint-community/regexpp': 4.12.2
|
'@eslint-community/regexpp': 4.12.2
|
||||||
@ -4385,13 +4115,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
hermes-estree: 0.25.1
|
hermes-estree: 0.25.1
|
||||||
|
|
||||||
httpx@2.3.3:
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 20.19.40
|
|
||||||
debug: 4.4.3
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
ignore@5.3.2: {}
|
ignore@5.3.2: {}
|
||||||
|
|
||||||
ignore@7.0.5: {}
|
ignore@7.0.5: {}
|
||||||
@ -4403,8 +4126,6 @@ snapshots:
|
|||||||
|
|
||||||
imurmurhash@0.1.4: {}
|
imurmurhash@0.1.4: {}
|
||||||
|
|
||||||
ini@1.3.5: {}
|
|
||||||
|
|
||||||
internal-slot@1.1.0:
|
internal-slot@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@ -4595,10 +4316,6 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer: 3.0.1
|
json-buffer: 3.0.1
|
||||||
|
|
||||||
kitx@2.2.0:
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 22.19.19
|
|
||||||
|
|
||||||
language-subtag-registry@0.3.20: {}
|
language-subtag-registry@0.3.20: {}
|
||||||
|
|
||||||
language-tags@1.0.9:
|
language-tags@1.0.9:
|
||||||
@ -4669,8 +4386,6 @@ snapshots:
|
|||||||
|
|
||||||
lodash.merge@4.6.2: {}
|
lodash.merge@4.6.2: {}
|
||||||
|
|
||||||
lodash@4.18.1: {}
|
|
||||||
|
|
||||||
loose-envify@1.4.0:
|
loose-envify@1.4.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
js-tokens: 3.0.0
|
js-tokens: 3.0.0
|
||||||
@ -4714,12 +4429,6 @@ snapshots:
|
|||||||
|
|
||||||
minipass@7.1.3: {}
|
minipass@7.1.3: {}
|
||||||
|
|
||||||
moment-timezone@0.5.45:
|
|
||||||
dependencies:
|
|
||||||
moment: 2.30.1
|
|
||||||
|
|
||||||
moment@2.30.1: {}
|
|
||||||
|
|
||||||
motion-dom@12.38.0:
|
motion-dom@12.38.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
motion-utils: 12.36.0
|
motion-utils: 12.36.0
|
||||||
@ -5019,8 +4728,6 @@ snapshots:
|
|||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
is-regex: 1.2.1
|
is-regex: 1.2.1
|
||||||
|
|
||||||
sax@1.6.0: {}
|
|
||||||
|
|
||||||
scheduler@0.27.0: {}
|
scheduler@0.27.0: {}
|
||||||
|
|
||||||
semver@6.3.1: {}
|
semver@6.3.1: {}
|
||||||
@ -5117,8 +4824,6 @@ snapshots:
|
|||||||
|
|
||||||
signal-exit@4.1.0: {}
|
signal-exit@4.1.0: {}
|
||||||
|
|
||||||
sm3@1.0.3: {}
|
|
||||||
|
|
||||||
source-map-js@1.2.1: {}
|
source-map-js@1.2.1: {}
|
||||||
|
|
||||||
stable-hash@0.0.5: {}
|
stable-hash@0.0.5: {}
|
||||||
@ -5406,13 +5111,6 @@ snapshots:
|
|||||||
string-width: 5.1.2
|
string-width: 5.1.2
|
||||||
strip-ansi: 7.2.0
|
strip-ansi: 7.2.0
|
||||||
|
|
||||||
xml2js@0.6.2:
|
|
||||||
dependencies:
|
|
||||||
sax: 1.6.0
|
|
||||||
xmlbuilder: 11.0.1
|
|
||||||
|
|
||||||
xmlbuilder@11.0.1: {}
|
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
zod-validation-error@4.0.2(zod@4.4.3):
|
zod-validation-error@4.0.2(zod@4.4.3):
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
allowBuilds:
|
allowBuilds:
|
||||||
'@alicloud/openapi-core': false
|
|
||||||
'@prisma/client': true
|
'@prisma/client': true
|
||||||
'@prisma/engines': true
|
'@prisma/engines': true
|
||||||
esbuild: true
|
esbuild: true
|
||||||
|
|||||||
@ -1,20 +0,0 @@
|
|||||||
-- =====================================================
|
|
||||||
-- 投票模型从"每日额度"切换为"终身 12 票 + 每艺人 1 票"
|
|
||||||
-- 生成时间: 2026-05-15
|
|
||||||
-- 影响: votes 表
|
|
||||||
-- =====================================================
|
|
||||||
|
|
||||||
-- 1. 先加唯一约束:每个用户对每个艺人最多 1 票(DB 层硬约束)
|
|
||||||
-- 生产探查确认零重复 (userId, artistId),加约束不会失败
|
|
||||||
-- leading column 是 user_id,可作为 FK(votes.user_id → users.id)的索引基础
|
|
||||||
ALTER TABLE `votes` ADD UNIQUE INDEX `votes_user_id_artist_id_key` (`user_id`, `artist_id`);
|
|
||||||
|
|
||||||
-- 2. 删除旧的非唯一复合索引(userId, artistId, createdAt)
|
|
||||||
-- 新 unique 索引以 user_id 开头,已经能满足 FK 约束的 leading 索引要求
|
|
||||||
ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_created_at_idx`;
|
|
||||||
|
|
||||||
-- =====================================================
|
|
||||||
-- 回滚 SQL(如需撤销)
|
|
||||||
-- ALTER TABLE `votes` ADD INDEX `votes_user_id_artist_id_created_at_idx` (`user_id`, `artist_id`, `created_at`);
|
|
||||||
-- ALTER TABLE `votes` DROP INDEX `votes_user_id_artist_id_key`;
|
|
||||||
-- =====================================================
|
|
||||||
@ -4,10 +4,7 @@
|
|||||||
// =============================================================
|
// =============================================================
|
||||||
|
|
||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
// native:本地开发(macOS / linux-glibc)
|
|
||||||
// linux-musl-openssl-3.0.x:容器运行时(node:22-alpine)
|
|
||||||
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
datasource db {
|
datasource db {
|
||||||
@ -19,39 +16,25 @@ datasource db {
|
|||||||
// 艺人 · 候选偶像
|
// 艺人 · 候选偶像
|
||||||
// =============================================================
|
// =============================================================
|
||||||
model Artist {
|
model Artist {
|
||||||
id String @id @db.VarChar(8) // 编号 001 ~ 036
|
id String @id @db.VarChar(8) // 编号 001 ~ 035
|
||||||
no String @unique @db.VarChar(8) // 展示用编号(带前置零)
|
no String @unique @db.VarChar(8) // 展示用编号(带前置零)
|
||||||
name String @db.VarChar(50) // 中文名
|
name String @db.VarChar(50) // 中文名
|
||||||
enName String @map("en_name") @db.VarChar(50) // 英文名
|
enName String @map("en_name") @db.VarChar(50) // 英文名
|
||||||
bio String @db.Text // 详细简介 / 人物小传
|
slogan String @db.VarChar(120) // 短宣传语
|
||||||
|
bio String @db.Text // 详细简介
|
||||||
|
birthday String @db.VarChar(8) // MM-DD
|
||||||
height Int @db.SmallInt // cm
|
height Int @db.SmallInt // cm
|
||||||
|
cv String? @db.VarChar(80) // CV 配音
|
||||||
// 人物小传字段(从《36 位虚拟艺人人物小传.docx》提取)
|
themeColor String @map("theme_color") @db.VarChar(10) // 应援色 hex
|
||||||
age Int? @db.SmallInt // 年龄
|
portrait String? @db.VarChar(500) // 立绘主图 URL
|
||||||
gender String? @db.VarChar(1) // 'M' / 'F' / 'N'
|
avatar String? @db.VarChar(500) // 圆形头像 URL
|
||||||
motto String? @db.VarChar(200) // 座右铭
|
videoUrl String? @map("video_url") @db.VarChar(500) // 15s 表演视频
|
||||||
personality String? @db.Text // 性格描述
|
videoPoster String? @map("video_poster") @db.VarChar(500) // 视频封面
|
||||||
catchphrase String? @db.VarChar(200) // 口头禅
|
tags Json @db.Json // string[] 标签数组
|
||||||
skills String? @db.VarChar(200) // 核心技能(顿号分隔)
|
|
||||||
track String? @db.VarChar(200) // 核心赛道(顿号分隔)
|
|
||||||
|
|
||||||
// 媒体资源(URL 走 TOS 桶 + CDN)
|
|
||||||
portrait String? @db.VarChar(500)
|
|
||||||
avatar String? @db.VarChar(500)
|
|
||||||
videoUrl String? @map("video_url") @db.VarChar(500)
|
|
||||||
videoPoster String? @map("video_poster") @db.VarChar(500)
|
|
||||||
|
|
||||||
// 历史遗留字段(前端已不再使用,保留为可选避免破坏既有数据)
|
|
||||||
slogan String? @db.VarChar(120)
|
|
||||||
birthday String? @db.VarChar(8) // MM-DD
|
|
||||||
cv String? @db.VarChar(80)
|
|
||||||
themeColor String? @map("theme_color") @db.VarChar(10)
|
|
||||||
|
|
||||||
tags Json @db.Json // ArtistTag[] · 现在是音乐流派 (rock/pop/chinese/hiphop/folk/jazz)
|
|
||||||
status ArtistStatus @default(ACTIVE)
|
status ArtistStatus @default(ACTIVE)
|
||||||
/// 缓存字段:当前票数。定期由后台聚合任务更新,避免实时 SUM(votes)。
|
/// 缓存字段:当前票数。定期由后台聚合任务更新,避免实时 SUM(votes)。
|
||||||
voteCount Int @default(0) @map("vote_count")
|
voteCount Int @default(0) @map("vote_count")
|
||||||
/// 缓存字段:当前排名。同上由后台计算。
|
/// 缓存字段:当前排名(1 ~ 35)。同上由后台计算。
|
||||||
currentRank Int? @map("current_rank")
|
currentRank Int? @map("current_rank")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@ -149,9 +132,8 @@ model Vote {
|
|||||||
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
artist Artist @relation(fields: [artistId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
@@map("votes")
|
@@map("votes")
|
||||||
// 投票规则:每用户对每艺人仅可投 1 票 —— DB 硬约束,防止并发绕过前端
|
// 关键索引:用户每日单艺人查询、艺人聚合
|
||||||
@@unique([userId, artistId])
|
@@index([userId, artistId, createdAt])
|
||||||
// 关键索引:艺人聚合 / 时序查询
|
|
||||||
@@index([artistId, createdAt])
|
@@index([artistId, createdAt])
|
||||||
@@index([createdAt])
|
@@index([createdAt])
|
||||||
}
|
}
|
||||||
|
|||||||
152
prisma/seed.ts
@ -1,28 +1,70 @@
|
|||||||
/**
|
/**
|
||||||
* Prisma 种子脚本 · 把 36 位真实艺人 + 活动配置写入 DB
|
* Prisma 种子脚本 · 用于初始化 35 位艺人 + 活动配置
|
||||||
*
|
|
||||||
* 数据来源:src/lib/artist-bios.ts (从《36 位虚拟艺人人物小传.docx》提取)
|
|
||||||
* 这份文件同时是前端 SSG 的静态源 + DB 的 seed 源,双写但单一来源,
|
|
||||||
* 改人物字段时只需要改 artist-bios.ts,然后重跑 seed。
|
|
||||||
*
|
*
|
||||||
* 运行:pnpm db:seed
|
* 运行:pnpm db:seed
|
||||||
*
|
|
||||||
* 注意:portrait / videoUrl 等 TOS URL 不写入 DB —— 前端从 NEXT_PUBLIC_TOS_DOMAIN
|
|
||||||
* 自动拼接,DB 字段保留为 NULL (灵活度高,换桶不需要重跑 seed)。
|
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { PrismaClient, Prisma } from "@prisma/client";
|
import { PrismaClient } from "@prisma/client";
|
||||||
import { ARTIST_SEEDS } from "../src/lib/artist-bios";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient();
|
const prisma = new PrismaClient();
|
||||||
|
|
||||||
|
interface SeedArtist {
|
||||||
|
no: string;
|
||||||
|
name: string;
|
||||||
|
enName: string;
|
||||||
|
slogan: string;
|
||||||
|
themeColor: string;
|
||||||
|
birthday: string;
|
||||||
|
height: number;
|
||||||
|
tags: string[];
|
||||||
|
hasCV: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STAGE_NAMES: SeedArtist[] = [
|
||||||
|
{ no: "001", name: "艺奈", enName: "AURORA", slogan: "破晓极光", themeColor: "#8b5cf6", birthday: "01-15", height: 165, tags: ["vocal", "visual"], hasCV: true },
|
||||||
|
{ no: "002", name: "路米", enName: "LUMI", slogan: "暖光治愈", themeColor: "#ec4899", birthday: "02-22", height: 163, tags: ["dance", "all-rounder"], hasCV: true },
|
||||||
|
{ no: "003", name: "星澪", enName: "NEBULA", slogan: "星云吟唱", themeColor: "#06b6d4", birthday: "03-08", height: 167, tags: ["rap", "leader"], hasCV: true },
|
||||||
|
{ no: "004", name: "凯", enName: "KAI", slogan: "海岸少年", themeColor: "#f59e0b", birthday: "04-12", height: 178, tags: ["all-rounder"], hasCV: true },
|
||||||
|
{ no: "005", name: "回音", enName: "ECHO", slogan: "声波女王", themeColor: "#10b981", birthday: "05-30", height: 164, tags: ["vocal", "leader"], hasCV: true },
|
||||||
|
{ no: "006", name: "薇尔", enName: "VEIL", slogan: "薄雾低语", themeColor: "#ef4444", birthday: "06-18", height: 162, tags: ["dance", "visual"], hasCV: true },
|
||||||
|
{ no: "007", name: "艾莉雅", enName: "ARIA", slogan: "咏叹之声", themeColor: "#a78bfa", birthday: "07-25", height: 168, tags: ["vocal"], hasCV: true },
|
||||||
|
{ no: "008", name: "怜", enName: "REN", slogan: "莲华少女", themeColor: "#f472b6", birthday: "08-09", height: 161, tags: ["rap", "all-rounder"], hasCV: true },
|
||||||
|
{ no: "009", name: "米拉", enName: "MIRA", slogan: "镜面舞者", themeColor: "#38bdf8", birthday: "09-14", height: 166, tags: ["dance"], hasCV: true },
|
||||||
|
{ no: "010", name: "诺娃", enName: "NOVA", slogan: "超新星", themeColor: "#fbbf24", birthday: "10-31", height: 165, tags: ["visual", "all-rounder"], hasCV: true },
|
||||||
|
{ no: "011", name: "纪罗", enName: "KIRO", slogan: "Rap 制造机", themeColor: "#34d399", birthday: "11-11", height: 175, tags: ["rap"], hasCV: true },
|
||||||
|
{ no: "012", name: "瑞", enName: "ZUI", slogan: "醉月夜", themeColor: "#fb7185", birthday: "12-24", height: 169, tags: ["vocal", "dance"], hasCV: true },
|
||||||
|
{ no: "013", name: "阳", enName: "SOL", slogan: "阳光少年", themeColor: "#fcd34d", birthday: "01-08", height: 172, tags: ["all-rounder"], hasCV: false },
|
||||||
|
{ no: "014", name: "凛", enName: "LIN", slogan: "学院偶像", themeColor: "#8b5cf6", birthday: "02-14", height: 168, tags: ["vocal"], hasCV: false },
|
||||||
|
{ no: "015", name: "律", enName: "LYRA", slogan: "竖琴公主", themeColor: "#a78bfa", birthday: "03-22", height: 164, tags: ["vocal", "visual"], hasCV: false },
|
||||||
|
{ no: "016", name: "昕", enName: "DAWN", slogan: "晨曦少女", themeColor: "#f472b6", birthday: "04-05", height: 166, tags: ["dance"], hasCV: false },
|
||||||
|
{ no: "017", name: "天", enName: "SKY", slogan: "天空之翼", themeColor: "#38bdf8", birthday: "05-19", height: 170, tags: ["all-rounder"], hasCV: false },
|
||||||
|
{ no: "018", name: "语", enName: "ARIE", slogan: "诗与远方", themeColor: "#10b981", birthday: "06-30", height: 163, tags: ["vocal"], hasCV: false },
|
||||||
|
{ no: "019", name: "翼", enName: "WING", slogan: "飞翔之翼", themeColor: "#ef4444", birthday: "07-15", height: 174, tags: ["dance", "all-rounder"], hasCV: false },
|
||||||
|
{ no: "020", name: "铃", enName: "CHIME", slogan: "风铃声", themeColor: "#fbbf24", birthday: "08-21", height: 162, tags: ["vocal"], hasCV: false },
|
||||||
|
{ no: "021", name: "夜", enName: "NYX", slogan: "暗夜女神", themeColor: "#7c3aed", birthday: "09-28", height: 167, tags: ["visual", "rap"], hasCV: false },
|
||||||
|
{ no: "022", name: "晴", enName: "SUNNY", slogan: "晴空万里", themeColor: "#facc15", birthday: "10-06", height: 165, tags: ["all-rounder"], hasCV: false },
|
||||||
|
{ no: "023", name: "月", enName: "LUNA", slogan: "月光女神", themeColor: "#c4b5fd", birthday: "11-25", height: 168, tags: ["vocal", "visual"], hasCV: false },
|
||||||
|
{ no: "024", name: "岚", enName: "STORM", slogan: "暴风之子", themeColor: "#0ea5e9", birthday: "12-13", height: 176, tags: ["rap"], hasCV: false },
|
||||||
|
{ no: "025", name: "雷", enName: "BOLT", slogan: "雷霆速度", themeColor: "#eab308", birthday: "01-29", height: 173, tags: ["dance"], hasCV: false },
|
||||||
|
{ no: "026", name: "焰", enName: "FLARE", slogan: "火焰之心", themeColor: "#dc2626", birthday: "02-08", height: 169, tags: ["all-rounder"], hasCV: false },
|
||||||
|
{ no: "027", name: "雪", enName: "FROST", slogan: "霜花少女", themeColor: "#e0e7ff", birthday: "03-15", height: 161, tags: ["vocal"], hasCV: false },
|
||||||
|
{ no: "028", name: "林", enName: "LEAF", slogan: "森林精灵", themeColor: "#22c55e", birthday: "04-22", height: 164, tags: ["dance", "all-rounder"], hasCV: false },
|
||||||
|
{ no: "029", name: "渊", enName: "ABYSS", slogan: "深渊之声", themeColor: "#1e293b", birthday: "05-11", height: 171, tags: ["rap"], hasCV: false },
|
||||||
|
{ no: "030", name: "瑶", enName: "JADE", slogan: "翡翠少女", themeColor: "#14b8a6", birthday: "06-27", height: 163, tags: ["visual"], hasCV: false },
|
||||||
|
{ no: "031", name: "晨", enName: "AURIA", slogan: "金色晨光", themeColor: "#f59e0b", birthday: "07-04", height: 166, tags: ["all-rounder"], hasCV: false },
|
||||||
|
{ no: "032", name: "岩", enName: "ROCK", slogan: "硬核摇滚", themeColor: "#78716c", birthday: "08-16", height: 177, tags: ["rap"], hasCV: false },
|
||||||
|
{ no: "033", name: "翔", enName: "SOAR", slogan: "翱翔天际", themeColor: "#0284c7", birthday: "09-02", height: 175, tags: ["dance"], hasCV: false },
|
||||||
|
{ no: "034", name: "茉", enName: "MOLLY", slogan: "茉莉芬芳", themeColor: "#fef3c7", birthday: "10-19", height: 162, tags: ["visual", "vocal"], hasCV: false },
|
||||||
|
{ no: "035", name: "梓", enName: "AZUR", slogan: "蓝调诗人", themeColor: "#6366f1", birthday: "11-07", height: 165, tags: ["all-rounder"], hasCV: false },
|
||||||
|
];
|
||||||
|
|
||||||
async function main() {
|
async function main() {
|
||||||
console.log("🌱 开始 seed 数据库...");
|
console.log("🌱 开始 seed 数据库...");
|
||||||
|
|
||||||
// 1. 活动配置 (upsert: 第一次创建, 后续仅延长 endAt)
|
// 1. 创建活动配置
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
const endAt = new Date(now);
|
const endAt = new Date(now);
|
||||||
endAt.setDate(endAt.getDate() + 30); // 默认活动期 30 天
|
endAt.setDate(endAt.getDate() + 12);
|
||||||
|
|
||||||
await prisma.activityConfig.upsert({
|
await prisma.activityConfig.upsert({
|
||||||
where: { id: 1 },
|
where: { id: 1 },
|
||||||
@ -32,71 +74,47 @@ async function main() {
|
|||||||
endAt,
|
endAt,
|
||||||
voteEnabled: true,
|
voteEnabled: true,
|
||||||
dailyQuota: 10,
|
dailyQuota: 10,
|
||||||
perArtistLimit: 0, // 不限单艺人
|
perArtistLimit: 0,
|
||||||
paidVoteEnabled: false,
|
paidVoteEnabled: false,
|
||||||
},
|
},
|
||||||
update: {
|
update: {
|
||||||
endAt,
|
endAt,
|
||||||
voteEnabled: true,
|
voteEnabled: true,
|
||||||
dailyQuota: 10,
|
|
||||||
perArtistLimit: 0,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
console.log(" ✓ 活动配置已写入 (dailyQuota=10, voteEnabled=true)");
|
console.log(" ✓ 活动配置已写入");
|
||||||
|
|
||||||
// 2. 36 位艺人 (upsert: 若 DB 里有旧 seed 的假数据, 覆盖为真实姓名/简介)
|
// 2. 创建 35 位艺人
|
||||||
let created = 0;
|
for (const a of STAGE_NAMES) {
|
||||||
let updated = 0;
|
await prisma.artist.upsert({
|
||||||
for (const seed of ARTIST_SEEDS) {
|
where: { id: a.no },
|
||||||
const existing = await prisma.artist.findUnique({ where: { id: seed.no } });
|
create: {
|
||||||
|
id: a.no,
|
||||||
const data = {
|
no: a.no,
|
||||||
no: seed.no,
|
name: a.name,
|
||||||
name: seed.name,
|
enName: a.enName,
|
||||||
enName: seed.enName,
|
slogan: a.slogan,
|
||||||
bio: seed.bio,
|
bio: `来自虚拟星域的偶像候选人 ${a.enName}(${a.name}),从小热爱音乐与舞蹈。代表作《${a.enName} - ${a.slogan}》深受粉丝喜爱。立志成为 Top12 出道阵容的一员,用音乐传递梦想与力量。`,
|
||||||
height: seed.height,
|
birthday: a.birthday,
|
||||||
age: seed.age,
|
height: a.height,
|
||||||
gender: seed.gender,
|
cv: a.hasCV ? `CV 配音 #${a.no}` : null,
|
||||||
motto: seed.motto ?? null,
|
themeColor: a.themeColor,
|
||||||
personality: seed.personality ?? null,
|
tags: a.tags,
|
||||||
catchphrase: seed.catchphrase ?? null,
|
status: "ACTIVE",
|
||||||
skills: seed.skills ?? null,
|
voteCount: 0,
|
||||||
track: seed.track ?? null,
|
currentRank: parseInt(a.no, 10),
|
||||||
tags: seed.tags as unknown as Prisma.InputJsonValue,
|
},
|
||||||
};
|
update: {
|
||||||
|
name: a.name,
|
||||||
if (existing) {
|
enName: a.enName,
|
||||||
await prisma.artist.update({
|
slogan: a.slogan,
|
||||||
where: { id: seed.no },
|
themeColor: a.themeColor,
|
||||||
data,
|
tags: a.tags,
|
||||||
});
|
},
|
||||||
updated++;
|
});
|
||||||
} else {
|
|
||||||
await prisma.artist.create({
|
|
||||||
data: {
|
|
||||||
id: seed.no,
|
|
||||||
...data,
|
|
||||||
status: "ACTIVE",
|
|
||||||
voteCount: 0,
|
|
||||||
currentRank: parseInt(seed.no, 10),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
created++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
console.log(
|
console.log(` ✓ 已写入 ${STAGE_NAMES.length} 位艺人`);
|
||||||
` ✓ 36 人 seed 完成 (新建 ${created}, 更新 ${updated})`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 3. 报告 DB 当前状态
|
|
||||||
const stats = {
|
|
||||||
artists: await prisma.artist.count({ where: { status: "ACTIVE" } }),
|
|
||||||
users: await prisma.user.count(),
|
|
||||||
votes: await prisma.vote.count(),
|
|
||||||
config: await prisma.activityConfig.count(),
|
|
||||||
};
|
|
||||||
console.log("📊 DB 当前状态:", stats);
|
|
||||||
console.log("✅ Seed 完成");
|
console.log("✅ Seed 完成");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.6 MiB |
BIN
public/logo.png
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 358 KiB |
|
Before Width: | Height: | Size: 2.9 MiB |
|
Before Width: | Height: | Size: 3.7 MiB |
|
Before Width: | Height: | Size: 4.1 MiB |
@ -1,50 +0,0 @@
|
|||||||
// 把 prisma/migrations/manual/*.sql 直接 apply 到当前 DATABASE_URL
|
|
||||||
// 不依赖 prisma migrate dev(项目无 migrations 历史 + 无 shadow DB)
|
|
||||||
//
|
|
||||||
// 用法: node scripts/apply-migration.mjs <sql-file>
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
import { readFile } from "node:fs/promises";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient({ log: ["warn", "error"] });
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
const file = process.argv[2];
|
|
||||||
if (!file) {
|
|
||||||
console.error("用法: node scripts/apply-migration.mjs <sql-file>");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
const sql = await readFile(file, "utf-8");
|
|
||||||
// 先按行去掉所有 -- 注释行,再按 ; 切分,过滤空段
|
|
||||||
const cleaned = sql
|
|
||||||
.split("\n")
|
|
||||||
.filter((l) => !/^\s*--/.test(l))
|
|
||||||
.join("\n");
|
|
||||||
const statements = cleaned
|
|
||||||
.split(/;\s*(?:\n|$)/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
|
|
||||||
console.log(`将执行 ${statements.length} 条 SQL:\n`);
|
|
||||||
statements.forEach((s, i) => console.log(` [${i + 1}] ${s.split("\n")[0]}...`));
|
|
||||||
console.log("");
|
|
||||||
|
|
||||||
for (const [i, stmt] of statements.entries()) {
|
|
||||||
console.log(`[${i + 1}/${statements.length}] 执行中...`);
|
|
||||||
try {
|
|
||||||
await prisma.$executeRawUnsafe(stmt);
|
|
||||||
console.log(`[${i + 1}] ✓ 成功`);
|
|
||||||
} catch (e) {
|
|
||||||
console.error(`[${i + 1}] ✗ 失败: ${e.message}`);
|
|
||||||
throw e;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log("\n=== 全部成功 ===");
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(async (e) => {
|
|
||||||
console.error("\n[ABORT]", e.message);
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,351 +0,0 @@
|
|||||||
// 一次性截图脚本 —— 用系统 Chrome 的 remote debugging 协议直接通信,
|
|
||||||
// 避免装 puppeteer/playwright(项目 pnpm store 状态不允许)。
|
|
||||||
//
|
|
||||||
// 通过 Fetch.requestPaused 拦截 /api/auth/session 注入 mock next-auth session,
|
|
||||||
// 让客户端 useSession() 以为已登录,从而能触发 VoteModal / 进入 /me 页。
|
|
||||||
import { spawn } from "node:child_process";
|
|
||||||
import { writeFile, mkdir } from "node:fs/promises";
|
|
||||||
import { setTimeout as wait } from "node:timers/promises";
|
|
||||||
|
|
||||||
const CHROME = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
||||||
const PORT = 9333;
|
|
||||||
const PROFILE = "C:\\Users\\10419\\AppData\\Local\\Temp\\cs-voting-shot";
|
|
||||||
const OUT_DIR = "d:\\ClaudeProjects\\虚拟明星\\UI-UX\\docs\\screenshots\\voting-refactor";
|
|
||||||
const ORIGIN = "http://localhost:3000";
|
|
||||||
|
|
||||||
const MOCK_SESSION = {
|
|
||||||
user: { id: "mock-user", name: "测试用户" },
|
|
||||||
expires: new Date(Date.now() + 86400 * 1000).toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
async function launchChrome() {
|
|
||||||
const proc = spawn(
|
|
||||||
CHROME,
|
|
||||||
[
|
|
||||||
`--headless=new`,
|
|
||||||
`--disable-gpu`,
|
|
||||||
`--remote-debugging-port=${PORT}`,
|
|
||||||
`--user-data-dir=${PROFILE}`,
|
|
||||||
`--window-size=1500,900`,
|
|
||||||
`--hide-scrollbars`,
|
|
||||||
`--no-first-run`,
|
|
||||||
`--no-default-browser-check`,
|
|
||||||
`about:blank`,
|
|
||||||
],
|
|
||||||
{ stdio: "ignore", detached: true },
|
|
||||||
);
|
|
||||||
proc.unref();
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
|
||||||
if (r.ok) return proc.pid;
|
|
||||||
} catch (_e) {
|
|
||||||
void _e;
|
|
||||||
}
|
|
||||||
await wait(300);
|
|
||||||
}
|
|
||||||
throw new Error("Chrome remote debugging did not come up");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function killChrome(pid) {
|
|
||||||
try {
|
|
||||||
process.kill(pid);
|
|
||||||
} catch {
|
|
||||||
/* */
|
|
||||||
}
|
|
||||||
spawn("taskkill", ["/F", "/PID", String(pid), "/T"], { stdio: "ignore" });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function openPage() {
|
|
||||||
const r = await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, {
|
|
||||||
method: "PUT",
|
|
||||||
});
|
|
||||||
return await r.json();
|
|
||||||
}
|
|
||||||
|
|
||||||
class CDP {
|
|
||||||
constructor(wsUrl) {
|
|
||||||
this.ws = null;
|
|
||||||
this.wsUrl = wsUrl;
|
|
||||||
this.id = 0;
|
|
||||||
this.pending = new Map();
|
|
||||||
this.listeners = new Set();
|
|
||||||
}
|
|
||||||
async connect() {
|
|
||||||
this.ws = new WebSocket(this.wsUrl);
|
|
||||||
await new Promise((res, rej) => {
|
|
||||||
this.ws.addEventListener("open", () => res(), { once: true });
|
|
||||||
this.ws.addEventListener("error", (e) => rej(e), { once: true });
|
|
||||||
});
|
|
||||||
this.ws.addEventListener("message", (ev) => {
|
|
||||||
const msg = JSON.parse(ev.data);
|
|
||||||
if (msg.id && this.pending.has(msg.id)) {
|
|
||||||
const { resolve, reject } = this.pending.get(msg.id);
|
|
||||||
this.pending.delete(msg.id);
|
|
||||||
if (msg.error) reject(new Error(msg.error.message));
|
|
||||||
else resolve(msg.result);
|
|
||||||
} else if (msg.method) {
|
|
||||||
for (const cb of this.listeners) cb(msg);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
on(cb) {
|
|
||||||
this.listeners.add(cb);
|
|
||||||
return () => this.listeners.delete(cb);
|
|
||||||
}
|
|
||||||
send(method, params = {}) {
|
|
||||||
const id = ++this.id;
|
|
||||||
return new Promise((resolve, reject) => {
|
|
||||||
this.pending.set(id, { resolve, reject });
|
|
||||||
this.ws.send(JSON.stringify({ id, method, params }));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
close() {
|
|
||||||
this.ws.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function b64(str) {
|
|
||||||
return Buffer.from(str).toString("base64");
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 安装 /api/auth/session 拦截器 —— 返回 mock session 让 useSession 觉得已登录。
|
|
||||||
* /me 路由会调用 next-auth/server,这个对客户端透明 —— 但 /me 页是 server component 包了
|
|
||||||
* client MeContent,如果 server 端 redirect 我们抓 not handled。简单做法:直接 navigate
|
|
||||||
* 到 /me 仍可能 redirect,但 hero / 卡片 / vote modal 这些纯 client 用 useSession 都 OK。
|
|
||||||
*/
|
|
||||||
async function setupSessionMock(cdp) {
|
|
||||||
await cdp.send("Fetch.enable", {
|
|
||||||
patterns: [{ urlPattern: "*/api/auth/session*" }],
|
|
||||||
});
|
|
||||||
cdp.on(async (msg) => {
|
|
||||||
if (msg.method !== "Fetch.requestPaused") return;
|
|
||||||
const { requestId, request } = msg.params;
|
|
||||||
if (request.url.includes("/api/auth/session")) {
|
|
||||||
const body = b64(JSON.stringify(MOCK_SESSION));
|
|
||||||
await cdp.send("Fetch.fulfillRequest", {
|
|
||||||
requestId,
|
|
||||||
responseCode: 200,
|
|
||||||
responseHeaders: [
|
|
||||||
{ name: "Content-Type", value: "application/json" },
|
|
||||||
{ name: "Cache-Control", value: "no-store" },
|
|
||||||
],
|
|
||||||
body,
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await cdp.send("Fetch.continueRequest", { requestId });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function waitForLoad(cdp, ms = 2000) {
|
|
||||||
await wait(ms);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setLocalStorage(cdp, items) {
|
|
||||||
for (const [key, value] of Object.entries(items)) {
|
|
||||||
await cdp.send("Runtime.evaluate", {
|
|
||||||
expression: `localStorage.setItem(${JSON.stringify(key)}, ${JSON.stringify(value)})`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function clearLocalStorage(cdp) {
|
|
||||||
await cdp.send("Runtime.evaluate", { expression: `localStorage.clear()` });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function navigate(cdp, url, settleMs = 2500) {
|
|
||||||
await cdp.send("Page.navigate", { url });
|
|
||||||
await waitForLoad(cdp, settleMs);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pauseVideos(cdp) {
|
|
||||||
await cdp.send("Runtime.evaluate", {
|
|
||||||
expression: `document.querySelectorAll('video').forEach(v => { try { v.pause(); v.currentTime = 0.5; } catch{} });`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function screenshotFull(cdp, path) {
|
|
||||||
const r = await cdp.send("Page.captureScreenshot", { format: "png" });
|
|
||||||
await writeFile(path, Buffer.from(r.data, "base64"));
|
|
||||||
console.log(`[ok] ${path}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function screenshotElement(cdp, selector, path, padding = 8) {
|
|
||||||
const r = await cdp.send("Runtime.evaluate", {
|
|
||||||
expression: `(() => {
|
|
||||||
const el = document.querySelector(${JSON.stringify(selector)});
|
|
||||||
if (!el) return null;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return { x: r.left, y: r.top, width: r.width, height: r.height };
|
|
||||||
})()`,
|
|
||||||
returnByValue: true,
|
|
||||||
});
|
|
||||||
if (!r.result || !r.result.value) {
|
|
||||||
console.log(`[skip] no element ${selector} for ${path}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
const b = r.result.value;
|
|
||||||
const cap = await cdp.send("Page.captureScreenshot", {
|
|
||||||
format: "png",
|
|
||||||
clip: {
|
|
||||||
x: Math.max(0, b.x - padding),
|
|
||||||
y: Math.max(0, b.y - padding),
|
|
||||||
width: Math.max(1, b.width + padding * 2),
|
|
||||||
height: Math.max(1, b.height + padding * 2),
|
|
||||||
scale: 1,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await writeFile(path, Buffer.from(cap.data, "base64"));
|
|
||||||
console.log(`[ok] ${path} (${selector})`);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeVoteState(ids) {
|
|
||||||
return JSON.stringify({ state: { votedArtists: ids }, version: 0 });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
await mkdir(OUT_DIR, { recursive: true });
|
|
||||||
console.log("[boot] launching Chrome...");
|
|
||||||
const pid = await launchChrome();
|
|
||||||
console.log(`[boot] Chrome pid=${pid} port=${PORT}`);
|
|
||||||
try {
|
|
||||||
const target = await openPage();
|
|
||||||
const cdp = new CDP(target.webSocketDebuggerUrl);
|
|
||||||
await cdp.connect();
|
|
||||||
await cdp.send("Page.enable");
|
|
||||||
await cdp.send("Network.enable");
|
|
||||||
await cdp.send("Runtime.enable");
|
|
||||||
await cdp.send("Emulation.setDeviceMetricsOverride", {
|
|
||||||
width: 1500,
|
|
||||||
height: 900,
|
|
||||||
deviceScaleFactor: 1,
|
|
||||||
mobile: false,
|
|
||||||
});
|
|
||||||
await setupSessionMock(cdp);
|
|
||||||
|
|
||||||
// ===== 1. Hero 进度三态(裁切胶囊 + 整图各一份)=====
|
|
||||||
// 1a. 0/12 (已登录但未投)
|
|
||||||
// user-data-dir 复用会带上上次的 localStorage —— 必须先进 ORIGIN 上下文再 clear
|
|
||||||
await navigate(cdp, ORIGIN, 500);
|
|
||||||
await clearLocalStorage(cdp);
|
|
||||||
await navigate(cdp, ORIGIN);
|
|
||||||
await pauseVideos(cdp);
|
|
||||||
await wait(800);
|
|
||||||
await screenshotFull(cdp, `${OUT_DIR}\\01a-hero-0of12.png`);
|
|
||||||
await screenshotElement(
|
|
||||||
cdp,
|
|
||||||
"[data-hero-vote-progress]",
|
|
||||||
`${OUT_DIR}\\01a-progress-0of12.png`,
|
|
||||||
12,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1b. 5/12
|
|
||||||
await setLocalStorage(cdp, {
|
|
||||||
"cyber-star-vote": makeVoteState(["001", "002", "003", "004", "005"]),
|
|
||||||
});
|
|
||||||
await navigate(cdp, ORIGIN);
|
|
||||||
await pauseVideos(cdp);
|
|
||||||
await wait(800);
|
|
||||||
await screenshotFull(cdp, `${OUT_DIR}\\01b-hero-5of12.png`);
|
|
||||||
await screenshotElement(
|
|
||||||
cdp,
|
|
||||||
"[data-hero-vote-progress]",
|
|
||||||
`${OUT_DIR}\\01b-progress-5of12.png`,
|
|
||||||
12,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1c. 12/12
|
|
||||||
await setLocalStorage(cdp, {
|
|
||||||
"cyber-star-vote": makeVoteState([
|
|
||||||
"001", "002", "003", "004", "005", "006",
|
|
||||||
"007", "008", "009", "010", "011", "012",
|
|
||||||
]),
|
|
||||||
});
|
|
||||||
await navigate(cdp, ORIGIN);
|
|
||||||
await pauseVideos(cdp);
|
|
||||||
await wait(800);
|
|
||||||
await screenshotFull(cdp, `${OUT_DIR}\\01c-hero-12of12.png`);
|
|
||||||
await screenshotElement(
|
|
||||||
cdp,
|
|
||||||
"[data-hero-vote-progress]",
|
|
||||||
`${OUT_DIR}\\01c-progress-12of12.png`,
|
|
||||||
12,
|
|
||||||
);
|
|
||||||
|
|
||||||
// ===== 2. 艺人卡片角标对比 =====
|
|
||||||
// 投了 1/3/5 — 卡片网格里能看到混合态(已投紫框✓ vs 未投灰框)
|
|
||||||
await setLocalStorage(cdp, {
|
|
||||||
"cyber-star-vote": makeVoteState(["001", "003", "005"]),
|
|
||||||
});
|
|
||||||
await navigate(cdp, ORIGIN);
|
|
||||||
await pauseVideos(cdp);
|
|
||||||
await cdp.send("Runtime.evaluate", {
|
|
||||||
expression: `document.getElementById('artists')?.scrollIntoView({behavior:'instant',block:'start'}); window.scrollBy(0, 100);`,
|
|
||||||
});
|
|
||||||
await wait(1000);
|
|
||||||
await screenshotFull(cdp, `${OUT_DIR}\\02-artist-cards-mixed.png`);
|
|
||||||
|
|
||||||
// ===== 3. /me 页 =====
|
|
||||||
// useSession returns mock session → MeContent 应该正常渲染
|
|
||||||
// 但 /me 是 server component:它在 server 端调 auth() — 我们 intercept 仅作用 client。
|
|
||||||
// 实际 server component 不会用到 fetch /api/auth/session,它用 cookies 直接验。
|
|
||||||
// 没有 next-auth cookie → server redirect。我们尝试,如果失败就截 redirect 后状态。
|
|
||||||
await navigate(cdp, `${ORIGIN}/me`, 1500);
|
|
||||||
// 检查是否被重定向
|
|
||||||
const urlInfo = await cdp.send("Runtime.evaluate", {
|
|
||||||
expression: "location.pathname",
|
|
||||||
returnByValue: true,
|
|
||||||
});
|
|
||||||
if (urlInfo.result.value !== "/me") {
|
|
||||||
console.log(
|
|
||||||
`[note] /me redirected to ${urlInfo.result.value} — server auth() not bypassed`,
|
|
||||||
);
|
|
||||||
// 在 hash 模式下不会 redirect; 强制 navigate 后端 client only render
|
|
||||||
// 退而求其次:直接构造一个空白 page 在客户端 render MeContent — 无法,跳过
|
|
||||||
}
|
|
||||||
await screenshotFull(cdp, `${OUT_DIR}\\03-me-page.png`);
|
|
||||||
|
|
||||||
// ===== 4. VoteModal 正常态(未投 + 未满) =====
|
|
||||||
await setLocalStorage(cdp, {
|
|
||||||
"cyber-star-vote": makeVoteState([]),
|
|
||||||
});
|
|
||||||
await navigate(cdp, ORIGIN);
|
|
||||||
await pauseVideos(cdp);
|
|
||||||
await wait(1200);
|
|
||||||
// 滚到卡片区点第一张卡的投票按钮
|
|
||||||
await cdp.send("Runtime.evaluate", {
|
|
||||||
expression: `document.getElementById('artists')?.scrollIntoView({behavior:'instant',block:'start'}); window.scrollBy(0, 100);`,
|
|
||||||
});
|
|
||||||
await wait(500);
|
|
||||||
const clicked = await cdp.send("Runtime.evaluate", {
|
|
||||||
expression: `(() => {
|
|
||||||
const btns = Array.from(document.querySelectorAll('button'));
|
|
||||||
const target = btns.find(b => (b.textContent || '').trim() === '投票');
|
|
||||||
if (target) { target.click(); return true; }
|
|
||||||
return false;
|
|
||||||
})()`,
|
|
||||||
returnByValue: true,
|
|
||||||
});
|
|
||||||
console.log(`[note] vote modal trigger: ${clicked.result.value}`);
|
|
||||||
await wait(1000);
|
|
||||||
await screenshotFull(cdp, `${OUT_DIR}\\04-vote-modal-normal.png`);
|
|
||||||
await screenshotElement(
|
|
||||||
cdp,
|
|
||||||
'[role="dialog"]',
|
|
||||||
`${OUT_DIR}\\04-vote-modal-cropped.png`,
|
|
||||||
24,
|
|
||||||
);
|
|
||||||
|
|
||||||
cdp.close();
|
|
||||||
console.log("[done]");
|
|
||||||
} finally {
|
|
||||||
await killChrome(pid);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch((e) => {
|
|
||||||
console.error(e);
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,47 +0,0 @@
|
|||||||
// 删除测试用户 + 其所有 vote / fanSupport / signIn 数据,
|
|
||||||
// 并把 artist.voteCount 回滚到投票前的值。
|
|
||||||
//
|
|
||||||
// 用法: node scripts/cleanup-test-user.mjs <phone>
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
const prisma = new PrismaClient({ log: ["error"] });
|
|
||||||
|
|
||||||
const phone = process.argv[2];
|
|
||||||
if (!phone) {
|
|
||||||
console.error("用法: node scripts/cleanup-test-user.mjs <phone>");
|
|
||||||
process.exit(2);
|
|
||||||
}
|
|
||||||
|
|
||||||
const user = await prisma.user.findUnique({
|
|
||||||
where: { phone },
|
|
||||||
select: { id: true, nickname: true },
|
|
||||||
});
|
|
||||||
if (!user) {
|
|
||||||
console.log(`[skip] 没有找到 phone=${phone} 的用户,无需 cleanup`);
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
console.log(`[cleanup] 用户 id=${user.id} (${user.nickname}) phone=${phone}`);
|
|
||||||
|
|
||||||
// 1. 把该用户的投票回滚到 artist.voteCount
|
|
||||||
const votes = await prisma.vote.findMany({
|
|
||||||
where: { userId: user.id },
|
|
||||||
select: { artistId: true, count: true },
|
|
||||||
});
|
|
||||||
console.log(` 待回滚 ${votes.length} 条投票`);
|
|
||||||
|
|
||||||
await prisma.$transaction(async (tx) => {
|
|
||||||
// 1. 减回 artist.voteCount
|
|
||||||
for (const v of votes) {
|
|
||||||
await tx.artist.update({
|
|
||||||
where: { id: v.artistId },
|
|
||||||
data: { voteCount: { decrement: v.count } },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// 2. 删除 Vote / FanSupport / SignIn(都有 onDelete: Cascade,删 user 就够,但显式更清晰)
|
|
||||||
// 3. 删除 user 本身 —— cascade 会清掉关联表
|
|
||||||
await tx.user.delete({ where: { id: user.id } });
|
|
||||||
});
|
|
||||||
|
|
||||||
console.log("[cleanup] 完成");
|
|
||||||
await prisma.$disconnect();
|
|
||||||
@ -1,147 +0,0 @@
|
|||||||
#!/usr/bin/env bash
|
|
||||||
# 端到端回归测试:走完整 next-auth OTP 登录 → /api/me → /api/vote 4 种路径
|
|
||||||
#
|
|
||||||
# 测试用户:全新手机号 13800138000(测试结束 cleanup-test-user.mjs 删除)
|
|
||||||
# dev OTP 万能码:123456
|
|
||||||
# 实际命中 route handler,不绕过任何中间件。
|
|
||||||
|
|
||||||
set -uo pipefail
|
|
||||||
|
|
||||||
BASE="http://localhost:3000"
|
|
||||||
PHONE="13800138000"
|
|
||||||
CODE="123456"
|
|
||||||
COOKIES=$(mktemp)
|
|
||||||
trap 'rm -f "$COOKIES"' EXIT
|
|
||||||
|
|
||||||
green() { echo -e "\033[32m$1\033[0m"; }
|
|
||||||
red() { echo -e "\033[31m$1\033[0m"; }
|
|
||||||
yel() { echo -e "\033[33m$1\033[0m"; }
|
|
||||||
|
|
||||||
# 累计通过 / 失败
|
|
||||||
PASS=0
|
|
||||||
FAIL=0
|
|
||||||
assert_eq() {
|
|
||||||
local name="$1" expected="$2" actual="$3"
|
|
||||||
if [[ "$actual" == "$expected" ]]; then
|
|
||||||
PASS=$((PASS+1)); green " [PASS] $name"
|
|
||||||
else
|
|
||||||
FAIL=$((FAIL+1)); red " [FAIL] $name -- expected=$expected actual=$actual"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
assert_contains() {
|
|
||||||
local name="$1" needle="$2" haystack="$3"
|
|
||||||
if echo "$haystack" | grep -q "$needle"; then
|
|
||||||
PASS=$((PASS+1)); green " [PASS] $name"
|
|
||||||
else
|
|
||||||
FAIL=$((FAIL+1)); red " [FAIL] $name -- did not find '$needle' in: $haystack"
|
|
||||||
fi
|
|
||||||
}
|
|
||||||
|
|
||||||
echo "=== 端到端回归测试 ==="
|
|
||||||
echo "测试用户 phone=$PHONE"
|
|
||||||
echo ""
|
|
||||||
|
|
||||||
# ===== 0. cleanup 任何上次残留的测试数据(测试幂等) =====
|
|
||||||
yel "[0] cleanup 上次测试残留"
|
|
||||||
node scripts/cleanup-test-user.mjs "$PHONE" 2>&1 | sed 's/^/ /'
|
|
||||||
|
|
||||||
# ===== 1. 取 CSRF token =====
|
|
||||||
yel "[1] 获取 CSRF token"
|
|
||||||
CSRF_RAW=$(curl -s -c "$COOKIES" "$BASE/api/auth/csrf")
|
|
||||||
CSRF=$(echo "$CSRF_RAW" | sed -n 's/.*"csrfToken":"\([^"]*\)".*/\1/p')
|
|
||||||
if [[ -z "$CSRF" ]]; then
|
|
||||||
red "无法获取 csrfToken,响应:$CSRF_RAW"
|
|
||||||
exit 1
|
|
||||||
fi
|
|
||||||
green " csrfToken=${CSRF:0:20}..."
|
|
||||||
|
|
||||||
# ===== 2. OTP 登录(Credentials provider 路径 /api/auth/callback/phone-otp) =====
|
|
||||||
yel "[2] OTP 登录 phone=$PHONE code=$CODE"
|
|
||||||
LOGIN_HTTP=$(curl -s -b "$COOKIES" -c "$COOKIES" -o /tmp/login.body -w "%{http_code}" \
|
|
||||||
-X POST "$BASE/api/auth/callback/phone-otp" \
|
|
||||||
-H "Content-Type: application/x-www-form-urlencoded" \
|
|
||||||
--data-urlencode "phone=$PHONE" \
|
|
||||||
--data-urlencode "code=$CODE" \
|
|
||||||
--data-urlencode "csrfToken=$CSRF" \
|
|
||||||
--data-urlencode "redirect=false" \
|
|
||||||
--data-urlencode "json=true")
|
|
||||||
echo " HTTP $LOGIN_HTTP"
|
|
||||||
|
|
||||||
# ===== 3. 验证 session 已建立 =====
|
|
||||||
yel "[3] 验证 session"
|
|
||||||
SESSION=$(curl -s -b "$COOKIES" "$BASE/api/auth/session")
|
|
||||||
echo " session: $SESSION"
|
|
||||||
assert_contains "session 包含 user.id" '"id"' "$SESSION"
|
|
||||||
|
|
||||||
# ===== 4. GET /api/me 验返回结构 =====
|
|
||||||
yel "[4] GET /api/me 验证新字段"
|
|
||||||
ME=$(curl -s -b "$COOKIES" "$BASE/api/me")
|
|
||||||
echo " body 摘要: $(echo "$ME" | head -c 300)..."
|
|
||||||
assert_contains "/api/me 返回 ok:true" '"ok":true' "$ME"
|
|
||||||
assert_contains "/api/me 含 voteQuota 字段" '"voteQuota"' "$ME"
|
|
||||||
assert_contains "/api/me voteQuota.total=12" '"total":12' "$ME"
|
|
||||||
assert_contains "/api/me 含 votedArtists 字段" '"votedArtists"' "$ME"
|
|
||||||
if echo "$ME" | grep -q '"dailyQuota"'; then
|
|
||||||
FAIL=$((FAIL+1)); red " [FAIL] /api/me 仍含 dailyQuota 字段(应已被 voteQuota 替换)"
|
|
||||||
else
|
|
||||||
PASS=$((PASS+1)); green " [PASS] /api/me 不再含旧 dailyQuota 字段"
|
|
||||||
fi
|
|
||||||
|
|
||||||
# ===== 5. POST /api/vote 投未投过的艺人(预期成功) =====
|
|
||||||
TEST_ARTIST="001"
|
|
||||||
yel "[5] POST /api/vote artistId=$TEST_ARTIST(首次投,预期 200)"
|
|
||||||
V1=$(curl -s -b "$COOKIES" -X POST "$BASE/api/vote" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"artistId\":\"$TEST_ARTIST\"}")
|
|
||||||
echo " body: $V1"
|
|
||||||
assert_contains "首投返回 ok:true" '"ok":true' "$V1"
|
|
||||||
assert_contains "首投返回 totalQuota:12" '"totalQuota":12' "$V1"
|
|
||||||
assert_contains "首投返回 votedCount=1" '"votedCount":1' "$V1"
|
|
||||||
assert_contains "首投返回 remaining=11" '"remaining":11' "$V1"
|
|
||||||
|
|
||||||
# ===== 6. POST /api/vote 同一艺人(预期 409 ALREADY_VOTED) =====
|
|
||||||
yel "[6] POST /api/vote artistId=$TEST_ARTIST(重投,预期 409 ALREADY_VOTED)"
|
|
||||||
V2=$(curl -s -b "$COOKIES" -X POST "$BASE/api/vote" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d "{\"artistId\":\"$TEST_ARTIST\"}")
|
|
||||||
echo " body: $V2"
|
|
||||||
assert_contains "重投返回 ok:false" '"ok":false' "$V2"
|
|
||||||
assert_contains "重投返回 ALREADY_VOTED" '"code":"ALREADY_VOTED"' "$V2"
|
|
||||||
|
|
||||||
# ===== 7. POST /api/vote 不存在的艺人(预期 404 NOT_FOUND) =====
|
|
||||||
yel "[7] POST /api/vote artistId=999(不存在,预期 404)"
|
|
||||||
V3=$(curl -s -b "$COOKIES" -X POST "$BASE/api/vote" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"artistId":"999"}')
|
|
||||||
echo " body: $V3"
|
|
||||||
assert_contains "无效艺人返回 ok:false" '"ok":false' "$V3"
|
|
||||||
assert_contains "无效艺人返回 NOT_FOUND" '"code":"NOT_FOUND"' "$V3"
|
|
||||||
|
|
||||||
# ===== 8. 再次 GET /api/me 验 votedArtists 含新投艺人 =====
|
|
||||||
yel "[8] GET /api/me 复查 votedArtists"
|
|
||||||
ME2=$(curl -s -b "$COOKIES" "$BASE/api/me")
|
|
||||||
assert_contains "复查 votedArtists 含 $TEST_ARTIST" "\"$TEST_ARTIST\"" "$ME2"
|
|
||||||
assert_contains "复查 voteQuota.used=1" '"used":1' "$ME2"
|
|
||||||
assert_contains "复查 voteQuota.remaining=11" '"remaining":11' "$ME2"
|
|
||||||
|
|
||||||
# ===== 9. 验证未登录 → 401 =====
|
|
||||||
yel "[9] 未登录调 /api/vote(预期 401)"
|
|
||||||
V_NOAUTH=$(curl -s -X POST "$BASE/api/vote" \
|
|
||||||
-H "Content-Type: application/json" \
|
|
||||||
-d '{"artistId":"001"}')
|
|
||||||
assert_contains "无 session 返回 UNAUTHORIZED" '"code":"UNAUTHORIZED"' "$V_NOAUTH"
|
|
||||||
|
|
||||||
# ===== 10. 最终 cleanup =====
|
|
||||||
yel "[10] 测试结束 cleanup"
|
|
||||||
node scripts/cleanup-test-user.mjs "$PHONE" 2>&1 | sed 's/^/ /'
|
|
||||||
|
|
||||||
# ===== 汇总 =====
|
|
||||||
echo ""
|
|
||||||
echo "=== 汇总 ==="
|
|
||||||
echo "通过 $PASS · 失败 $FAIL"
|
|
||||||
if [[ $FAIL -gt 0 ]]; then
|
|
||||||
red "回归测试有失败,详见上方"
|
|
||||||
exit 1
|
|
||||||
else
|
|
||||||
green "全部通过"
|
|
||||||
fi
|
|
||||||
@ -1,10 +0,0 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
const prisma = new PrismaClient({ log: ["error"] });
|
|
||||||
const u = await prisma.user.findMany({
|
|
||||||
where: { phone: { not: null } },
|
|
||||||
select: { id: true, phone: true, nickname: true },
|
|
||||||
orderBy: { id: "asc" },
|
|
||||||
});
|
|
||||||
console.log("现有手机号用户:");
|
|
||||||
for (const r of u) console.log(` user_id=${r.id} phone=${r.phone} name=${r.nickname}`);
|
|
||||||
await prisma.$disconnect();
|
|
||||||
@ -1,93 +0,0 @@
|
|||||||
// 只读探查生产 DB 真实状态 —— 不写任何数据,不改任何 schema。
|
|
||||||
// 用法:node scripts/inspect-db.mjs
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
|
|
||||||
const prisma = new PrismaClient({ log: ["error"] });
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("=== 生产 DB 真实状态(只读)===\n");
|
|
||||||
|
|
||||||
// 1. 用户量级
|
|
||||||
const userCount = await prisma.user.count();
|
|
||||||
console.log(`users 总数: ${userCount}`);
|
|
||||||
|
|
||||||
// 2. 投票量级
|
|
||||||
const voteCount = await prisma.vote.count();
|
|
||||||
console.log(`votes 总数: ${voteCount}`);
|
|
||||||
|
|
||||||
// 3. 是否存在重复 (userId, artistId) — 加 unique 前必看
|
|
||||||
const dup = await prisma.$queryRaw`
|
|
||||||
SELECT user_id, artist_id, COUNT(*) AS cnt
|
|
||||||
FROM votes
|
|
||||||
GROUP BY user_id, artist_id
|
|
||||||
HAVING cnt > 1
|
|
||||||
ORDER BY cnt DESC
|
|
||||||
LIMIT 20
|
|
||||||
`;
|
|
||||||
console.log(`重复 (userId, artistId) 行数: ${dup.length}`);
|
|
||||||
if (dup.length > 0) {
|
|
||||||
console.log("Top 20 重复样本:");
|
|
||||||
for (const row of dup) {
|
|
||||||
console.log(` user=${row.user_id} artist=${row.artist_id} 重复=${row.cnt}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. 单用户最大投票数 — 看是否有人已超过 12 票
|
|
||||||
const topVoters = await prisma.$queryRaw`
|
|
||||||
SELECT user_id, COUNT(*) AS total_votes, COUNT(DISTINCT artist_id) AS unique_artists
|
|
||||||
FROM votes
|
|
||||||
GROUP BY user_id
|
|
||||||
ORDER BY total_votes DESC
|
|
||||||
LIMIT 10
|
|
||||||
`;
|
|
||||||
console.log(`\nTop 10 投票最多的用户:`);
|
|
||||||
for (const row of topVoters) {
|
|
||||||
console.log(
|
|
||||||
` user=${row.user_id} total=${row.total_votes} 不同艺人=${row.unique_artists}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. DailyQuota 状态
|
|
||||||
const dqCount = await prisma.dailyQuota.count();
|
|
||||||
console.log(`\ndaily_quota 总数: ${dqCount}`);
|
|
||||||
|
|
||||||
// 6. FanSupport 状态
|
|
||||||
const fsCount = await prisma.fanSupport.count();
|
|
||||||
console.log(`fan_supports 总数: ${fsCount}`);
|
|
||||||
|
|
||||||
// 7. ActivityConfig 配置
|
|
||||||
const config = await prisma.activityConfig.findUnique({ where: { id: 1 } });
|
|
||||||
console.log(`\nactivity_config:`);
|
|
||||||
if (config) {
|
|
||||||
console.log(` voteEnabled=${config.voteEnabled}`);
|
|
||||||
console.log(` dailyQuota=${config.dailyQuota}`);
|
|
||||||
console.log(` perArtistLimit=${config.perArtistLimit}`);
|
|
||||||
console.log(` startAt=${config.startAt.toISOString()}`);
|
|
||||||
console.log(` endAt=${config.endAt.toISOString()}`);
|
|
||||||
} else {
|
|
||||||
console.log(" (空)");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 8. Vote 表当前索引(原始 SQL 探)
|
|
||||||
const indexes = await prisma.$queryRaw`
|
|
||||||
SHOW INDEX FROM votes
|
|
||||||
`;
|
|
||||||
console.log(`\nvotes 当前索引:`);
|
|
||||||
for (const idx of indexes) {
|
|
||||||
console.log(
|
|
||||||
` ${idx.Key_name} col=${idx.Column_name} seq=${idx.Seq_in_index} unique=${idx.Non_unique === 0 ? "Y" : "N"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 9. 服务器版本
|
|
||||||
const ver = await prisma.$queryRaw`SELECT VERSION() AS v`;
|
|
||||||
console.log(`\nMySQL 版本: ${ver[0].v}`);
|
|
||||||
|
|
||||||
await prisma.$disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(async (e) => {
|
|
||||||
console.error(e);
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,59 +0,0 @@
|
|||||||
// 清空投票相关测试数据,准备重新测试。
|
|
||||||
//
|
|
||||||
// 清:votes / fan_supports / daily_quota / sign_ins / risk_logs / ranking_snapshots
|
|
||||||
// reset:artists.vote_count = 0, artists.current_rank = null
|
|
||||||
// 保留:users / artists 配置 / activity_config / invitations
|
|
||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
const prisma = new PrismaClient({ log: ["error"] });
|
|
||||||
|
|
||||||
console.log("=== 清空投票测试数据 ===\n");
|
|
||||||
|
|
||||||
// 跑前 snapshot
|
|
||||||
const before = {
|
|
||||||
votes: await prisma.vote.count(),
|
|
||||||
fanSupports: await prisma.fanSupport.count(),
|
|
||||||
dailyQuotas: await prisma.dailyQuota.count(),
|
|
||||||
signIns: await prisma.signIn.count(),
|
|
||||||
riskLogs: await prisma.riskLog.count(),
|
|
||||||
snapshots: await prisma.rankingSnapshot.count(),
|
|
||||||
artistsWithVotes: await prisma.artist.count({ where: { voteCount: { gt: 0 } } }),
|
|
||||||
};
|
|
||||||
console.log("清前:", before);
|
|
||||||
|
|
||||||
await prisma.$transaction(
|
|
||||||
async (tx) => {
|
|
||||||
await tx.vote.deleteMany({});
|
|
||||||
await tx.fanSupport.deleteMany({});
|
|
||||||
await tx.dailyQuota.deleteMany({});
|
|
||||||
await tx.signIn.deleteMany({});
|
|
||||||
await tx.riskLog.deleteMany({});
|
|
||||||
await tx.rankingSnapshot.deleteMany({});
|
|
||||||
// reset artists 缓存
|
|
||||||
await tx.artist.updateMany({
|
|
||||||
data: { voteCount: 0, currentRank: null },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ timeout: 30000 },
|
|
||||||
);
|
|
||||||
|
|
||||||
const after = {
|
|
||||||
votes: await prisma.vote.count(),
|
|
||||||
fanSupports: await prisma.fanSupport.count(),
|
|
||||||
dailyQuotas: await prisma.dailyQuota.count(),
|
|
||||||
signIns: await prisma.signIn.count(),
|
|
||||||
riskLogs: await prisma.riskLog.count(),
|
|
||||||
snapshots: await prisma.rankingSnapshot.count(),
|
|
||||||
artistsWithVotes: await prisma.artist.count({ where: { voteCount: { gt: 0 } } }),
|
|
||||||
};
|
|
||||||
console.log("\n清后:", after);
|
|
||||||
|
|
||||||
// 同时确认保留了什么
|
|
||||||
const preserved = {
|
|
||||||
users: await prisma.user.count(),
|
|
||||||
artists: await prisma.artist.count(),
|
|
||||||
activityConfig: await prisma.activityConfig.count(),
|
|
||||||
};
|
|
||||||
console.log("保留:", preserved);
|
|
||||||
|
|
||||||
await prisma.$disconnect();
|
|
||||||
console.log("\n✓ 完成");
|
|
||||||
@ -1,86 +0,0 @@
|
|||||||
import { spawn } from "node:child_process";
|
|
||||||
import { writeFile } from "node:fs/promises";
|
|
||||||
import { setTimeout as wait } from "node:timers/promises";
|
|
||||||
|
|
||||||
const CHROME = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
||||||
const PORT = 9333;
|
|
||||||
const PROFILE = "C:\\Users\\10419\\AppData\\Local\\Temp\\cs-narrow-shot";
|
|
||||||
const OUT = process.env.SHOT_OUT || "d:/ClaudeProjects/虚拟明星/UI-UX/docs/screenshots/nav-overlap-narrow.png";
|
|
||||||
const WIDTH = Number(process.env.SHOT_WIDTH || 740);
|
|
||||||
const HEIGHT = Number(process.env.SHOT_HEIGHT || 1000);
|
|
||||||
|
|
||||||
const proc = spawn(CHROME, [
|
|
||||||
`--headless=new`, `--disable-gpu`, `--remote-debugging-port=${PORT}`,
|
|
||||||
`--user-data-dir=${PROFILE}`, `--hide-scrollbars`, `--no-first-run`, `about:blank`,
|
|
||||||
], { stdio: "ignore", detached: true });
|
|
||||||
proc.unref();
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
try { if ((await fetch(`http://127.0.0.1:${PORT}/json/version`)).ok) break; } catch {}
|
|
||||||
await wait(300);
|
|
||||||
}
|
|
||||||
const t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: "PUT" })).json();
|
|
||||||
const ws = new WebSocket(t.webSocketDebuggerUrl);
|
|
||||||
await new Promise(r => ws.addEventListener("open", () => r(), { once: true }));
|
|
||||||
let id = 0; const pending = new Map();
|
|
||||||
ws.addEventListener("message", (e) => {
|
|
||||||
const m = JSON.parse(e.data);
|
|
||||||
if (m.id && pending.has(m.id)) { const { resolve, reject } = pending.get(m.id); pending.delete(m.id);
|
|
||||||
if (m.error) reject(new Error(m.error.message)); else resolve(m.result); }
|
|
||||||
});
|
|
||||||
const cmd = (method, params = {}) => new Promise((resolve, reject) => {
|
|
||||||
pending.set(++id, { resolve, reject });
|
|
||||||
ws.send(JSON.stringify({ id, method, params }));
|
|
||||||
});
|
|
||||||
|
|
||||||
await cmd("Emulation.setDeviceMetricsOverride", {
|
|
||||||
width: WIDTH, height: HEIGHT, deviceScaleFactor: 1, mobile: false,
|
|
||||||
});
|
|
||||||
await cmd("Page.navigate", { url: "http://localhost:3000" });
|
|
||||||
await wait(3500);
|
|
||||||
await cmd("Runtime.evaluate", {
|
|
||||||
expression: `document.querySelectorAll('video').forEach(v => { try { v.pause(); } catch{} });`,
|
|
||||||
});
|
|
||||||
await wait(500);
|
|
||||||
const r = await cmd("Page.captureScreenshot", { format: "png" });
|
|
||||||
await writeFile(OUT, Buffer.from(r.data, "base64"));
|
|
||||||
|
|
||||||
// 同时取关键元素的位置信息
|
|
||||||
const layout = await cmd("Runtime.evaluate", {
|
|
||||||
returnByValue: true,
|
|
||||||
expression: `(() => {
|
|
||||||
const get = sel => {
|
|
||||||
const el = document.querySelector(sel);
|
|
||||||
if (!el) return null;
|
|
||||||
const r = el.getBoundingClientRect();
|
|
||||||
return { top: Math.round(r.top), left: Math.round(r.left), width: Math.round(r.width), height: Math.round(r.height), text: el.textContent?.trim().slice(0, 30) ?? '' };
|
|
||||||
};
|
|
||||||
return {
|
|
||||||
headerNav: get('header > nav'),
|
|
||||||
mobileNavLinks: get('header ul.md\\\\:hidden') || get('header [class*="md:hidden"] ul') || (() => {
|
|
||||||
// mobile NavLinks 在 header 下方第二行,找 header 内最后一个 ul
|
|
||||||
const uls = document.querySelectorAll('header ul');
|
|
||||||
const last = uls[uls.length - 1];
|
|
||||||
if (!last) return null;
|
|
||||||
const r = last.getBoundingClientRect();
|
|
||||||
return { top: Math.round(r.top), left: Math.round(r.left), width: Math.round(r.width), height: Math.round(r.height), text: last.textContent?.trim().slice(0, 30) ?? '' };
|
|
||||||
})(),
|
|
||||||
heroEyebrow: (() => {
|
|
||||||
const els = Array.from(document.querySelectorAll('p'));
|
|
||||||
const m = els.find(p => /Top 12.*Cyber Star/i.test(p.textContent || ''));
|
|
||||||
if (!m) return null;
|
|
||||||
const r = m.getBoundingClientRect();
|
|
||||||
return { top: Math.round(r.top), left: Math.round(r.left), width: Math.round(r.width), height: Math.round(r.height), text: m.textContent?.trim().slice(0, 40) ?? '' };
|
|
||||||
})(),
|
|
||||||
heroProgress: get('[data-hero-vote-progress]'),
|
|
||||||
};
|
|
||||||
})()`,
|
|
||||||
});
|
|
||||||
console.log("\n=== 关键元素位置(viewport 坐标)===");
|
|
||||||
for (const [k, v] of Object.entries(layout.result.value)) {
|
|
||||||
if (v) console.log(` ${k.padEnd(16)} top=${String(v.top).padStart(3)}px left=${String(v.left).padStart(3)}px w=${v.width} h=${v.height} "${v.text}"`);
|
|
||||||
else console.log(` ${k.padEnd(16)} (未找到)`);
|
|
||||||
}
|
|
||||||
console.log(`\n✓ ${OUT}`);
|
|
||||||
ws.close();
|
|
||||||
try { process.kill(proc.pid); } catch {}
|
|
||||||
spawn("taskkill", ["/F", "/PID", String(proc.pid), "/T"], { stdio: "ignore" });
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
// 后端投票规则验证(不污染生产数据,所有写操作都在事务里 rollback)
|
|
||||||
import { PrismaClient, Prisma } from "@prisma/client";
|
|
||||||
const prisma = new PrismaClient({ log: ["error"] });
|
|
||||||
|
|
||||||
const PASS = "\x1b[32mPASS\x1b[0m";
|
|
||||||
const FAIL = "\x1b[31mFAIL\x1b[0m";
|
|
||||||
|
|
||||||
let results = [];
|
|
||||||
function record(name, ok, detail = "") {
|
|
||||||
results.push({ name, ok, detail });
|
|
||||||
console.log(` [${ok ? PASS : FAIL}] ${name}${detail ? " -- " + detail : ""}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function main() {
|
|
||||||
console.log("=== 后端投票规则验证(事务回滚,不留痕)===\n");
|
|
||||||
|
|
||||||
// 取一个真实用户(测试数据中的最大投票用户)
|
|
||||||
const sample = await prisma.$queryRaw`
|
|
||||||
SELECT user_id, COUNT(*) AS c FROM votes GROUP BY user_id ORDER BY c DESC LIMIT 1
|
|
||||||
`;
|
|
||||||
if (sample.length === 0) {
|
|
||||||
console.log("[note] votes 表为空,跳过有交互的测试");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const sampleUserId = BigInt(sample[0].user_id);
|
|
||||||
const sampleVotes = await prisma.vote.findMany({
|
|
||||||
where: { userId: sampleUserId },
|
|
||||||
select: { artistId: true },
|
|
||||||
});
|
|
||||||
const sampleArtist = sampleVotes[0].artistId;
|
|
||||||
console.log(
|
|
||||||
`测试样本:user=${sampleUserId} 已投艺人=[${sampleVotes
|
|
||||||
.map((v) => v.artistId)
|
|
||||||
.join(",")}]\n`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 测试 1:DB unique 约束生效 —— 重复 (userId, artistId) INSERT 应失败 P2002
|
|
||||||
console.log("[测试 1] DB unique 约束:重复 (userId, artistId) 必被拒");
|
|
||||||
try {
|
|
||||||
await prisma.$transaction(
|
|
||||||
async (tx) => {
|
|
||||||
await tx.vote.create({
|
|
||||||
data: {
|
|
||||||
userId: sampleUserId,
|
|
||||||
artistId: sampleArtist,
|
|
||||||
count: 1,
|
|
||||||
source: "QUOTA",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
throw new Error("ROLLBACK_AFTER_TEST"); // 强制回滚
|
|
||||||
},
|
|
||||||
{ timeout: 10000 },
|
|
||||||
);
|
|
||||||
record("unique 约束阻挡重复投票", false, "INSERT 居然成功了");
|
|
||||||
} catch (e) {
|
|
||||||
if (
|
|
||||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
e.code === "P2002"
|
|
||||||
) {
|
|
||||||
record("unique 约束阻挡重复投票", true, "P2002 unique constraint");
|
|
||||||
} else if (e.message === "ROLLBACK_AFTER_TEST") {
|
|
||||||
record(
|
|
||||||
"unique 约束阻挡重复投票",
|
|
||||||
false,
|
|
||||||
"INSERT 居然成功 = 没有 unique 约束!",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
record("unique 约束阻挡重复投票", false, `异常: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试 2:不同艺人 INSERT 应该成功(在事务内回滚,不留痕)
|
|
||||||
console.log(
|
|
||||||
"\n[测试 2] 不同艺人投票不会被 unique 阻挡(事务内验证后回滚)",
|
|
||||||
);
|
|
||||||
const candidateArtist = await prisma.artist.findFirst({
|
|
||||||
where: {
|
|
||||||
id: { notIn: sampleVotes.map((v) => v.artistId) },
|
|
||||||
status: "ACTIVE",
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
if (!candidateArtist) {
|
|
||||||
record("跨艺人投票", false, "找不到该用户未投过的艺人样本");
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
await prisma.$transaction(
|
|
||||||
async (tx) => {
|
|
||||||
await tx.vote.create({
|
|
||||||
data: {
|
|
||||||
userId: sampleUserId,
|
|
||||||
artistId: candidateArtist.id,
|
|
||||||
count: 1,
|
|
||||||
source: "QUOTA",
|
|
||||||
},
|
|
||||||
});
|
|
||||||
throw new Error("ROLLBACK_AFTER_TEST");
|
|
||||||
},
|
|
||||||
{ timeout: 10000 },
|
|
||||||
);
|
|
||||||
record("跨艺人投票", false, "事务未回滚");
|
|
||||||
} catch (e) {
|
|
||||||
if (e.message === "ROLLBACK_AFTER_TEST") {
|
|
||||||
record(
|
|
||||||
"跨艺人投票",
|
|
||||||
true,
|
|
||||||
`允许投给新艺人 ${candidateArtist.id},事务已回滚`,
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
record("跨艺人投票", false, `异常: ${e.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试 3:终身额度 12 票上限(由 /api/vote 应用层校验,DB 不强制)
|
|
||||||
// 验证:对所有用户跑 SELECT COUNT(*) < 12 是 true,确认目前没人超额
|
|
||||||
console.log("\n[测试 3] 终身额度上限 12 票 - 数据合规性");
|
|
||||||
const overflows = await prisma.$queryRaw`
|
|
||||||
SELECT user_id, COUNT(*) AS c FROM votes GROUP BY user_id HAVING c > 12
|
|
||||||
`;
|
|
||||||
if (overflows.length === 0) {
|
|
||||||
record(
|
|
||||||
"现存数据无超额用户",
|
|
||||||
true,
|
|
||||||
"12 票上限对所有现存用户都成立(应用层会兜底)",
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
record(
|
|
||||||
"现存数据无超额用户",
|
|
||||||
false,
|
|
||||||
`${overflows.length} 个用户已超过 12 票:${overflows
|
|
||||||
.map((r) => `user=${r.user_id} c=${r.c}`)
|
|
||||||
.join("; ")}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 测试 4:回归确认 /api/me 用的 vote 关键查询能返回 votedArtists 列表
|
|
||||||
console.log("\n[测试 4] /api/me votedArtists 查询正确性");
|
|
||||||
const votedList = await prisma.vote.findMany({
|
|
||||||
where: { userId: sampleUserId },
|
|
||||||
select: { artistId: true, createdAt: true },
|
|
||||||
orderBy: { createdAt: "asc" },
|
|
||||||
});
|
|
||||||
const isAsc = votedList.every(
|
|
||||||
(v, i) =>
|
|
||||||
i === 0 || votedList[i - 1].createdAt.getTime() <= v.createdAt.getTime(),
|
|
||||||
);
|
|
||||||
record(
|
|
||||||
"votedArtists 按 createdAt 升序",
|
|
||||||
isAsc,
|
|
||||||
`共 ${votedList.length} 条`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// 测试 5:旧 daily_quota / fan_supports 数据仍可读(向后兼容,不报错)
|
|
||||||
console.log("\n[测试 5] 旧数据兼容性");
|
|
||||||
try {
|
|
||||||
const dqCount = await prisma.dailyQuota.count();
|
|
||||||
const fsCount = await prisma.fanSupport.count();
|
|
||||||
record("旧 DailyQuota / FanSupport 仍可读", true, `dq=${dqCount} fs=${fsCount}`);
|
|
||||||
} catch (e) {
|
|
||||||
record("旧 DailyQuota / FanSupport 仍可读", false, e.message);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 汇总
|
|
||||||
console.log("\n=== 汇总 ===");
|
|
||||||
const passed = results.filter((r) => r.ok).length;
|
|
||||||
const failed = results.length - passed;
|
|
||||||
console.log(`通过 ${passed} / 共 ${results.length}${failed ? ` 失败 ${failed}` : ""}`);
|
|
||||||
await prisma.$disconnect();
|
|
||||||
if (failed) process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
main().catch(async (e) => {
|
|
||||||
console.error("\n[ABORT]", e);
|
|
||||||
await prisma.$disconnect();
|
|
||||||
process.exit(1);
|
|
||||||
});
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
import { spawn } from "node:child_process";
|
|
||||||
import { writeFile } from "node:fs/promises";
|
|
||||||
import { setTimeout as wait } from "node:timers/promises";
|
|
||||||
|
|
||||||
const CHROME = "C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe";
|
|
||||||
const PORT = 9333;
|
|
||||||
const PROFILE = "C:\\Users\\10419\\AppData\\Local\\Temp\\cs-top12-verify";
|
|
||||||
|
|
||||||
const proc = spawn(
|
|
||||||
CHROME,
|
|
||||||
[
|
|
||||||
`--headless=new`,
|
|
||||||
`--disable-gpu`,
|
|
||||||
`--remote-debugging-port=${PORT}`,
|
|
||||||
`--user-data-dir=${PROFILE}`,
|
|
||||||
`--window-size=1500,900`,
|
|
||||||
`--hide-scrollbars`,
|
|
||||||
`--no-first-run`,
|
|
||||||
`about:blank`,
|
|
||||||
],
|
|
||||||
{ stdio: "ignore", detached: true },
|
|
||||||
);
|
|
||||||
proc.unref();
|
|
||||||
for (let i = 0; i < 30; i++) {
|
|
||||||
try {
|
|
||||||
const r = await fetch(`http://127.0.0.1:${PORT}/json/version`);
|
|
||||||
if (r.ok) break;
|
|
||||||
} catch {}
|
|
||||||
await wait(300);
|
|
||||||
}
|
|
||||||
|
|
||||||
const t = await (await fetch(`http://127.0.0.1:${PORT}/json/new?about:blank`, { method: "PUT" })).json();
|
|
||||||
const ws = new WebSocket(t.webSocketDebuggerUrl);
|
|
||||||
await new Promise((r) => ws.addEventListener("open", () => r(), { once: true }));
|
|
||||||
let id = 0;
|
|
||||||
const pending = new Map();
|
|
||||||
ws.addEventListener("message", (e) => {
|
|
||||||
const m = JSON.parse(e.data);
|
|
||||||
if (m.id && pending.has(m.id)) {
|
|
||||||
const { resolve, reject } = pending.get(m.id);
|
|
||||||
pending.delete(m.id);
|
|
||||||
if (m.error) reject(new Error(m.error.message));
|
|
||||||
else resolve(m.result);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
const cmd = (method, params = {}) =>
|
|
||||||
new Promise((resolve, reject) => {
|
|
||||||
pending.set(++id, { resolve, reject });
|
|
||||||
ws.send(JSON.stringify({ id, method, params }));
|
|
||||||
});
|
|
||||||
|
|
||||||
await cmd("Emulation.setDeviceMetricsOverride", {
|
|
||||||
width: 1500, height: 900, deviceScaleFactor: 1, mobile: false,
|
|
||||||
});
|
|
||||||
await cmd("Page.navigate", { url: "http://localhost:3000" });
|
|
||||||
await wait(3000);
|
|
||||||
await cmd("Runtime.evaluate", {
|
|
||||||
expression: `document.querySelectorAll('video').forEach(v => { try { v.pause(); } catch{} });`,
|
|
||||||
});
|
|
||||||
// 滚到 Top12 区
|
|
||||||
await cmd("Runtime.evaluate", {
|
|
||||||
expression: `window.scrollTo(0, window.innerHeight);`,
|
|
||||||
});
|
|
||||||
await wait(1500);
|
|
||||||
// 多等一下 useRanking 拉到数据
|
|
||||||
await wait(2500);
|
|
||||||
|
|
||||||
const r = await cmd("Page.captureScreenshot", { format: "png" });
|
|
||||||
await writeFile(
|
|
||||||
"d:/ClaudeProjects/虚拟明星/UI-UX/docs/screenshots/top12-fix-verify.png",
|
|
||||||
Buffer.from(r.data, "base64"),
|
|
||||||
);
|
|
||||||
console.log("✓ saved");
|
|
||||||
ws.close();
|
|
||||||
try { process.kill(proc.pid); } catch {}
|
|
||||||
spawn("taskkill", ["/F", "/PID", String(proc.pid), "/T"], { stdio: "ignore" });
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
import { PrismaClient } from "@prisma/client";
|
|
||||||
const prisma = new PrismaClient({ log: ["error"] });
|
|
||||||
const rows = await prisma.$queryRaw`
|
|
||||||
SELECT INDEX_NAME, COLUMN_NAME, SEQ_IN_INDEX, NON_UNIQUE
|
|
||||||
FROM information_schema.STATISTICS
|
|
||||||
WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'votes'
|
|
||||||
ORDER BY INDEX_NAME, SEQ_IN_INDEX
|
|
||||||
`;
|
|
||||||
for (const r of rows) {
|
|
||||||
console.log(
|
|
||||||
` ${r.INDEX_NAME} col=${r.COLUMN_NAME} seq=${r.SEQ_IN_INDEX} unique=${r.NON_UNIQUE == 0 ? "Y" : "N"}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
await prisma.$disconnect();
|
|
||||||
@ -2,8 +2,7 @@ import type { NextRequest } from "next/server";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { rateLimit } from "@/lib/rate-limit";
|
import { rateLimit } from "@/lib/rate-limit";
|
||||||
import { getClientIp } from "@/lib/current-user";
|
import { getClientIp } from "@/lib/current-user";
|
||||||
import { storeOtp } from "@/lib/otp-store";
|
import { getRedis } from "@/lib/redis";
|
||||||
import { sendOtpSms } from "@/lib/sms";
|
|
||||||
import { ok, ERR } from "@/lib/api-response";
|
import { ok, ERR } from "@/lib/api-response";
|
||||||
|
|
||||||
const Body = z.object({
|
const Body = z.object({
|
||||||
@ -14,7 +13,7 @@ const Body = z.object({
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/auth/send-otp
|
* POST /api/auth/send-otp
|
||||||
* 发送短信验证码 · 单手机号 60s 限频 / 单 IP 5 分钟 100 次
|
* 发送短信验证码 · 单手机号 60s 限频 / 单 IP 5 分钟 5 次
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@ -31,47 +30,28 @@ export async function POST(req: NextRequest) {
|
|||||||
}
|
}
|
||||||
const ip = await getClientIp();
|
const ip = await getClientIp();
|
||||||
if (ip) {
|
if (ip) {
|
||||||
const ipRl = await rateLimit(`otp:ip:${ip}`, 300, 100);
|
const ipRl = await rateLimit(`otp:ip:${ip}`, 300, 5);
|
||||||
if (!ipRl.allowed) return ERR.RATE_LIMITED();
|
if (!ipRl.allowed) return ERR.RATE_LIMITED();
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成 6 位验证码 + 存储 (Redis 可用走 Redis, 否则走进程内 Map; 始终 5min TTL)
|
// 生成 6 位验证码
|
||||||
const code = String(Math.floor(100000 + Math.random() * 900000));
|
const code = String(Math.floor(100000 + Math.random() * 900000));
|
||||||
await storeOtp(phone, code);
|
|
||||||
|
|
||||||
// 开发环境下把验证码打在终端, 方便本地 QA / 排错; 生产不打
|
// 缓存到 Redis(5 分钟过期)
|
||||||
|
const redis = getRedis();
|
||||||
|
if (redis) {
|
||||||
|
await redis.set(`sms:otp:${phone}`, code, "EX", 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO[团队]: 接入真实短信服务(阿里云 / 火山引擎 SMS)
|
||||||
|
// - 模板:${SMS_TEMPLATE_CODE}
|
||||||
|
// - 签名:${SMS_SIGN_NAME}
|
||||||
|
// - 参数:{ code, expireMin: 5 }
|
||||||
if (process.env.NODE_ENV !== "production") {
|
if (process.env.NODE_ENV !== "production") {
|
||||||
console.log(`[dev-otp] phone=${phone} code=${code} (dev 环境也接受 123456)`);
|
console.log(`[dev-otp] 发送给 ${phone}: ${code}(开发环境也接受万能码 123456)`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 调阿里云短信发送
|
return ok({ message: "验证码已发送", expiresIn: 300 });
|
||||||
const sms = await sendOtpSms(phone, code);
|
|
||||||
|
|
||||||
if (sms.ok) {
|
|
||||||
console.log(`[sms] sent phone=${phone} bizId=${sms.bizId}`);
|
|
||||||
return ok({ message: "验证码已发送", expiresIn: 300 });
|
|
||||||
}
|
|
||||||
|
|
||||||
// 失败处理:
|
|
||||||
// - SMS 未配置且非生产 → 控制台打 code, 仍返回成功(开发态联调)
|
|
||||||
// - 阿里云明确返回参数 / 触发流控 → 422
|
|
||||||
// - 其它 → 500
|
|
||||||
if (sms.errorCode === "SMS_NOT_CONFIGURED") {
|
|
||||||
if (process.env.NODE_ENV !== "production") {
|
|
||||||
console.log(`[dev-otp] SMS 未配置, 验证码 ${phone}: ${code}(也可用万能码 123456)`);
|
|
||||||
return ok({ message: "验证码已发送", expiresIn: 300 });
|
|
||||||
}
|
|
||||||
console.error("[sms] 生产环境短信未配置, 检查 SMS_* 环境变量");
|
|
||||||
return ERR.INTERNAL("短信服务未配置");
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error(
|
|
||||||
`[sms] 发送失败 phone=${phone} code=${sms.errorCode} message=${sms.errorMessage}`,
|
|
||||||
);
|
|
||||||
if (sms.errorCode?.startsWith("isv.")) {
|
|
||||||
return ERR.VALIDATION(sms.errorMessage ?? "短信发送失败");
|
|
||||||
}
|
|
||||||
return ERR.INTERNAL("短信发送失败");
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("[POST /api/auth/send-otp]", e);
|
console.error("[POST /api/auth/send-otp]", e);
|
||||||
return ERR.INTERNAL();
|
return ERR.INTERNAL();
|
||||||
|
|||||||
@ -1,26 +1,17 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentUser } from "@/lib/current-user";
|
import { getCurrentUser } from "@/lib/current-user";
|
||||||
import { isSameUtcDay, startOfUtcDay } from "@/lib/date-utils";
|
|
||||||
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
|
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
|
||||||
|
|
||||||
/**
|
|
||||||
* 终身投票总额度,与前端 src/lib/store.ts 的 TOTAL_VOTE_QUOTA 对齐。
|
|
||||||
*/
|
|
||||||
const TOTAL_VOTE_QUOTA = 12;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/me
|
* GET /api/me
|
||||||
* 当前用户信息:基础资料、应援列表、签到状态、终身票数额度。
|
* 当前用户信息:基础资料、累计投票数、应援列表、签到状态、今日票数额度。
|
||||||
*
|
|
||||||
* 新增字段 votedArtists: string[] —— 按投票时间升序(最早投的在前),
|
|
||||||
* 供前端 hydrate 时取代纯 localStorage,跨设备/清缓存后仍可恢复。
|
|
||||||
*/
|
*/
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) return ERR.UNAUTHORIZED();
|
if (!user) return ERR.UNAUTHORIZED();
|
||||||
|
|
||||||
const today = startOfUtcDay();
|
const today = startOfDay();
|
||||||
|
|
||||||
type SupportRow = Awaited<
|
type SupportRow = Awaited<
|
||||||
ReturnType<typeof prisma.fanSupport.findMany>
|
ReturnType<typeof prisma.fanSupport.findMany>
|
||||||
@ -30,60 +21,68 @@ export async function GET() {
|
|||||||
no: string;
|
no: string;
|
||||||
name: string;
|
name: string;
|
||||||
enName: string;
|
enName: string;
|
||||||
|
slogan: string;
|
||||||
|
themeColor: string;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
currentRank: number | null;
|
currentRank: number | null;
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const [profile, signIn, supports, votedList] = (await Promise.all([
|
const [profile, signIn, supports, dailyQuotaRow, config] =
|
||||||
prisma.user.findUnique({
|
(await Promise.all([
|
||||||
where: { id: user.id },
|
prisma.user.findUnique({
|
||||||
select: {
|
where: { id: user.id },
|
||||||
id: true,
|
select: {
|
||||||
nickname: true,
|
id: true,
|
||||||
avatar: true,
|
nickname: true,
|
||||||
phone: true,
|
avatar: true,
|
||||||
createdAt: true,
|
phone: true,
|
||||||
},
|
createdAt: true,
|
||||||
}),
|
},
|
||||||
prisma.signIn.findFirst({
|
}),
|
||||||
where: { userId: user.id },
|
prisma.signIn.findFirst({
|
||||||
orderBy: { date: "desc" },
|
where: { userId: user.id },
|
||||||
}),
|
orderBy: { date: "desc" },
|
||||||
prisma.fanSupport.findMany({
|
}),
|
||||||
where: { userId: user.id },
|
prisma.fanSupport.findMany({
|
||||||
include: {
|
where: { userId: user.id },
|
||||||
artist: {
|
include: {
|
||||||
select: {
|
artist: {
|
||||||
id: true,
|
select: {
|
||||||
no: true,
|
id: true,
|
||||||
name: true,
|
no: true,
|
||||||
enName: true,
|
name: true,
|
||||||
voteCount: true,
|
enName: true,
|
||||||
currentRank: true,
|
slogan: true,
|
||||||
|
themeColor: true,
|
||||||
|
voteCount: true,
|
||||||
|
currentRank: true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
orderBy: { votedTotal: "desc" },
|
||||||
orderBy: { createdAt: "asc" },
|
}),
|
||||||
}),
|
prisma.dailyQuota.findUnique({
|
||||||
// 按投票顺序拉 votedArtists —— 前端 store 用作 hydrate 真相源
|
where: { userId_date: { userId: user.id, date: today } },
|
||||||
prisma.vote.findMany({
|
}),
|
||||||
where: { userId: user.id },
|
prisma.activityConfig.findUnique({ where: { id: 1 } }),
|
||||||
select: { artistId: true, createdAt: true },
|
])) as [
|
||||||
orderBy: { createdAt: "asc" },
|
Awaited<ReturnType<typeof prisma.user.findUnique>>,
|
||||||
}),
|
Awaited<ReturnType<typeof prisma.signIn.findFirst>>,
|
||||||
])) as [
|
SupportRow[],
|
||||||
Awaited<ReturnType<typeof prisma.user.findUnique>>,
|
Awaited<ReturnType<typeof prisma.dailyQuota.findUnique>>,
|
||||||
Awaited<ReturnType<typeof prisma.signIn.findFirst>>,
|
Awaited<ReturnType<typeof prisma.activityConfig.findUnique>>,
|
||||||
SupportRow[],
|
];
|
||||||
{ artistId: string; createdAt: Date }[],
|
|
||||||
];
|
|
||||||
|
|
||||||
if (!profile) return ERR.NOT_FOUND("用户不存在");
|
if (!profile) return ERR.NOT_FOUND("用户不存在");
|
||||||
|
|
||||||
const votedArtists = votedList.map((v) => v.artistId);
|
const totalVotes = await prisma.vote.aggregate({
|
||||||
const votedCount = votedArtists.length;
|
where: { userId: user.id },
|
||||||
const remaining = Math.max(0, TOTAL_VOTE_QUOTA - votedCount);
|
_sum: { count: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const dailyQuota = dailyQuotaRow?.totalQuota ?? config?.dailyQuota ?? 10;
|
||||||
|
const usedToday = dailyQuotaRow?.usedQuota ?? 0;
|
||||||
|
|
||||||
return ok(
|
return ok(
|
||||||
sanitizeBigInt({
|
sanitizeBigInt({
|
||||||
@ -91,19 +90,16 @@ export async function GET() {
|
|||||||
signIn: {
|
signIn: {
|
||||||
streak: signIn?.streak ?? 0,
|
streak: signIn?.streak ?? 0,
|
||||||
lastDate: signIn?.date ?? null,
|
lastDate: signIn?.date ?? null,
|
||||||
todaySignedIn: signIn ? isSameUtcDay(signIn.date, today) : false,
|
todaySignedIn: signIn ? sameDay(signIn.date, today) : false,
|
||||||
},
|
},
|
||||||
// 终身票数视图 —— 替代旧的 dailyQuota
|
totalVotes: totalVotes._sum.count ?? 0,
|
||||||
voteQuota: {
|
dailyQuota: {
|
||||||
total: TOTAL_VOTE_QUOTA,
|
total: dailyQuota,
|
||||||
used: votedCount,
|
used: usedToday,
|
||||||
remaining,
|
remaining: Math.max(0, dailyQuota - usedToday),
|
||||||
},
|
},
|
||||||
// 已投艺人 ID 列表(按时间升序),前端 hydrate 时回放到 store
|
|
||||||
votedArtists,
|
|
||||||
supports: supports.map((s: SupportRow) => ({
|
supports: supports.map((s: SupportRow) => ({
|
||||||
artist: s.artist,
|
artist: s.artist,
|
||||||
// 新规则下 votedTotal 固定 1;旧数据 votedTotal>1 仍真实反映历史
|
|
||||||
votedTotal: s.votedTotal,
|
votedTotal: s.votedTotal,
|
||||||
})),
|
})),
|
||||||
}),
|
}),
|
||||||
@ -113,3 +109,17 @@ export async function GET() {
|
|||||||
return ERR.INTERNAL();
|
return ERR.INTERNAL();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startOfDay(d = new Date()): Date {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(0, 0, 0, 0);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameDay(a: Date, b: Date) {
|
||||||
|
return (
|
||||||
|
a.getFullYear() === b.getFullYear() &&
|
||||||
|
a.getMonth() === b.getMonth() &&
|
||||||
|
a.getDate() === b.getDate()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getCurrentUser } from "@/lib/current-user";
|
import { getCurrentUser } from "@/lib/current-user";
|
||||||
import { startOfUtcDay } from "@/lib/date-utils";
|
|
||||||
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
|
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -13,7 +12,7 @@ export async function POST() {
|
|||||||
const user = await getCurrentUser();
|
const user = await getCurrentUser();
|
||||||
if (!user) return ERR.UNAUTHORIZED();
|
if (!user) return ERR.UNAUTHORIZED();
|
||||||
|
|
||||||
const today = startOfUtcDay();
|
const today = startOfDay();
|
||||||
const yesterday = new Date(today);
|
const yesterday = new Date(today);
|
||||||
yesterday.setDate(yesterday.getDate() - 1);
|
yesterday.setDate(yesterday.getDate() - 1);
|
||||||
|
|
||||||
@ -57,3 +56,8 @@ export async function POST() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startOfDay(d = new Date()): Date {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(0, 0, 0, 0);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import { ok, ERR } from "@/lib/api-response";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* GET /api/ranking
|
* GET /api/ranking
|
||||||
* 返回完整 36 人实时排名(按 voteCount 降序)。
|
* 返回完整 35 人实时排名(按 voteCount 降序)。
|
||||||
*
|
*
|
||||||
* 该接口适合每分钟轮询。生产环境会优先读 Redis 缓存(由后台聚合任务每分钟刷新)。
|
* 该接口适合每分钟轮询。生产环境会优先读 Redis 缓存(由后台聚合任务每分钟刷新)。
|
||||||
*/
|
*/
|
||||||
@ -17,6 +17,8 @@ export async function GET() {
|
|||||||
no: true,
|
no: true,
|
||||||
name: true,
|
name: true,
|
||||||
enName: true,
|
enName: true,
|
||||||
|
slogan: true,
|
||||||
|
themeColor: true,
|
||||||
avatar: true,
|
avatar: true,
|
||||||
portrait: true,
|
portrait: true,
|
||||||
voteCount: true,
|
voteCount: true,
|
||||||
|
|||||||
@ -12,46 +12,32 @@ import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
|
|||||||
|
|
||||||
type TxClient = Prisma.TransactionClient;
|
type TxClient = Prisma.TransactionClient;
|
||||||
|
|
||||||
/**
|
|
||||||
* 终身投票额度:每个用户共 12 票,每位艺人最多 1 票。
|
|
||||||
* 用 const 而非读 DB,避免每次请求多一次查询。前端 store 同名常量保持一致。
|
|
||||||
*/
|
|
||||||
const TOTAL_VOTE_QUOTA = 12;
|
|
||||||
|
|
||||||
const VoteBody = z.object({
|
const VoteBody = z.object({
|
||||||
artistId: z.string().min(1).max(8),
|
artistId: z.string().min(1).max(8),
|
||||||
// 旧前端仍可能传 count 字段(>=1),后端一律视为 1 票
|
count: z.number().int().min(1).max(99_999),
|
||||||
count: z.number().int().min(1).max(99_999).optional(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
/** 内部抛错用,事务捕获后转业务错误响应 */
|
/** 内部抛错用,事务捕获后转为业务错误响应 */
|
||||||
class QuotaExhaustedError extends Error {
|
class QuotaExhaustedError extends Error {
|
||||||
constructor() {
|
constructor(public remaining: number) {
|
||||||
super("QUOTA_EXHAUSTED");
|
super("QUOTA_EXHAUSTED");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
class AlreadyVotedError extends Error {
|
|
||||||
constructor() {
|
|
||||||
super("ALREADY_VOTED");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* POST /api/vote
|
* POST /api/vote
|
||||||
|
* 投票接口。
|
||||||
*
|
*
|
||||||
* 新规则:
|
* 规则:
|
||||||
* - 每用户终身 12 票
|
* - 每日总额度:ActivityConfig.dailyQuota(默认 10)
|
||||||
* - 每艺人 1 票(DB 层 @@unique([userId, artistId]) 兜底)
|
* - 无单艺人上限(perArtistLimit 字段保留但不强制)
|
||||||
* - 不可撤销,不限时
|
* - 单用户限流:1 秒 5 次;单 IP 限流:60 秒 60 次
|
||||||
* - 单用户限流:1 秒 5 次;单 IP 限流:60 秒 60 次
|
|
||||||
*
|
*
|
||||||
* 流程:
|
* 流程:
|
||||||
* 1. 鉴权 + 反作弊限流
|
* 1. 鉴权 + 反作弊限流
|
||||||
* 2. 校验活动开关(voteEnabled)
|
* 2. 校验活动开关 + 时间窗
|
||||||
* 3. 事务:已投艺人计数 >= 12 → QUOTA_EXHAUSTED;否则写票
|
* 3. 事务:当日额度检查 → 写投票 → 累加艺人票数 → 更新应援 → 扣减额度
|
||||||
* - DB unique 冲突 (P2002) → ALREADY_VOTED
|
* 4. 返回最新票数 + 当日剩余
|
||||||
* 4. 累加 artist.voteCount;upsert FanSupport(votedTotal=1)
|
|
||||||
* 5. 返回最新票数 + 剩余票数
|
|
||||||
*/
|
*/
|
||||||
export async function POST(req: NextRequest) {
|
export async function POST(req: NextRequest) {
|
||||||
try {
|
try {
|
||||||
@ -72,29 +58,41 @@ export async function POST(req: NextRequest) {
|
|||||||
if (!parsed.success) {
|
if (!parsed.success) {
|
||||||
return ERR.VALIDATION(parsed.error.issues[0]?.message ?? "参数错误");
|
return ERR.VALIDATION(parsed.error.issues[0]?.message ?? "参数错误");
|
||||||
}
|
}
|
||||||
const { artistId } = parsed.data;
|
const { artistId, count } = parsed.data;
|
||||||
|
|
||||||
const config = await prisma.activityConfig.findUnique({ where: { id: 1 } });
|
const config = await prisma.activityConfig.findUnique({ where: { id: 1 } });
|
||||||
if (!config?.voteEnabled) return ERR.ACTIVITY_OFF();
|
if (!config?.voteEnabled) return ERR.ACTIVITY_OFF();
|
||||||
// 新规则不限时,移除 startAt/endAt 校验
|
const now = new Date();
|
||||||
|
if (now < config.startAt || now > config.endAt) return ERR.ACTIVITY_OFF();
|
||||||
|
|
||||||
const ua = await getUserAgent();
|
const ua = await getUserAgent();
|
||||||
|
const today = startOfDay();
|
||||||
|
const dailyQuota = config.dailyQuota;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const result = await prisma.$transaction(async (tx: TxClient) => {
|
const result = await prisma.$transaction(async (tx: TxClient) => {
|
||||||
// 1. 终身额度校验:已投艺人数 >= 12 → 拒
|
// 1. 当日额度(不存在则按 config 创建)
|
||||||
const votedSoFar = await tx.vote.count({ where: { userId: user.id } });
|
const dq = await tx.dailyQuota.upsert({
|
||||||
if (votedSoFar >= TOTAL_VOTE_QUOTA) {
|
where: { userId_date: { userId: user.id, date: today } },
|
||||||
throw new QuotaExhaustedError();
|
create: {
|
||||||
|
userId: user.id,
|
||||||
|
date: today,
|
||||||
|
totalQuota: dailyQuota,
|
||||||
|
usedQuota: 0,
|
||||||
|
},
|
||||||
|
update: {},
|
||||||
|
});
|
||||||
|
const remaining = dq.totalQuota - dq.usedQuota;
|
||||||
|
if (count > remaining) {
|
||||||
|
throw new QuotaExhaustedError(remaining);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. 写入投票
|
// 2. 写入投票
|
||||||
// DB unique (userId, artistId) 在 catch 里转 ALREADY_VOTED
|
|
||||||
const vote = await tx.vote.create({
|
const vote = await tx.vote.create({
|
||||||
data: {
|
data: {
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
artistId,
|
artistId,
|
||||||
count: 1,
|
count,
|
||||||
source: "QUOTA",
|
source: "QUOTA",
|
||||||
ip: ip ?? undefined,
|
ip: ip ?? undefined,
|
||||||
ua: ua ?? undefined,
|
ua: ua ?? undefined,
|
||||||
@ -104,23 +102,27 @@ export async function POST(req: NextRequest) {
|
|||||||
// 3. 累加艺人票数
|
// 3. 累加艺人票数
|
||||||
const artist = await tx.artist.update({
|
const artist = await tx.artist.update({
|
||||||
where: { id: artistId },
|
where: { id: artistId },
|
||||||
data: { voteCount: { increment: 1 } },
|
data: { voteCount: { increment: count } },
|
||||||
select: { id: true, voteCount: true, name: true },
|
select: { id: true, voteCount: true, name: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
// 4. 应援关系 —— 每艺人 1 票,votedTotal 固定 1
|
// 4. 应援关系
|
||||||
await tx.fanSupport.upsert({
|
await tx.fanSupport.upsert({
|
||||||
where: { userId_artistId: { userId: user.id, artistId } },
|
where: { userId_artistId: { userId: user.id, artistId } },
|
||||||
create: { userId: user.id, artistId, votedTotal: 1 },
|
create: { userId: user.id, artistId, votedTotal: count },
|
||||||
update: { votedTotal: 1 },
|
update: { votedTotal: { increment: count } },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 扣减当日额度
|
||||||
|
const updatedDq = await tx.dailyQuota.update({
|
||||||
|
where: { userId_date: { userId: user.id, date: today } },
|
||||||
|
data: { usedQuota: { increment: count } },
|
||||||
});
|
});
|
||||||
|
|
||||||
const votedAfter = votedSoFar + 1;
|
|
||||||
return {
|
return {
|
||||||
vote,
|
vote,
|
||||||
artist,
|
artist,
|
||||||
votedCount: votedAfter,
|
remaining: updatedDq.totalQuota - updatedDq.usedQuota,
|
||||||
remaining: TOTAL_VOTE_QUOTA - votedAfter,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -129,33 +131,15 @@ export async function POST(req: NextRequest) {
|
|||||||
artistId: result.artist.id,
|
artistId: result.artist.id,
|
||||||
artistVotes: result.artist.voteCount,
|
artistVotes: result.artist.voteCount,
|
||||||
voteId: result.vote.id,
|
voteId: result.vote.id,
|
||||||
votedCount: result.votedCount,
|
|
||||||
remaining: result.remaining,
|
remaining: result.remaining,
|
||||||
totalQuota: TOTAL_VOTE_QUOTA,
|
dailyQuota,
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof QuotaExhaustedError) {
|
if (e instanceof QuotaExhaustedError) {
|
||||||
return ERR.QUOTA_EXHAUSTED();
|
return ERR.QUOTA_EXHAUSTED(
|
||||||
}
|
`今日票数仅剩 ${e.remaining} 票,无法一次投出 ${count} 票`,
|
||||||
if (e instanceof AlreadyVotedError) {
|
);
|
||||||
return ERR.ALREADY_VOTED();
|
|
||||||
}
|
|
||||||
// Prisma unique 冲突 → ALREADY_VOTED
|
|
||||||
if (
|
|
||||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
e.code === "P2002"
|
|
||||||
) {
|
|
||||||
return ERR.ALREADY_VOTED();
|
|
||||||
}
|
|
||||||
// 艺人不存在:
|
|
||||||
// - P2003: FK 违反(vote.create 时 artistId 外键约束失败)
|
|
||||||
// - P2025: 记录不存在(artist.update 找不到目标)
|
|
||||||
if (
|
|
||||||
e instanceof Prisma.PrismaClientKnownRequestError &&
|
|
||||||
(e.code === "P2003" || e.code === "P2025")
|
|
||||||
) {
|
|
||||||
return ERR.NOT_FOUND("艺人不存在");
|
|
||||||
}
|
}
|
||||||
throw e;
|
throw e;
|
||||||
}
|
}
|
||||||
@ -164,3 +148,9 @@ export async function POST(req: NextRequest) {
|
|||||||
return ERR.INTERNAL();
|
return ERR.INTERNAL();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function startOfDay(d = new Date()): Date {
|
||||||
|
const x = new Date(d);
|
||||||
|
x.setHours(0, 0, 0, 0);
|
||||||
|
return x;
|
||||||
|
}
|
||||||
|
|||||||
@ -16,10 +16,10 @@ export async function generateMetadata({
|
|||||||
}: ArtistPageProps): Promise<Metadata> {
|
}: ArtistPageProps): Promise<Metadata> {
|
||||||
const { id } = await params;
|
const { id } = await params;
|
||||||
const artist = getArtist(id);
|
const artist = getArtist(id);
|
||||||
if (!artist) return { title: "艺人不存在 · 银河初星计划 C . S . G" };
|
if (!artist) return { title: "艺人不存在 · CYBER STAR" };
|
||||||
|
|
||||||
return {
|
return {
|
||||||
title: `${artist.name} · ${artist.enName} · 银河初星计划 C . S . G`,
|
title: `${artist.name} · ${artist.enName} · CYBER STAR`,
|
||||||
description: artist.bio.slice(0, 120),
|
description: artist.bio.slice(0, 120),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 81 KiB After Width: | Height: | Size: 25 KiB |
@ -33,27 +33,15 @@ const inter = Inter({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "银河初星计划 C . S . G",
|
title: "CYBER ✦ STAR · 虚拟偶像 Top12 出道企划",
|
||||||
description:
|
description:
|
||||||
"36 位虚拟偶像候选人,由你投票决出最终出道 Top12。银河初星计划 C . S . G。",
|
"35 位虚拟偶像候选人,由你投票决出最终出道 Top12。Cyber Star · Virtual Idol Debut Project.",
|
||||||
keywords: [
|
keywords: ["虚拟偶像", "出道", "投票", "Top12", "Cyber Star", "Virtual Idol"],
|
||||||
"银河初星计划",
|
|
||||||
"C . S . G",
|
|
||||||
"虚拟偶像",
|
|
||||||
"出道",
|
|
||||||
"投票",
|
|
||||||
"Top12",
|
|
||||||
"Virtual Idol",
|
|
||||||
],
|
|
||||||
openGraph: {
|
openGraph: {
|
||||||
title: "银河初星计划 C . S . G",
|
title: "CYBER ✦ STAR",
|
||||||
description: "虚拟偶像 Top12 出道企划",
|
description: "虚拟偶像 Top12 出道企划",
|
||||||
type: "website",
|
type: "website",
|
||||||
},
|
},
|
||||||
icons: {
|
|
||||||
icon: "/favicon.ico?v=4",
|
|
||||||
shortcut: "/favicon.ico?v=4",
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function RootLayout({
|
export default function RootLayout({
|
||||||
@ -69,7 +57,7 @@ export default function RootLayout({
|
|||||||
<body className="min-h-full flex flex-col">
|
<body className="min-h-full flex flex-col">
|
||||||
<Providers>
|
<Providers>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
<main className="flex-1 pt-20">{children}</main>
|
<main className="flex-1">{children}</main>
|
||||||
<Footer />
|
<Footer />
|
||||||
</Providers>
|
</Providers>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@ -5,6 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { Phone, KeyRound, Loader2 } from "lucide-react";
|
import { Phone, KeyRound, Loader2 } from "lucide-react";
|
||||||
|
import Logo from "@/components/Logo";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
@ -70,12 +71,7 @@ export default function LoginForm() {
|
|||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
console.error("[login] signIn 返回错误:", result);
|
setError("验证码错误或已失效");
|
||||||
setError(
|
|
||||||
result.error === "CredentialsSignin"
|
|
||||||
? "验证码错误或已失效"
|
|
||||||
: `登录失败:${result.error}`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
router.push(callbackUrl);
|
router.push(callbackUrl);
|
||||||
router.refresh();
|
router.refresh();
|
||||||
@ -91,15 +87,8 @@ export default function LoginForm() {
|
|||||||
<div className="min-h-[calc(100vh-128px)] flex items-center justify-center px-4 py-10">
|
<div className="min-h-[calc(100vh-128px)] flex items-center justify-center px-4 py-10">
|
||||||
<div className="w-full max-w-md">
|
<div className="w-full max-w-md">
|
||||||
{/* Logo */}
|
{/* Logo */}
|
||||||
<div className="flex flex-col items-center mb-8">
|
<div className="text-center mb-8">
|
||||||
<img
|
<Logo size="lg" href={null} />
|
||||||
src="/logo-v4.png?v=4"
|
|
||||||
alt="银河初星计划 C . S . G"
|
|
||||||
decoding="async"
|
|
||||||
draggable={false}
|
|
||||||
className="block select-none h-20 sm:h-24 w-auto"
|
|
||||||
style={{ background: "transparent" }}
|
|
||||||
/>
|
|
||||||
<p className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3">
|
<p className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3">
|
||||||
Sign in to Vote
|
Sign in to Vote
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@ -2,7 +2,7 @@ import { Suspense } from "react";
|
|||||||
import LoginForm from "./LoginForm";
|
import LoginForm from "./LoginForm";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "登录 · 银河初星计划 C . S . G",
|
title: "登录 · CYBER STAR",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
|
|||||||
@ -1,17 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo } from "react";
|
import { useState } from "react";
|
||||||
import { signOut } from "next-auth/react";
|
import { signOut } from "next-auth/react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import UserHeader from "@/components/me/UserHeader";
|
import UserHeader from "@/components/me/UserHeader";
|
||||||
import QuotaCard from "@/components/me/QuotaCard";
|
import QuotaCard from "@/components/me/QuotaCard";
|
||||||
import StatsGrid from "@/components/me/StatsGrid";
|
import StatsGrid from "@/components/me/StatsGrid";
|
||||||
|
import SignInCalendar from "@/components/me/SignInCalendar";
|
||||||
import MyFanSupport from "@/components/me/MyFanSupport";
|
import MyFanSupport from "@/components/me/MyFanSupport";
|
||||||
|
import { MOCK_USER, getFanSupports, type MockUser } from "@/lib/mock-user";
|
||||||
import {
|
import {
|
||||||
useVoteStore,
|
useVoteStore,
|
||||||
selectRemaining,
|
selectRemaining,
|
||||||
TOTAL_VOTE_QUOTA,
|
DAILY_VOTE_QUOTA,
|
||||||
type MySupport,
|
|
||||||
} from "@/lib/store";
|
} from "@/lib/store";
|
||||||
|
|
||||||
interface MeContentProps {
|
interface MeContentProps {
|
||||||
@ -22,21 +23,69 @@ interface MeContentProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function MeContent({ session }: MeContentProps) {
|
export default function MeContent({ session }: MeContentProps) {
|
||||||
// 订阅 store 原始引用(稳定,仅在 set() 时变更),组件内 useMemo 派生 supports,
|
const myTotalVotes = useVoteStore((s) => s.myTotalVotes);
|
||||||
// 避免 Zustand v5 + useSyncExternalStore 对"selector 返回新引用"报 infinite-loop 错。
|
|
||||||
const votedArtists = useVoteStore((s) => s.votedArtists);
|
|
||||||
const storeArtists = useVoteStore((s) => s.artists);
|
const storeArtists = useVoteStore((s) => s.artists);
|
||||||
const remaining = useVoteStore(selectRemaining);
|
const remaining = useVoteStore(selectRemaining);
|
||||||
const votedCount = votedArtists.length;
|
|
||||||
|
|
||||||
const supports = useMemo<MySupport[]>(() => {
|
const [signedInToday, setSignedInToday] = useState(MOCK_USER.todaySignedIn);
|
||||||
const list: MySupport[] = [];
|
const [weeklySignIn, setWeeklySignIn] = useState(MOCK_USER.weeklySignIn);
|
||||||
for (const id of votedArtists) {
|
|
||||||
const artist = storeArtists.find((a) => a.id === id);
|
const user: MockUser = {
|
||||||
if (artist) list.push({ artist });
|
...MOCK_USER,
|
||||||
|
id: session.id,
|
||||||
|
nickname: session.nickname,
|
||||||
|
todaySignedIn: signedInToday,
|
||||||
|
weeklySignIn,
|
||||||
|
totalVotes: MOCK_USER.totalVotes + myTotalVotes,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 用 store 里最新的艺人排名重算"我的应援"当前排名
|
||||||
|
const supports = getFanSupports().map((s) => {
|
||||||
|
const fresh = storeArtists.find((a) => a.id === s.artist.id);
|
||||||
|
return fresh ? { ...s, artist: fresh } : s;
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleInvite = async () => {
|
||||||
|
const url =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? `${window.location.origin}?invite=${session.id}`
|
||||||
|
: "";
|
||||||
|
if (typeof navigator !== "undefined" && navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share({
|
||||||
|
title: "CYBER STAR · 一起为偶像应援",
|
||||||
|
text: "邀请你加入虚拟偶像 Top12 出道企划!",
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return list;
|
try {
|
||||||
}, [votedArtists, storeArtists]);
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast.success("邀请链接已复制 · 快去喊朋友一起来");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败,请手动复制地址");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSignIn = () => {
|
||||||
|
if (signedInToday) {
|
||||||
|
toast("今日已签到", { icon: "✓" });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const idx = weeklySignIn.findIndex((v) => !v);
|
||||||
|
if (idx === -1) {
|
||||||
|
toast("本周已全部签到");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const next = [...weeklySignIn];
|
||||||
|
next[idx] = true;
|
||||||
|
setWeeklySignIn(next);
|
||||||
|
setSignedInToday(true);
|
||||||
|
toast.success("签到成功");
|
||||||
|
};
|
||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
toast("正在退出登录…");
|
toast("正在退出登录…");
|
||||||
@ -45,15 +94,26 @@ export default function MeContent({ session }: MeContentProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10 space-y-8">
|
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10 space-y-8">
|
||||||
<UserHeader
|
<UserHeader user={user} onLogout={handleLogout} />
|
||||||
nickname={session.nickname}
|
|
||||||
userId={session.id}
|
<QuotaCard
|
||||||
onLogout={handleLogout}
|
remaining={remaining}
|
||||||
|
dailyQuota={DAILY_VOTE_QUOTA}
|
||||||
|
cumulative={user.totalVotes}
|
||||||
|
onInvite={handleInvite}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<QuotaCard remaining={remaining} totalQuota={TOTAL_VOTE_QUOTA} />
|
<StatsGrid user={user} />
|
||||||
|
|
||||||
<StatsGrid voted={votedCount} remaining={remaining} />
|
<section>
|
||||||
|
<SectionTitle label="每日签到" />
|
||||||
|
<SignInCalendar
|
||||||
|
weekly={user.weeklySignIn}
|
||||||
|
todaySigned={user.todaySignedIn}
|
||||||
|
streak={user.signInStreak}
|
||||||
|
onSignIn={handleSignIn}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
<SectionTitle label="我的应援" />
|
<SectionTitle label="我的应援" />
|
||||||
|
|||||||
@ -4,7 +4,7 @@ import { auth } from "@/lib/auth";
|
|||||||
import MeContent from "./MeContent";
|
import MeContent from "./MeContent";
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "个人中心 · 银河初星计划 C . S . G",
|
title: "个人中心 · CYBER STAR",
|
||||||
};
|
};
|
||||||
|
|
||||||
export default async function MePage() {
|
export default async function MePage() {
|
||||||
|
|||||||
@ -7,65 +7,36 @@ import Top12Bar from "@/components/Top12Bar";
|
|||||||
import ArtistCard from "@/components/cards/ArtistCard";
|
import ArtistCard from "@/components/cards/ArtistCard";
|
||||||
import ArtistFilters, { type TagFilter } from "@/components/ArtistFilters";
|
import ArtistFilters, { type TagFilter } from "@/components/ArtistFilters";
|
||||||
import VoteModal from "@/components/VoteModal";
|
import VoteModal from "@/components/VoteModal";
|
||||||
import { sortArtists, type SortKey } from "@/lib/mock-data";
|
import { getActivityEndTime, sortArtists } from "@/lib/mock-data";
|
||||||
import { useVoteStore } from "@/lib/store";
|
import { useVoteStore } from "@/lib/store";
|
||||||
import { useVoteAction } from "@/hooks/useVoteAction";
|
import { useVoteAction } from "@/hooks/useVoteAction";
|
||||||
import { useRanking } from "@/hooks/useRanking";
|
|
||||||
import { useScrollRestore } from "@/hooks/useScrollRestore";
|
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { tosUrl } from "@/lib/tos";
|
|
||||||
import type { Artist } from "@/types/artist";
|
|
||||||
|
|
||||||
export default function Home() {
|
export default function Home() {
|
||||||
const storeArtists = useVoteStore((s) => s.artists);
|
const artists = useVoteStore((s) => s.artists);
|
||||||
|
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
|
||||||
// 30s 轮询 /api/ranking 拿服务端真实票数
|
useVoteAction();
|
||||||
const live = useRanking({ pollInterval: 30_000 });
|
|
||||||
|
|
||||||
// 投票成功后立即 refresh 排名,不等下次轮询(解决"票数 +1 / Top12 排位延迟"问题)
|
|
||||||
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
|
|
||||||
useVoteAction({ onVoteSuccess: live.refresh });
|
|
||||||
|
|
||||||
const [tagFilter, setTagFilter] = useState<TagFilter>("all");
|
const [tagFilter, setTagFilter] = useState<TagFilter>("all");
|
||||||
const [sortKey, setSortKey] = useState<SortKey>("votes");
|
|
||||||
const [filterStuck, setFilterStuck] = useState(false);
|
const [filterStuck, setFilterStuck] = useState(false);
|
||||||
const filterSentinelRef = useRef<HTMLDivElement>(null);
|
const filterSentinelRef = useRef<HTMLDivElement>(null);
|
||||||
const setStoreFilterStuck = useUIStore((s) => s.setFilterStuck);
|
|
||||||
|
|
||||||
// 首页滚动位置 per-tab 记忆:从艺人详情点 ← 返回时恢复到上次浏览位置
|
const endTime = useMemo(() => getActivityEndTime(), []);
|
||||||
useScrollRestore("home");
|
|
||||||
|
|
||||||
// 数据同步:本地乐观投票 + 服务端票数取 max,避免本地 store 只看到自己投的票。
|
|
||||||
// 与 /ranking 页面同一策略,首页 Top12 + 候选区都基于这份合并数据。
|
|
||||||
const artists = useMemo<Artist[]>(() => {
|
|
||||||
const apiVotes = new Map<string, number>();
|
|
||||||
if (live.data?.list) {
|
|
||||||
for (const row of live.data.list) apiVotes.set(row.id, row.voteCount);
|
|
||||||
}
|
|
||||||
const merged = storeArtists.map((a) => {
|
|
||||||
const apiV = apiVotes.get(a.id) ?? 0;
|
|
||||||
return apiV > a.votes ? { ...a, votes: apiV } : a;
|
|
||||||
});
|
|
||||||
const ranked = sortArtists(merged, "votes");
|
|
||||||
return ranked.map((a, i) => ({ ...a, rank: i + 1 }));
|
|
||||||
}, [storeArtists, live.data]);
|
|
||||||
|
|
||||||
const visibleArtists = useMemo(() => {
|
const visibleArtists = useMemo(() => {
|
||||||
let list = [...artists];
|
let list = [...artists];
|
||||||
if (tagFilter !== "all") {
|
if (tagFilter !== "all") {
|
||||||
list = list.filter((a) => a.tags.includes(tagFilter));
|
list = list.filter((a) => a.tags.includes(tagFilter));
|
||||||
}
|
}
|
||||||
return sortArtists(list, sortKey);
|
return sortArtists(list, "votes");
|
||||||
}, [artists, tagFilter, sortKey]);
|
}, [artists, tagFilter]);
|
||||||
|
|
||||||
// 仅在首页启用 scroll-snap:用户接近 Hero/Top12/候选区时自然吸附。
|
// 仅在首页启用 scroll-snap mandatory:用户下滑就立即切换到下一个 snap 点
|
||||||
// 用 proximity 而不是 mandatory —— mandatory 会把"滚到底部"强制吸回到最后一个
|
// (Hero → Top12 → 候选区)。卸载时还原。
|
||||||
// snap 点的 start(候选区顶部),表现为回弹;proximity 只在靠近时吸,远离不干预。
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const prev = root.style.scrollSnapType;
|
const prev = root.style.scrollSnapType;
|
||||||
root.style.scrollSnapType = "y proximity";
|
root.style.scrollSnapType = "y mandatory";
|
||||||
return () => {
|
return () => {
|
||||||
root.style.scrollSnapType = prev;
|
root.style.scrollSnapType = prev;
|
||||||
};
|
};
|
||||||
@ -89,24 +60,16 @@ export default function Home() {
|
|||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// 把 filterStuck 同步到全局 UI store —— 让导航栏感知,在吸顶时关掉自己的玻璃,
|
|
||||||
// 让筛选条延伸出的"共享玻璃带"成为唯一的 backdrop-filter,消除接缝
|
|
||||||
useEffect(() => {
|
|
||||||
setStoreFilterStuck(filterStuck);
|
|
||||||
return () => setStoreFilterStuck(false);
|
|
||||||
}, [filterStuck, setStoreFilterStuck]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Hero · 全屏沉浸式视频 · 作为第一个 snap 点
|
{/* Hero · 全屏沉浸式视频 · 作为第一个 snap 点 */}
|
||||||
-mt-20 把 Hero 拉到 main 顶部 padding 之上,让视频铺到导航后面(与毛玻璃导航重叠) */}
|
|
||||||
<div
|
<div
|
||||||
className="-mt-20"
|
|
||||||
style={{
|
style={{
|
||||||
scrollSnapAlign: "start",
|
scrollSnapAlign: "start",
|
||||||
|
scrollMarginTop: "80px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<HeroBanner videoSrc={tosUrl("videos/hero-pv.mp4")} />
|
<HeroBanner endTime={endTime} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Top12 出道位 · 作为第二个 snap 点:滚动结束后自然落到这里,标题贴近顶部 */}
|
{/* Top12 出道位 · 作为第二个 snap 点:滚动结束后自然落到这里,标题贴近顶部 */}
|
||||||
@ -134,7 +97,7 @@ export default function Home() {
|
|||||||
<div className="flex items-end justify-between mb-3 px-1">
|
<div className="flex items-end justify-between mb-3 px-1">
|
||||||
<h2 className="font-display text-base sm:text-lg tracking-[0.2em] text-white inline-flex items-center gap-2">
|
<h2 className="font-display text-base sm:text-lg tracking-[0.2em] text-white inline-flex items-center gap-2">
|
||||||
<Users size={16} className="text-purple-300" />
|
<Users size={16} className="text-purple-300" />
|
||||||
{artists.length} 位候选人
|
35 位候选人
|
||||||
</h2>
|
</h2>
|
||||||
<p className="font-label text-[11px] tracking-widest text-white/45 uppercase">
|
<p className="font-label text-[11px] tracking-widest text-white/45 uppercase">
|
||||||
当前显示{" "}
|
当前显示{" "}
|
||||||
@ -149,31 +112,17 @@ export default function Home() {
|
|||||||
{/* 哨兵:用于检测筛选条是否吸顶 */}
|
{/* 哨兵:用于检测筛选条是否吸顶 */}
|
||||||
<div ref={filterSentinelRef} aria-hidden style={{ height: 0 }} />
|
<div ref={filterSentinelRef} aria-hidden style={{ height: 0 }} />
|
||||||
|
|
||||||
{/* 筛选条 · 外层铺满,内层版心承载文案。
|
{/* 筛选条 · 外层铺满(与导航栏同宽),吸顶后启用毛玻璃;内层版心承载文案 */}
|
||||||
吸顶时,absolute 子层把玻璃从 -top-20 一路扩到容器底部 ——
|
|
||||||
这是一个单一元素的 backdrop-filter,横跨 nav 区域 + filter 区域,
|
|
||||||
消除两块独立玻璃在 y=80 接缝处的视觉割裂。导航栏同步关掉自己的玻璃。 */}
|
|
||||||
<div
|
<div
|
||||||
className="sticky z-30 transition-colors duration-200"
|
className={cn(
|
||||||
|
"sticky z-30 transition-colors duration-200",
|
||||||
|
filterStuck &&
|
||||||
|
"backdrop-blur-xl bg-[rgba(13,10,36,0.85)] border-b border-white/[0.08]",
|
||||||
|
)}
|
||||||
style={{ top: "80px" }}
|
style={{ top: "80px" }}
|
||||||
>
|
>
|
||||||
{/* 共享玻璃带:absolute,吸顶时延伸到 nav 顶部,opacity 平滑过渡 */}
|
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
<div
|
<ArtistFilters tagFilter={tagFilter} onTagChange={setTagFilter} />
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-x-0 -top-20 bottom-0 pointer-events-none",
|
|
||||||
"bg-surface/40 backdrop-blur-xl backdrop-saturate-150 border-b border-white/[0.06]",
|
|
||||||
"transition-opacity duration-300",
|
|
||||||
filterStuck ? "opacity-100" : "opacity-0",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="relative max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<ArtistFilters
|
|
||||||
tagFilter={tagFilter}
|
|
||||||
onTagChange={setTagFilter}
|
|
||||||
sort={sortKey}
|
|
||||||
onSortChange={setSortKey}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -194,7 +143,7 @@ export default function Home() {
|
|||||||
<VoteModal
|
<VoteModal
|
||||||
artist={target}
|
artist={target}
|
||||||
remaining={remaining}
|
remaining={remaining}
|
||||||
totalQuota={totalQuota}
|
dailyQuota={dailyQuota}
|
||||||
onClose={closeVote}
|
onClose={closeVote}
|
||||||
onConfirm={confirmVote}
|
onConfirm={confirmVote}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -14,55 +14,27 @@ import type { Artist } from "@/types/artist";
|
|||||||
|
|
||||||
export default function RankingPage() {
|
export default function RankingPage() {
|
||||||
const storeArtists = useVoteStore((s) => s.artists);
|
const storeArtists = useVoteStore((s) => s.artists);
|
||||||
|
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
|
||||||
|
useVoteAction();
|
||||||
|
|
||||||
const live = useRanking({ pollInterval: 30_000 });
|
const live = useRanking({ pollInterval: 30_000 });
|
||||||
|
|
||||||
// 投票成功后立即 refresh 排名,不等下次轮询
|
|
||||||
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
|
|
||||||
useVoteAction({ onVoteSuccess: live.refresh });
|
|
||||||
|
|
||||||
// 数据同步:本地乐观投票 + 服务端最新票数取 max(避免 API 落后覆盖本地新票,
|
|
||||||
// 也避免本地缺其他用户的票数)。合并后按 votes desc + no asc 重新排序并赋 rank。
|
|
||||||
const sorted = useMemo<Artist[]>(() => {
|
const sorted = useMemo<Artist[]>(() => {
|
||||||
const apiVotes = new Map<string, number>();
|
if (live.data?.list && live.data.list.length > 0) {
|
||||||
if (live.data?.list) {
|
return live.data.list.map((row) => {
|
||||||
for (const row of live.data.list) apiVotes.set(row.id, row.voteCount);
|
const base = storeArtists.find((a) => a.id === row.id);
|
||||||
|
if (!base) return row as unknown as Artist;
|
||||||
|
return { ...base, votes: row.voteCount, rank: row.rank };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
const merged = storeArtists.map((a) => {
|
return sortArtists(storeArtists, "votes");
|
||||||
const apiV = apiVotes.get(a.id) ?? 0;
|
|
||||||
return apiV > a.votes ? { ...a, votes: apiV } : a;
|
|
||||||
});
|
|
||||||
const ranked = sortArtists(merged, "votes");
|
|
||||||
// 重新按合并后的排名赋 rank(store 自带的 rank 仅来自本地 vote 后的 rank())
|
|
||||||
return ranked.map((a, i) => ({ ...a, rank: i + 1 }));
|
|
||||||
}, [storeArtists, live.data]);
|
}, [storeArtists, live.data]);
|
||||||
|
|
||||||
// 仅有票的人能"进榜单",0 票不参与排名兜底
|
const top3 = sorted.slice(0, 3);
|
||||||
const ranked = sorted.filter((a) => a.votes > 0);
|
const top4to12 = sorted.slice(3, 12);
|
||||||
const zeros = sorted.filter((a) => a.votes === 0);
|
const candidates = sorted.slice(12);
|
||||||
// 只要有 1 人有票就显示领奖台(#2/#3 缺位会自动以"虚位以待"占位);
|
|
||||||
// 至少 12 人有票才有"出道线 / 复活位"概念
|
|
||||||
const podiumReady = ranked.length >= 1;
|
|
||||||
const debutReady = ranked.length >= 12;
|
|
||||||
|
|
||||||
const top3 = podiumReady ? ranked.slice(0, 3) : [];
|
const debutCutoff = sorted[11]?.votes ?? 0;
|
||||||
|
|
||||||
// 出道线上方(top3 之后到出道线之间)
|
|
||||||
// - Top12 满员:经典 9 位(rank 4-12)
|
|
||||||
// - 已有领奖台但 Top12 未满:podium 之后的所有有票
|
|
||||||
// - 连领奖台都没满:所有有票的人都从这里开始
|
|
||||||
const aboveLine = debutReady
|
|
||||||
? ranked.slice(3, 12)
|
|
||||||
: podiumReady
|
|
||||||
? ranked.slice(3)
|
|
||||||
: ranked;
|
|
||||||
|
|
||||||
// 出道线下方
|
|
||||||
// - Top12 满员:ranked[12..] + 全部 0 票
|
|
||||||
// - 否则:仅 0 票
|
|
||||||
const belowLine = debutReady ? [...ranked.slice(12), ...zeros] : zeros;
|
|
||||||
|
|
||||||
const debutCutoff = debutReady ? ranked[11].votes : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@ -87,13 +59,8 @@ export default function RankingPage() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{aboveLine.map((a, idx) => {
|
{top4to12.map((a, idx) => {
|
||||||
const prev =
|
const prev = idx === 0 ? top3[2] : top4to12[idx - 1];
|
||||||
idx === 0
|
|
||||||
? podiumReady
|
|
||||||
? top3[2]
|
|
||||||
: undefined
|
|
||||||
: aboveLine[idx - 1];
|
|
||||||
const gap = prev ? prev.votes - a.votes : undefined;
|
const gap = prev ? prev.votes - a.votes : undefined;
|
||||||
return (
|
return (
|
||||||
<RankingRow
|
<RankingRow
|
||||||
@ -106,16 +73,14 @@ export default function RankingPage() {
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 仅 Top12 满员才显示"出道线"分隔 */}
|
<DebutLineDivider />
|
||||||
{debutReady && <DebutLineDivider />}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
{belowLine.map((a, idx) => {
|
{candidates.map((a, idx) => {
|
||||||
const prev =
|
const prev =
|
||||||
idx === 0 ? aboveLine[aboveLine.length - 1] : belowLine[idx - 1];
|
idx === 0 ? top4to12[top4to12.length - 1] : candidates[idx - 1];
|
||||||
const gap = prev ? prev.votes - a.votes : undefined;
|
const gap = prev ? prev.votes - a.votes : undefined;
|
||||||
// 仅 Top12 满员时第一位才是"复活位"
|
const isRescue = idx === 0;
|
||||||
const isRescue = debutReady && idx === 0;
|
|
||||||
const gapToDebut = isRescue ? debutCutoff - a.votes + 1 : undefined;
|
const gapToDebut = isRescue ? debutCutoff - a.votes + 1 : undefined;
|
||||||
return (
|
return (
|
||||||
<RankingRow
|
<RankingRow
|
||||||
@ -136,7 +101,7 @@ export default function RankingPage() {
|
|||||||
<VoteModal
|
<VoteModal
|
||||||
artist={target}
|
artist={target}
|
||||||
remaining={remaining}
|
remaining={remaining}
|
||||||
totalQuota={totalQuota}
|
dailyQuota={dailyQuota}
|
||||||
onClose={closeVote}
|
onClose={closeVote}
|
||||||
onConfirm={confirmVote}
|
onConfirm={confirmVote}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@ -2,75 +2,38 @@
|
|||||||
|
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import type { ArtistTag } from "@/types/artist";
|
import type { ArtistTag } from "@/types/artist";
|
||||||
import type { SortKey } from "@/lib/mock-data";
|
|
||||||
|
|
||||||
|
export type ViewMode = "grid" | "list";
|
||||||
export type TagFilter = ArtistTag | "all";
|
export type TagFilter = ArtistTag | "all";
|
||||||
|
|
||||||
interface ArtistFiltersProps {
|
interface ArtistFiltersProps {
|
||||||
tagFilter: TagFilter;
|
tagFilter: TagFilter;
|
||||||
onTagChange: (tag: TagFilter) => void;
|
onTagChange: (tag: TagFilter) => void;
|
||||||
sort: SortKey;
|
|
||||||
onSortChange: (sort: SortKey) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const TAG_OPTIONS: { key: TagFilter; label: string }[] = [
|
const TAG_OPTIONS: { key: TagFilter; label: string }[] = [
|
||||||
{ key: "all", label: "全部" },
|
{ key: "all", label: "全部" },
|
||||||
{ key: "rock", label: "摇滚" },
|
{ key: "dance", label: "舞蹈担当" },
|
||||||
{ key: "pop", label: "流行" },
|
{ key: "vocal", label: "声乐担当" },
|
||||||
{ key: "chinese", label: "国风" },
|
{ key: "rap", label: "rap担当" },
|
||||||
{ key: "hiphop", label: "嘻哈说唱" },
|
{ key: "all-rounder", label: "全能型" },
|
||||||
{ key: "folk", label: "民谣治愈" },
|
|
||||||
{ key: "jazz", label: "爵士" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
|
|
||||||
{ key: "votes", label: "实时排名" },
|
|
||||||
{ key: "no", label: "编号顺序" },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function ArtistFilters({
|
export default function ArtistFilters({
|
||||||
tagFilter,
|
tagFilter,
|
||||||
onTagChange,
|
onTagChange,
|
||||||
sort,
|
|
||||||
onSortChange,
|
|
||||||
}: ArtistFiltersProps) {
|
}: ArtistFiltersProps) {
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-3 py-3 border-b border-white/[0.06]">
|
<div className="flex items-center gap-1 py-3 border-b border-white/[0.06] overflow-x-auto">
|
||||||
{/* 左:标签筛选 · 隐藏横向滚动条 thumb,避免在毛玻璃栏里出现深色小矩形 */}
|
{TAG_OPTIONS.map((opt) => (
|
||||||
<div className="flex items-center gap-1 overflow-x-auto flex-1 min-w-0 [scrollbar-width:none] [&::-webkit-scrollbar]:hidden">
|
<TagPill
|
||||||
{TAG_OPTIONS.map((opt) => (
|
key={opt.key}
|
||||||
<TagPill
|
active={tagFilter === opt.key}
|
||||||
key={opt.key}
|
onClick={() => onTagChange(opt.key)}
|
||||||
active={tagFilter === opt.key}
|
>
|
||||||
onClick={() => onTagChange(opt.key)}
|
{opt.label}
|
||||||
>
|
</TagPill>
|
||||||
{opt.label}
|
))}
|
||||||
</TagPill>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 右:排序切换(segmented control 胶囊) */}
|
|
||||||
<div className="flex-shrink-0 inline-flex rounded-full bg-white/[0.04] border border-white/10 p-0.5 text-xs">
|
|
||||||
{SORT_OPTIONS.map((opt) => {
|
|
||||||
const active = sort === opt.key;
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={opt.key}
|
|
||||||
type="button"
|
|
||||||
onClick={() => onSortChange(opt.key)}
|
|
||||||
aria-pressed={active}
|
|
||||||
className={cn(
|
|
||||||
"px-3 sm:px-3.5 h-7 rounded-full transition-colors whitespace-nowrap",
|
|
||||||
active
|
|
||||||
? "bg-purple-500/25 text-purple-100 shadow-[0_0_10px_rgba(139,92,246,0.35)]"
|
|
||||||
: "text-white/55 hover:text-white/85",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{opt.label}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,80 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useRouter } from "next/navigation";
|
|
||||||
import { ChevronLeft } from "lucide-react";
|
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
import { useFooterPush } from "@/hooks/useFooterPush";
|
|
||||||
|
|
||||||
interface FloatingBackButtonProps {
|
|
||||||
/** 显示前的滚动阈值(px),默认 300 与投票按钮一致 */
|
|
||||||
threshold?: number;
|
|
||||||
/** 备用 href:历史栈为空时(直接进入详情页)回退到该地址 */
|
|
||||||
fallbackHref?: string;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 左下角浮动返回按钮。
|
|
||||||
* - 用户滚到第一屏以下才显示,避免与顶部面包屑视觉冗余
|
|
||||||
* - 优先用 router.back() 回到浏览器历史的上一页(会触发上页 scroll restore)
|
|
||||||
* - 检测 sessionStorage 标记:如果是直接打开/刷新本页,history.back() 不安全,改用 fallbackHref
|
|
||||||
* - 玻璃化样式,与导航栏吸顶后的玻璃配方对齐
|
|
||||||
*/
|
|
||||||
export default function FloatingBackButton({
|
|
||||||
threshold = 300,
|
|
||||||
fallbackHref = "/",
|
|
||||||
className,
|
|
||||||
}: FloatingBackButtonProps) {
|
|
||||||
const router = useRouter();
|
|
||||||
const [visible, setVisible] = useState(false);
|
|
||||||
const [hasInternalHistory, setHasInternalHistory] = useState(false);
|
|
||||||
// footer 进视口时按钮向上平移,始终漂浮在 footer 顶部上方
|
|
||||||
const footerPush = useFooterPush();
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Navigation 组件每次路由变化会把 nav:count +1。
|
|
||||||
// count > 1 → 当前会话内已经经过至少一次跳转,router.back() 安全。
|
|
||||||
// count <= 1 → 直接打开/刷新本页,history 栈外是站外,改用 fallbackHref。
|
|
||||||
const count = parseInt(sessionStorage.getItem("nav:count") || "0", 10);
|
|
||||||
setHasInternalHistory(count > 1);
|
|
||||||
|
|
||||||
const handler = () => setVisible(window.scrollY > threshold);
|
|
||||||
handler();
|
|
||||||
window.addEventListener("scroll", handler, { passive: true });
|
|
||||||
return () => window.removeEventListener("scroll", handler);
|
|
||||||
}, [threshold]);
|
|
||||||
|
|
||||||
const goBack = () => {
|
|
||||||
if (hasInternalHistory) {
|
|
||||||
router.back();
|
|
||||||
} else {
|
|
||||||
router.push(fallbackHref);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goBack}
|
|
||||||
aria-label="返回上一页"
|
|
||||||
style={{
|
|
||||||
// 隐藏态:下移 12px;显示态:基础位置 - footerPush(避让 footer)
|
|
||||||
transform: visible
|
|
||||||
? `translateY(-${footerPush}px)`
|
|
||||||
: "translateY(12px)",
|
|
||||||
}}
|
|
||||||
className={cn(
|
|
||||||
"fixed bottom-6 left-6 sm:bottom-8 sm:left-8 z-40 w-14 h-14 rounded-full",
|
|
||||||
"bg-surface/55 backdrop-blur-xl backdrop-saturate-150 border border-white/[0.08]",
|
|
||||||
"shadow-[0_8px_28px_rgba(0,0,0,0.55)] text-white/85 hover:text-white hover:bg-surface/75",
|
|
||||||
"flex items-center justify-center transition-all duration-300",
|
|
||||||
visible ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none",
|
|
||||||
"hover:scale-105 active:scale-95",
|
|
||||||
className,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<ChevronLeft size={22} strokeWidth={2.2} />
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,27 +1,22 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Heart, Check } from "lucide-react";
|
import { Heart } from "lucide-react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { useFooterPush } from "@/hooks/useFooterPush";
|
|
||||||
|
|
||||||
interface FloatingVoteButtonProps {
|
interface FloatingVoteButtonProps {
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
/** 显示前的滚动阈值(px) */
|
/** 显示前的滚动阈值(px) */
|
||||||
threshold?: number;
|
threshold?: number;
|
||||||
className?: string;
|
className?: string;
|
||||||
hasVoted?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function FloatingVoteButton({
|
export default function FloatingVoteButton({
|
||||||
onClick,
|
onClick,
|
||||||
threshold = 300,
|
threshold = 300,
|
||||||
className,
|
className,
|
||||||
hasVoted = false,
|
|
||||||
}: FloatingVoteButtonProps) {
|
}: FloatingVoteButtonProps) {
|
||||||
const [visible, setVisible] = useState(false);
|
const [visible, setVisible] = useState(false);
|
||||||
// footer 进视口时按钮向上平移,始终漂浮在 footer 顶部上方
|
|
||||||
const footerPush = useFooterPush();
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handler = () => setVisible(window.scrollY > threshold);
|
const handler = () => setVisible(window.scrollY > threshold);
|
||||||
@ -34,34 +29,18 @@ export default function FloatingVoteButton({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
aria-label={hasVoted ? "已投票" : "立即投票"}
|
aria-label="立即投票"
|
||||||
style={{
|
|
||||||
// 隐藏态:下移 12px;显示态:基础位置 - footerPush(避让 footer)
|
|
||||||
transform: visible
|
|
||||||
? `translateY(-${footerPush}px)`
|
|
||||||
: "translateY(12px)",
|
|
||||||
}}
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"fixed bottom-6 right-6 sm:bottom-8 sm:right-8 z-40 w-14 h-14 rounded-full flex flex-col items-center justify-center font-display text-[9px] tracking-widest transition-all duration-300",
|
"fixed bottom-6 right-6 sm:bottom-8 sm:right-8 z-40 w-14 h-14 rounded-full bg-grad-purple text-white flex flex-col items-center justify-center font-display text-[9px] tracking-widest shadow-purple-glow animate-pulse-glow transition-all",
|
||||||
hasVoted
|
visible
|
||||||
? "bg-white/12 border border-white/15 text-white/65"
|
? "opacity-100 translate-y-0 pointer-events-auto"
|
||||||
: "bg-grad-purple text-white shadow-purple-glow animate-pulse-glow",
|
: "opacity-0 translate-y-3 pointer-events-none",
|
||||||
visible ? "opacity-100 pointer-events-auto" : "opacity-0 pointer-events-none",
|
|
||||||
"hover:scale-105",
|
"hover:scale-105",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hasVoted ? (
|
<Heart size={16} fill="white" className="mb-0.5" />
|
||||||
<>
|
<span>投票</span>
|
||||||
<Check size={16} strokeWidth={3} className="mb-0.5" />
|
|
||||||
<span>已投</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Heart size={16} fill="white" className="mb-0.5" />
|
|
||||||
<span>投票</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
</button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,13 @@
|
|||||||
|
import Logo from "./Logo";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
const year = new Date().getFullYear();
|
const year = new Date().getFullYear();
|
||||||
return (
|
return (
|
||||||
<footer className="border-t border-white/[0.06] bg-deep mt-16">
|
<footer className="border-t border-white/[0.06] bg-deep mt-16">
|
||||||
<div className="max-w-[1500px] mx-auto px-6 sm:px-8 h-16 flex items-center justify-center text-center">
|
<div className="max-w-[1500px] mx-auto px-6 sm:px-8 h-16 flex flex-col sm:flex-row items-center justify-center gap-3 sm:gap-6 text-center">
|
||||||
|
<Logo size="sm" href={null} />
|
||||||
<p className="text-[11px] text-white/35 tracking-[0.05em]">
|
<p className="text-[11px] text-white/35 tracking-[0.05em]">
|
||||||
© {year} 银河初星计划 C . S . G · All Rights Reserved
|
© {year} CYBER STAR · All Rights Reserved
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</footer>
|
</footer>
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { Volume2, VolumeX } from "lucide-react";
|
import { Volume2, VolumeX } from "lucide-react";
|
||||||
import HeroVoteProgress from "./HeroVoteProgress";
|
import Countdown from "./ui/Countdown";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
interface HeroBannerProps {
|
interface HeroBannerProps {
|
||||||
@ -10,6 +10,8 @@ interface HeroBannerProps {
|
|||||||
videoSrc?: string;
|
videoSrc?: string;
|
||||||
/** 视频封面图 */
|
/** 视频封面图 */
|
||||||
poster?: string;
|
poster?: string;
|
||||||
|
/** 活动结束时间 */
|
||||||
|
endTime: Date | string | number;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -17,12 +19,12 @@ interface HeroBannerProps {
|
|||||||
* 全屏沉浸式 Hero:
|
* 全屏沉浸式 Hero:
|
||||||
* - 容器宽度铺满视口(视频背景),但内部文案在 1500 版心内
|
* - 容器宽度铺满视口(视频背景),但内部文案在 1500 版心内
|
||||||
* - 高度 = 100svh - 80px 导航
|
* - 高度 = 100svh - 80px 导航
|
||||||
* - 右上角的"应援进度"组件替代原倒计时,体现 12 票终身额度玩法
|
|
||||||
* - 声音按钮在右下角,即使视频未加载也能切换图标状态(视觉即时反馈)
|
* - 声音按钮在右下角,即使视频未加载也能切换图标状态(视觉即时反馈)
|
||||||
*/
|
*/
|
||||||
export default function HeroBanner({
|
export default function HeroBanner({
|
||||||
videoSrc,
|
videoSrc,
|
||||||
poster,
|
poster,
|
||||||
|
endTime,
|
||||||
className,
|
className,
|
||||||
}: HeroBannerProps) {
|
}: HeroBannerProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
@ -33,7 +35,7 @@ export default function HeroBanner({
|
|||||||
if (!v || !videoSrc) return;
|
if (!v || !videoSrc) return;
|
||||||
v.muted = isMuted;
|
v.muted = isMuted;
|
||||||
v.play().catch(() => {});
|
v.play().catch(() => {});
|
||||||
// 仅在 videoSrc 变化时执行 · 不依赖 isMuted(mute 切换由按钮处理)
|
// 仅在 videoSrc 变化时执行 · 不依赖 isMuted(mute 切换由按钮处理)
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [videoSrc]);
|
}, [videoSrc]);
|
||||||
|
|
||||||
@ -48,14 +50,13 @@ export default function HeroBanner({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<section
|
<section
|
||||||
data-hero-banner
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full overflow-hidden bg-deepest",
|
"relative w-full overflow-hidden bg-deepest",
|
||||||
"min-h-[560px]",
|
"min-h-[560px]",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
height: "100svh",
|
height: "calc(100svh - 80px)",
|
||||||
minHeight: "560px",
|
minHeight: "560px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@ -82,22 +83,36 @@ export default function HeroBanner({
|
|||||||
className="absolute inset-0 pointer-events-none"
|
className="absolute inset-0 pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"linear-gradient(180deg, rgba(8,5,26,0.45) 0%, rgba(8,5,26,0.12) 40%, rgba(8,5,26,0.08) 100%)",
|
"linear-gradient(180deg, rgba(8,5,26,0.45) 0%, rgba(8,5,26,0.12) 40%, rgba(8,5,26,0.85) 100%)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 版心容器:1500px max-width,所有文案 / 倒计时 / 声音按钮全部在内 */}
|
{/* 版心容器:1500px max-width,所有文案 / 倒计时 / 声音按钮全部在内 */}
|
||||||
<div className="absolute inset-0 max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
|
<div className="absolute inset-0 max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
{/* Eyebrow 左上 · 紧贴导航下方 */}
|
{/* Eyebrow 左上 */}
|
||||||
<div className="absolute top-[6.5rem] sm:top-[7.5rem] left-4 sm:left-6 lg:left-8 z-10">
|
<div className="absolute top-6 sm:top-10 left-4 sm:left-6 lg:left-8 z-10">
|
||||||
<p className="font-label text-[10px] sm:text-xs tracking-[0.28em] uppercase text-purple-200/90">
|
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-200/90">
|
||||||
银河初星计划 C . S . G
|
Top 12 · Virtual Idol Debut Project
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 应援进度 右上 · 紧贴导航下方 · 与左侧 Eyebrow 同高对齐 */}
|
{/* 浅紫边框倒计时 右上 */}
|
||||||
<div className="absolute top-[6.25rem] sm:top-[7rem] right-4 sm:right-6 lg:right-8 z-10">
|
<div className="absolute top-5 sm:top-8 right-4 sm:right-6 lg:right-8 z-10">
|
||||||
<HeroVoteProgress />
|
<Countdown endTime={endTime} compact />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中央 CYBER ✦ STAR */}
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center z-10 px-6 text-center">
|
||||||
|
<h1 className="font-logo text-6xl sm:text-8xl lg:text-9xl tracking-[0.2em] glow-text-purple inline-flex items-baseline text-white">
|
||||||
|
CYBER
|
||||||
|
<span className="text-purple-300 mx-2 sm:mx-4 text-4xl sm:text-6xl lg:text-7xl">
|
||||||
|
✦
|
||||||
|
</span>
|
||||||
|
STAR
|
||||||
|
</h1>
|
||||||
|
<p className="mt-6 text-white/80 text-base sm:text-lg tracking-[0.4em]">
|
||||||
|
虚 拟 偶 像 出 道 企 划
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 声音按钮 右下 */}
|
{/* 声音按钮 右下 */}
|
||||||
@ -115,7 +130,7 @@ export default function HeroBanner({
|
|||||||
{/* 底部渐隐 */}
|
{/* 底部渐隐 */}
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute inset-x-0 bottom-0 h-16 pointer-events-none"
|
className="absolute inset-x-0 bottom-0 h-24 pointer-events-none"
|
||||||
style={{
|
style={{
|
||||||
background:
|
background:
|
||||||
"linear-gradient(180deg, transparent 0%, var(--color-deepest) 100%)",
|
"linear-gradient(180deg, transparent 0%, var(--color-deepest) 100%)",
|
||||||
|
|||||||
@ -1,115 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useSession } from "next-auth/react";
|
|
||||||
import { Check, LogIn } from "lucide-react";
|
|
||||||
import { useVoteStore, TOTAL_VOTE_QUOTA } from "@/lib/store";
|
|
||||||
import { useLoginModalStore } from "@/lib/login-modal-store";
|
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hero 区域右上角的"应援进度"小组件,替代原 Countdown。
|
|
||||||
* 视觉对齐 Countdown compact 模式:同高度 h-9、同位置、同毛玻璃质感(深底 + backdrop-blur + 浅紫边框)。
|
|
||||||
*
|
|
||||||
* 三态:
|
|
||||||
* 1. 未登录 → "登录后开始投票" 可点击 CTA · 12 格全暗
|
|
||||||
* 2. 已登录,未投满 → "应援进度 X/12" · 已投点亮、未投暗紫描边
|
|
||||||
* 3. 投满 12 票 → "✓ 12 票全部投出" · 12 格全亮 · 紫色强辉光
|
|
||||||
*/
|
|
||||||
export default function HeroVoteProgress({ className }: { className?: string }) {
|
|
||||||
const { status } = useSession();
|
|
||||||
const votedCount = useVoteStore((s) => s.votedArtists.length);
|
|
||||||
const openLogin = useLoginModalStore((s) => s.show);
|
|
||||||
|
|
||||||
const authed = status === "authenticated";
|
|
||||||
const filled = authed ? votedCount : 0;
|
|
||||||
const exhausted = authed && votedCount >= TOTAL_VOTE_QUOTA;
|
|
||||||
|
|
||||||
// 未登录 → 可点击 CTA;其它态 → 普通信息容器
|
|
||||||
const isClickable = !authed;
|
|
||||||
|
|
||||||
const containerCls = cn(
|
|
||||||
"inline-flex items-center gap-2.5 h-9 px-4 rounded-full",
|
|
||||||
"bg-[rgba(13,10,36,0.55)] backdrop-blur-md",
|
|
||||||
"border transition-colors",
|
|
||||||
exhausted
|
|
||||||
? "border-purple-300/70 shadow-[0_0_18px_rgba(139,92,246,0.45)]"
|
|
||||||
: "border-purple-300/40",
|
|
||||||
isClickable && "cursor-pointer hover:bg-[rgba(13,10,36,0.7)] hover:border-purple-300/60",
|
|
||||||
className,
|
|
||||||
);
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
{/* 左侧文字状态 */}
|
|
||||||
{!authed ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-white/85 text-xs leading-none">
|
|
||||||
<LogIn size={11} className="text-purple-300" />
|
|
||||||
登录后开始投票
|
|
||||||
</span>
|
|
||||||
) : exhausted ? (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-purple-200 text-xs leading-none font-medium">
|
|
||||||
<Check size={12} strokeWidth={3} />
|
|
||||||
12 票全部投出
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center gap-1.5 text-white/85 text-xs leading-none">
|
|
||||||
应援进度
|
|
||||||
<span className="font-display text-purple-300 tabular-nums tracking-wider">
|
|
||||||
{filled}/{TOTAL_VOTE_QUOTA}
|
|
||||||
</span>
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 12 格点亮式进度条 —— 窄屏隐藏,避免与左侧 Eyebrow 横向挤撞;>= md 才显示 */}
|
|
||||||
<span
|
|
||||||
aria-hidden
|
|
||||||
className="hidden md:inline-flex items-center gap-[3px] ml-0.5"
|
|
||||||
>
|
|
||||||
{Array.from({ length: TOTAL_VOTE_QUOTA }).map((_, i) => {
|
|
||||||
const lit = i < filled;
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
key={i}
|
|
||||||
className={cn(
|
|
||||||
"w-1.5 h-1.5 rounded-full transition-all duration-300",
|
|
||||||
lit
|
|
||||||
? exhausted
|
|
||||||
? "bg-purple-200 shadow-[0_0_6px_rgba(196,181,253,0.95)]"
|
|
||||||
: "bg-purple-400 shadow-[0_0_4px_rgba(167,139,250,0.85)]"
|
|
||||||
: "bg-purple-500/15 border border-purple-300/25",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (isClickable) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => openLogin()}
|
|
||||||
aria-label="登录后开始投票"
|
|
||||||
data-hero-vote-progress
|
|
||||||
className={containerCls}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={containerCls}
|
|
||||||
data-hero-vote-progress
|
|
||||||
aria-label={
|
|
||||||
exhausted
|
|
||||||
? "12 票已全部投出"
|
|
||||||
: `应援进度 ${filled} 票 / 共 ${TOTAL_VOTE_QUOTA} 票`
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{content}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -8,7 +8,7 @@ interface LogoProps {
|
|||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 高度由 size 控制,宽度按 logo-v4.png 实际比例自适应
|
// 高度由 size 控制,宽度按 logo.png 实际比例(约 5.41:1,单行 CYBER STAR + 星环)自适应
|
||||||
const HEIGHT_PX: Record<LogoSize, number> = {
|
const HEIGHT_PX: Record<LogoSize, number> = {
|
||||||
sm: 24,
|
sm: 24,
|
||||||
md: 44,
|
md: 44,
|
||||||
@ -25,17 +25,18 @@ export default function Logo({
|
|||||||
|
|
||||||
// 用原生 <img> 绕开 Next/Image 的格式转换 —— 某些环境下 sharp 把透明 PNG
|
// 用原生 <img> 绕开 Next/Image 的格式转换 —— 某些环境下 sharp 把透明 PNG
|
||||||
// 转 webp/avif 时会铺白底,导致 logo 在深色 nav 上出现白色矩形。
|
// 转 webp/avif 时会铺白底,导致 logo 在深色 nav 上出现白色矩形。
|
||||||
// ?v=4 缓存破坏:logo 改版时 +1,浏览器立刻拉新版而不读老缓存。
|
|
||||||
const inner = (
|
const inner = (
|
||||||
<img
|
<img
|
||||||
src="/logo-v4.png?v=4"
|
src="/logo.png"
|
||||||
alt="银河初星计划 C . S . G"
|
alt="CYBER STAR"
|
||||||
decoding="async"
|
decoding="async"
|
||||||
draggable={false}
|
draggable={false}
|
||||||
style={{
|
style={{
|
||||||
height: `${h}px`,
|
height: `${h}px`,
|
||||||
width: "auto",
|
width: "auto",
|
||||||
background: "transparent",
|
background: "transparent",
|
||||||
|
// 保留紫色辉光,但 drop-shadow 不会引入白底
|
||||||
|
filter: "drop-shadow(0 0 14px rgba(139,92,246,0.4))",
|
||||||
}}
|
}}
|
||||||
className={`block select-none ${className}`}
|
className={`block select-none ${className}`}
|
||||||
/>
|
/>
|
||||||
@ -46,7 +47,7 @@ export default function Logo({
|
|||||||
<Link
|
<Link
|
||||||
href={href}
|
href={href}
|
||||||
className="inline-flex items-center hover:opacity-90 transition-opacity"
|
className="inline-flex items-center hover:opacity-90 transition-opacity"
|
||||||
aria-label="银河初星计划 C . S . G · 首页"
|
aria-label="CYBER STAR · 首页"
|
||||||
style={{ background: "transparent" }}
|
style={{ background: "transparent" }}
|
||||||
>
|
>
|
||||||
{inner}
|
{inner}
|
||||||
|
|||||||
@ -19,9 +19,10 @@ const NAV_ITEMS: Array<{
|
|||||||
|
|
||||||
interface NavLinksProps {
|
interface NavLinksProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
mobile?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function NavLinks({ className }: NavLinksProps) {
|
export default function NavLinks({ className, mobile = false }: NavLinksProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const openLogin = useLoginModalStore((s) => s.show);
|
const openLogin = useLoginModalStore((s) => s.show);
|
||||||
@ -42,11 +43,39 @@ export default function NavLinks({ className }: NavLinksProps) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if (mobile) {
|
||||||
|
return (
|
||||||
|
<ul
|
||||||
|
className={cn(
|
||||||
|
"flex items-center gap-6 px-6 py-2.5 text-[13px] tracking-[0.1em] whitespace-nowrap",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{NAV_ITEMS.map((item) => {
|
||||||
|
const active = isActive(item.href);
|
||||||
|
return (
|
||||||
|
<li key={item.href}>
|
||||||
|
<Link
|
||||||
|
href={item.href}
|
||||||
|
onClick={(e) => handleClick(e, item)}
|
||||||
|
className={cn(
|
||||||
|
"transition-colors",
|
||||||
|
active ? "text-purple-300" : "text-white/55",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ul
|
<ul
|
||||||
className={cn(
|
className={cn(
|
||||||
// 单一布局:窄屏 gap-5 text-[13px],sm 以上 gap-8 text-sm,装饰一致
|
"items-center gap-8 text-sm tracking-[0.1em]",
|
||||||
"flex items-center gap-5 sm:gap-8 text-[13px] sm:text-sm tracking-[0.1em] whitespace-nowrap",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
|||||||
@ -1,107 +1,31 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import Logo from "./Logo";
|
import Logo from "./Logo";
|
||||||
import NavLinks from "./NavLinks";
|
import NavLinks from "./NavLinks";
|
||||||
import SearchTrigger from "./SearchTrigger";
|
import SearchTrigger from "./SearchTrigger";
|
||||||
import AuthMenu from "./auth/AuthMenu";
|
import AuthMenu from "./auth/AuthMenu";
|
||||||
import RemainingVotesBadge from "./auth/RemainingVotesBadge";
|
import RemainingVotesBadge from "./auth/RemainingVotesBadge";
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
import { useUIStore } from "@/lib/ui-store";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 导航栏 · 上下文敏感的玻璃态切换
|
|
||||||
* - 顶部/盖在 Hero 之上时:完全透明,只有内容(无玻璃层、无分割线)
|
|
||||||
* - 内容滚到 nav 下方时:渐显为毛玻璃(blur + saturate + surface 微着色),与下方筛选条无缝连接
|
|
||||||
* - 玻璃层用独立 absolute 子层 + opacity 过渡,避免直接切 backdrop-filter class 的硬切
|
|
||||||
*
|
|
||||||
* 检测策略:
|
|
||||||
* - 路由存在 [data-hero-banner] → IntersectionObserver 观察 Hero(首页)
|
|
||||||
* - 否则 → 滚动监听,scrollY <= 0 时透明,任意向下滚动即玻璃
|
|
||||||
*/
|
|
||||||
export default function Navigation() {
|
export default function Navigation() {
|
||||||
const pathname = usePathname();
|
|
||||||
// 初始乐观:任何页面首屏都假设在顶部/Hero 上,透明。effect 挂载后会立刻校正。
|
|
||||||
const [isTransparent, setIsTransparent] = useState(true);
|
|
||||||
// 筛选条吸顶时,nav 也变透明 —— 此时筛选条的"共享玻璃带"已延伸到 nav 顶部,
|
|
||||||
// nav 关掉自己的玻璃,避免双重 backdrop-filter 在 y=80 处出现拼接线。
|
|
||||||
const filterStuck = useUIStore((s) => s.filterStuck);
|
|
||||||
const glassOff = isTransparent || filterStuck;
|
|
||||||
const showLogo = pathname !== "/";
|
|
||||||
|
|
||||||
// 维护一个站内导航计数器(per-tab),供 FloatingBackButton 判断 router.back() 是否安全
|
|
||||||
useEffect(() => {
|
|
||||||
const prev = parseInt(sessionStorage.getItem("nav:count") || "0", 10);
|
|
||||||
sessionStorage.setItem("nav:count", String(prev + 1));
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const heroEl = document.querySelector("[data-hero-banner]");
|
|
||||||
|
|
||||||
// 1) 有 Hero 的页面:观察 Hero 是否还在视口顶部下方
|
|
||||||
if (heroEl) {
|
|
||||||
setIsTransparent(true);
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => setIsTransparent(entry.isIntersecting),
|
|
||||||
{
|
|
||||||
// 视口顶部下移 80px(导航高度),让 Hero 完全滚出 nav 下方时触发
|
|
||||||
rootMargin: "-80px 0px 0px 0px",
|
|
||||||
threshold: 0,
|
|
||||||
},
|
|
||||||
);
|
|
||||||
observer.observe(heroEl);
|
|
||||||
return () => observer.disconnect();
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2) 无 Hero 的页面:scrollY === 0 时透明,任何向下滚动即玻璃
|
|
||||||
const onScroll = () => setIsTransparent(window.scrollY <= 0);
|
|
||||||
onScroll();
|
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
return () => window.removeEventListener("scroll", onScroll);
|
|
||||||
}, [pathname]);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="fixed top-0 inset-x-0 z-50">
|
<header className="sticky top-0 z-50 backdrop-blur-xl bg-[rgba(13,10,36,0.85)] border-b border-white/[0.08]">
|
||||||
{/* 玻璃层 · 通过 opacity 渐显渐隐 · bg-surface 与全站卡片同色
|
<nav className="max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-8">
|
||||||
glassOff = isTransparent || filterStuck:
|
<Logo size="md" />
|
||||||
- isTransparent: Hero / 页顶,本来就要透明
|
|
||||||
- filterStuck: 筛选条已伸出共享玻璃,nav 关掉自己的玻璃避免接缝 */}
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-0 bg-surface/40 backdrop-blur-xl backdrop-saturate-150 transition-opacity duration-300",
|
|
||||||
glassOff ? "opacity-0" : "opacity-100",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
{/* 顶部 1px 高光 · 仅玻璃态下显示 */}
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-x-0 top-0 h-px bg-white/[0.06] transition-opacity duration-300",
|
|
||||||
glassOff ? "opacity-0" : "opacity-100",
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<nav className="relative max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-4 sm:gap-8">
|
|
||||||
{showLogo && (
|
|
||||||
<div className="hidden sm:flex shrink-0 items-center">
|
|
||||||
<Logo
|
|
||||||
size="md"
|
|
||||||
className="drop-shadow-[0_0_14px_rgba(139,92,246,0.55)]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 左侧:首页 / 排行榜 / 我的 */}
|
{/* 中部:首页 / 排行榜 / 我的 */}
|
||||||
<NavLinks />
|
<NavLinks className="hidden md:flex" />
|
||||||
|
|
||||||
{/* 右侧:搜索 + 今日余票 + 登录/注册 (或 头像+下拉) */}
|
{/* 右侧:搜索 + 今日余票 + 登录/注册 (或 头像+下拉) */}
|
||||||
<div className="ml-auto flex items-center gap-2 sm:gap-3">
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<SearchTrigger />
|
<SearchTrigger />
|
||||||
<RemainingVotesBadge />
|
<RemainingVotesBadge />
|
||||||
<AuthMenu />
|
<AuthMenu />
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
{/* 移动端:单独一行 nav links */}
|
||||||
|
<NavLinks
|
||||||
|
className="md:hidden border-t border-white/[0.05] overflow-x-auto"
|
||||||
|
mobile
|
||||||
|
/>
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,27 +3,15 @@
|
|||||||
import { SessionProvider } from "next-auth/react";
|
import { SessionProvider } from "next-auth/react";
|
||||||
import { Toaster } from "react-hot-toast";
|
import { Toaster } from "react-hot-toast";
|
||||||
import GlobalLoginModal from "@/components/auth/GlobalLoginModal";
|
import GlobalLoginModal from "@/components/auth/GlobalLoginModal";
|
||||||
import { useSyncMe } from "@/hooks/useSyncMe";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 登录后把服务端 /api/me 同步到本地 vote store 的隐形组件。
|
|
||||||
* 必须放在 SessionProvider 内部才能拿到 useSession。
|
|
||||||
*/
|
|
||||||
function SyncMeBridge() {
|
|
||||||
useSyncMe();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 客户端全局 Provider 集合
|
* 客户端全局 Provider 集合
|
||||||
* - SessionProvider: 让 client 组件能用 useSession()
|
* - SessionProvider: 让 client 组件能用 useSession()
|
||||||
* - SyncMeBridge: 登录后用 /api/me 覆盖本地票数态(跨设备同步关键)
|
|
||||||
* - Toaster: 全站 toast 容器(紫调样式,自动叠加)
|
* - Toaster: 全站 toast 容器(紫调样式,自动叠加)
|
||||||
*/
|
*/
|
||||||
export default function Providers({ children }: { children: React.ReactNode }) {
|
export default function Providers({ children }: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<SessionProvider>
|
<SessionProvider>
|
||||||
<SyncMeBridge />
|
|
||||||
{children}
|
{children}
|
||||||
<GlobalLoginModal />
|
<GlobalLoginModal />
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|||||||
@ -6,13 +6,8 @@ import { AnimatePresence, motion } from "framer-motion";
|
|||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { useVoteStore } from "@/lib/store";
|
import { ARTISTS } from "@/lib/mock-data";
|
||||||
import { TAG_LABEL, type Artist } from "@/types/artist";
|
import { TAG_LABEL, type Artist } from "@/types/artist";
|
||||||
|
|
||||||
function formatVotes(v: number): string {
|
|
||||||
if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`;
|
|
||||||
return v.toLocaleString();
|
|
||||||
}
|
|
||||||
import ArtistPortrait from "./cards/ArtistPortrait";
|
import ArtistPortrait from "./cards/ArtistPortrait";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
@ -45,30 +40,21 @@ export default function SearchModal({ open, onClose }: SearchModalProps) {
|
|||||||
};
|
};
|
||||||
}, [open]);
|
}, [open]);
|
||||||
|
|
||||||
// 订阅 store 拿活的票数 / rank(投票后立即反映)
|
// 过滤艺人:名字 / 编号 / slogan / 标签
|
||||||
const storeArtists = useVoteStore((s) => s.artists);
|
|
||||||
|
|
||||||
// 过滤艺人:名字 / 编号 / 座右铭 / 标签。默认显示真实 Top12(有票才上榜)
|
|
||||||
const results = useMemo<Artist[]>(() => {
|
const results = useMemo<Artist[]>(() => {
|
||||||
const q = query.trim().toLowerCase();
|
const q = query.trim().toLowerCase();
|
||||||
if (!q) {
|
if (!q) return ARTISTS.slice(0, 12); // 默认显示前 12 名
|
||||||
const top12 = storeArtists.filter((a) => a.votes > 0).slice(0, 12);
|
return ARTISTS.filter((a) => {
|
||||||
// 还没产生 Top12 时退回到默认 12 位(按编号),保持空态可浏览
|
const tagText = a.tags.map((t) => TAG_LABEL[t]).join("");
|
||||||
return top12.length > 0 ? top12 : storeArtists.slice(0, 12);
|
return (
|
||||||
}
|
a.name.toLowerCase().includes(q) ||
|
||||||
return storeArtists
|
a.enName.toLowerCase().includes(q) ||
|
||||||
.filter((a) => {
|
a.no.includes(q) ||
|
||||||
const tagText = a.tags.map((t) => TAG_LABEL[t]).join("");
|
a.slogan.toLowerCase().includes(q) ||
|
||||||
return (
|
tagText.includes(q)
|
||||||
a.name.toLowerCase().includes(q) ||
|
);
|
||||||
a.enName.toLowerCase().includes(q) ||
|
}).slice(0, 20);
|
||||||
a.no.includes(q) ||
|
}, [query]);
|
||||||
(a.motto?.toLowerCase().includes(q) ?? false) ||
|
|
||||||
tagText.includes(q)
|
|
||||||
);
|
|
||||||
})
|
|
||||||
.slice(0, 20);
|
|
||||||
}, [query, storeArtists]);
|
|
||||||
|
|
||||||
// 键盘导航
|
// 键盘导航
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -176,9 +162,7 @@ export default function SearchModal({ open, onClose }: SearchModalProps) {
|
|||||||
{!query && (
|
{!query && (
|
||||||
<div className="px-5 pt-1 pb-2">
|
<div className="px-5 pt-1 pb-2">
|
||||||
<p className="font-label text-[10px] tracking-widest uppercase text-purple-300/70">
|
<p className="font-label text-[10px] tracking-widest uppercase text-purple-300/70">
|
||||||
{results.some((a) => a.votes > 0)
|
热门艺人 · TOP 12
|
||||||
? "热门艺人 · TOP 12"
|
|
||||||
: "推荐艺人 · 编号顺序"}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
@ -221,7 +205,7 @@ function ResultRow({
|
|||||||
onHover: () => void;
|
onHover: () => void;
|
||||||
onClick: () => void;
|
onClick: () => void;
|
||||||
}) {
|
}) {
|
||||||
const inTop12 = artist.rank <= 12 && artist.votes > 0;
|
const inTop12 = artist.rank <= 12;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
data-index={index}
|
data-index={index}
|
||||||
@ -253,11 +237,9 @@ function ResultRow({
|
|||||||
· {artist.enName}
|
· {artist.enName}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{artist.motto && (
|
<div className="text-[11px] text-white/45 truncate">
|
||||||
<div className="text-[11px] text-white/45 truncate italic">
|
{artist.slogan}
|
||||||
“{artist.motto}”
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-shrink-0 text-right">
|
<div className="flex-shrink-0 text-right">
|
||||||
@ -270,7 +252,7 @@ function ResultRow({
|
|||||||
#{artist.rank}
|
#{artist.rank}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-white/35 tabular-nums">
|
<div className="text-[10px] text-white/35 tabular-nums">
|
||||||
{formatVotes(artist.votes)} 票
|
{(artist.votes / 10000).toFixed(1)}w 票
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@ -18,8 +18,7 @@ function formatVotes(v: number): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Top12Bar({ artists, showHeader = true }: Top12BarProps) {
|
export default function Top12Bar({ artists, showHeader = true }: Top12BarProps) {
|
||||||
// Top12 出道位 只看「真正有票」的人 —— 0 票时不靠编号兜底占位
|
const top12 = artists.slice(0, 12);
|
||||||
const top12 = artists.filter((a) => a.votes > 0).slice(0, 12);
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
{showHeader && (
|
{showHeader && (
|
||||||
@ -38,31 +37,16 @@ export default function Top12Bar({ artists, showHeader = true }: Top12BarProps)
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{top12.length === 0 ? (
|
{/* 12 张胶囊卡片 · grid 等分铺满,无滚动 · 无外边框无背景 */}
|
||||||
<Top12Empty />
|
<div
|
||||||
) : (
|
className={cn(
|
||||||
// 12 张胶囊卡片 · grid 等分铺满,无滚动 · 无外边框无背景
|
"grid grid-cols-6 sm:grid-cols-12 gap-2 sm:gap-3 py-2",
|
||||||
<div
|
)}
|
||||||
className={cn(
|
>
|
||||||
"grid grid-cols-6 sm:grid-cols-12 gap-2 sm:gap-3 py-2",
|
{top12.map((artist) => (
|
||||||
)}
|
<Top12Card key={artist.id} artist={artist} />
|
||||||
>
|
))}
|
||||||
{top12.map((artist) => (
|
</div>
|
||||||
<Top12Card key={artist.id} artist={artist} />
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Top12Empty() {
|
|
||||||
return (
|
|
||||||
<div className="py-10 sm:py-12 px-6 text-center border border-dashed border-white/10 rounded-2xl bg-white/[0.02]">
|
|
||||||
<p className="font-label text-[11px] tracking-widest uppercase text-purple-300 mb-2">
|
|
||||||
Awaiting Votes
|
|
||||||
</p>
|
|
||||||
<p className="text-sm text-white/70">出道位尚未产生 · 等你为 ta 投下第一票</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,47 +3,60 @@
|
|||||||
import { useEffect, useState, useCallback } from "react";
|
import { useEffect, useState, useCallback } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import { X, Heart, AlertCircle, Check } from "lucide-react";
|
import { X, Heart } from "lucide-react";
|
||||||
import type { Artist } from "@/types/artist";
|
import type { Artist } from "@/types/artist";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { useVoteStore, selectHasVoted } from "@/lib/store";
|
|
||||||
import Button from "./ui/Button";
|
import Button from "./ui/Button";
|
||||||
import ArtistPortrait from "./cards/ArtistPortrait";
|
import ArtistPortrait from "./cards/ArtistPortrait";
|
||||||
|
|
||||||
|
type VoteOption = number | "ALL";
|
||||||
|
|
||||||
interface VoteModalProps {
|
interface VoteModalProps {
|
||||||
/** 当前要投票的艺人,传 null 关闭弹窗 */
|
/** 当前要投票的艺人,传 null 关闭弹窗 */
|
||||||
artist: Artist | null;
|
artist: Artist | null;
|
||||||
/** 剩余可投票数(终身 12 - 已投) */
|
/** 今日剩余票数(ALL 即投出该数值) */
|
||||||
remaining: number;
|
remaining: number;
|
||||||
/** 总额度常量 12(用于文案 "X / 12") */
|
/** 每日总额度(用于副文案展示) */
|
||||||
totalQuota: number;
|
dailyQuota: number;
|
||||||
/** 关闭弹窗 */
|
/** 关闭弹窗 */
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
/** 确认投票(无 count 参数,固定 1 票) */
|
/** 确认投票(count 为最终实际投票数,ALL 会被解析为 remaining) */
|
||||||
onConfirm: (artist: Artist) => void | Promise<void>;
|
onConfirm: (artist: Artist, count: number) => void | Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const VOTE_OPTIONS: VoteOption[] = [1, 3, 5, "ALL"];
|
||||||
|
|
||||||
|
function defaultOption(remaining: number): VoteOption {
|
||||||
|
if (remaining >= 3) return 3;
|
||||||
|
if (remaining >= 1) return remaining as VoteOption;
|
||||||
|
return "ALL";
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCount(opt: VoteOption, remaining: number): number {
|
||||||
|
return opt === "ALL" ? remaining : opt;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function VoteModal({
|
export default function VoteModal({
|
||||||
artist,
|
artist,
|
||||||
remaining,
|
remaining,
|
||||||
totalQuota,
|
dailyQuota,
|
||||||
onClose,
|
onClose,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: VoteModalProps) {
|
}: VoteModalProps) {
|
||||||
const open = artist != null;
|
const open = artist != null;
|
||||||
|
const [selected, setSelected] = useState<VoteOption>(defaultOption(remaining));
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
// 即时判断当前艺人是否已被投过(避免父组件忘传防护)
|
|
||||||
const hasVotedSelector = artist ? selectHasVoted(artist.id) : () => false;
|
|
||||||
const hasVoted = useVoteStore(hasVotedSelector);
|
|
||||||
|
|
||||||
useEffect(() => setMounted(true), []);
|
useEffect(() => setMounted(true), []);
|
||||||
|
|
||||||
// 打开时重置 loading 态
|
// 打开时重置默认选择
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (open) setLoading(false);
|
if (open) {
|
||||||
}, [open]);
|
setSelected(defaultOption(remaining));
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [open, remaining]);
|
||||||
|
|
||||||
// ESC 关闭 + body 锁滚
|
// ESC 关闭 + body 锁滚
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@ -60,18 +73,18 @@ export default function VoteModal({
|
|||||||
};
|
};
|
||||||
}, [open, onClose]);
|
}, [open, onClose]);
|
||||||
|
|
||||||
const exhausted = remaining <= 0;
|
const actualCount = resolveCount(selected, remaining);
|
||||||
const canSubmit = !exhausted && !hasVoted && !loading;
|
const canSubmit = remaining > 0 && actualCount > 0 && actualCount <= remaining;
|
||||||
|
|
||||||
const handleConfirm = useCallback(async () => {
|
const handleConfirm = useCallback(async () => {
|
||||||
if (!artist || !canSubmit) return;
|
if (!artist || loading || !canSubmit) return;
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await onConfirm(artist);
|
await onConfirm(artist, actualCount);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [artist, canSubmit, onConfirm]);
|
}, [artist, actualCount, canSubmit, loading, onConfirm]);
|
||||||
|
|
||||||
if (!mounted) return null;
|
if (!mounted) return null;
|
||||||
|
|
||||||
@ -94,7 +107,7 @@ export default function VoteModal({
|
|||||||
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-default"
|
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-default"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 弹窗主体 */}
|
{/* 弹窗主体(已去除顶部紫色横条) */}
|
||||||
<motion.div
|
<motion.div
|
||||||
role="dialog"
|
role="dialog"
|
||||||
aria-modal="true"
|
aria-modal="true"
|
||||||
@ -116,86 +129,81 @@ export default function VoteModal({
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* 头像 */}
|
{/* 头像 */}
|
||||||
<div className="w-20 h-20 mx-auto mb-3.5 rounded-full overflow-hidden border-2 border-purple-500 shadow-[0_0_16px_rgba(139,92,246,0.4)] relative">
|
<div className="w-20 h-20 mx-auto mb-3.5 rounded-full overflow-hidden border-2 border-purple-500 shadow-[0_0_16px_rgba(139,92,246,0.4)]">
|
||||||
<ArtistPortrait
|
<ArtistPortrait
|
||||||
artist={artist}
|
artist={artist}
|
||||||
rounded="rounded-full"
|
rounded="rounded-full"
|
||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
{hasVoted && (
|
|
||||||
<div className="absolute inset-0 bg-black/55 flex items-center justify-center">
|
|
||||||
<Check size={28} className="text-purple-300" strokeWidth={3} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 标题 */}
|
{/* 标题 */}
|
||||||
<div className="text-center mb-3.5">
|
<div className="text-center mb-4">
|
||||||
<div
|
<div
|
||||||
id="vote-modal-title"
|
id="vote-modal-title"
|
||||||
className="text-lg font-bold text-white mb-1"
|
className="text-lg font-bold text-white mb-1"
|
||||||
>
|
>
|
||||||
{hasVoted
|
为 {artist.name} 投票
|
||||||
? `已为 ${artist.name} 投过票`
|
|
||||||
: exhausted
|
|
||||||
? "12 票已用完"
|
|
||||||
: `为 ${artist.name} 投票`}
|
|
||||||
</div>
|
</div>
|
||||||
<div className="font-label text-[11px] tracking-widest text-white/45 uppercase">
|
<div className="font-label text-[11px] tracking-widest text-white/45 uppercase">
|
||||||
No.{artist.no} · Current Rank #{artist.rank}
|
No.{artist.no} · Current Rank #{artist.rank}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 剩余票数显示 */}
|
{/* 剩余票数提示 */}
|
||||||
<div
|
<div className="flex items-center justify-between text-xs mb-2.5">
|
||||||
className={cn(
|
<span className="text-white/55">选择投票数:</span>
|
||||||
"flex items-center justify-between text-xs mb-4 px-3 py-2.5 rounded-lg border",
|
<span className="text-purple-300 tabular-nums">
|
||||||
exhausted
|
今日剩余 {remaining} / {dailyQuota}
|
||||||
? "border-pink-400/30 bg-pink-400/[0.05]"
|
|
||||||
: "border-purple-500/25 bg-purple-500/[0.06]",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<span className="text-white/65">你的剩余票数</span>
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"font-display tabular-nums text-base",
|
|
||||||
exhausted ? "text-pink-300" : "text-purple-300",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{remaining}{" "}
|
|
||||||
<span className="text-white/40 text-xs">/ {totalQuota}</span>
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 规则提示 · 不可撤销警示(仅在可投态显示) */}
|
{/* 票数选择 */}
|
||||||
{!hasVoted && !exhausted && (
|
<div className="flex gap-2.5 justify-center mb-5">
|
||||||
<div className="flex items-start gap-2 mb-5 px-3 py-2.5 rounded-lg bg-white/[0.03] border border-white/[0.06]">
|
{VOTE_OPTIONS.map((opt) => {
|
||||||
<AlertCircle
|
const active = selected === opt;
|
||||||
size={14}
|
const optValue = resolveCount(opt, remaining);
|
||||||
className="text-purple-300/80 mt-0.5 flex-shrink-0"
|
const disabled =
|
||||||
/>
|
remaining === 0 ||
|
||||||
<p className="text-[11px] leading-relaxed text-white/65">
|
optValue === 0 ||
|
||||||
投出后不可撤销 · 每位艺人仅能投 1 票
|
optValue > remaining ||
|
||||||
</p>
|
(opt === "ALL" && remaining === 0);
|
||||||
</div>
|
return (
|
||||||
)}
|
<button
|
||||||
|
type="button"
|
||||||
|
key={String(opt)}
|
||||||
|
onClick={() => !disabled && setSelected(opt)}
|
||||||
|
disabled={disabled}
|
||||||
|
className={cn(
|
||||||
|
"rounded-lg font-display text-base flex items-center justify-center transition-all w-14 h-13 py-3.5 px-3",
|
||||||
|
disabled &&
|
||||||
|
"bg-surface/40 border border-white/8 text-white/25 cursor-not-allowed",
|
||||||
|
!disabled &&
|
||||||
|
!active &&
|
||||||
|
"bg-surface border border-white/14 text-white/65 hover:border-white/30",
|
||||||
|
!disabled &&
|
||||||
|
active &&
|
||||||
|
"bg-purple-500/12 border border-purple-500 text-purple-300 shadow-[0_0_16px_rgba(139,92,246,0.35)]",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{opt}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* 确认按钮 */}
|
{/* 确认按钮 */}
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
className="w-full h-12 text-sm"
|
className="w-full h-12 text-sm"
|
||||||
onClick={hasVoted || exhausted ? onClose : handleConfirm}
|
onClick={handleConfirm}
|
||||||
loading={loading}
|
loading={loading}
|
||||||
disabled={loading}
|
disabled={!canSubmit}
|
||||||
leftIcon={
|
leftIcon={<Heart size={14} />}
|
||||||
hasVoted || exhausted ? undefined : <Heart size={14} />
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{hasVoted
|
{remaining === 0
|
||||||
? "好的"
|
? "今日票数已用完"
|
||||||
: exhausted
|
: `确认投出 ${actualCount} 票`}
|
||||||
? "感谢支持"
|
|
||||||
: "投出我的一票"}
|
|
||||||
</Button>
|
</Button>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|||||||
@ -1,19 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { ChevronLeft, Heart, Share2 } from "lucide-react";
|
||||||
ChevronLeft,
|
import toast from "react-hot-toast";
|
||||||
Heart,
|
|
||||||
Check,
|
|
||||||
Quote as QuoteIcon,
|
|
||||||
Sparkles,
|
|
||||||
Compass,
|
|
||||||
MessageCircle,
|
|
||||||
User,
|
|
||||||
Ruler,
|
|
||||||
Calendar,
|
|
||||||
BookOpen,
|
|
||||||
} from "lucide-react";
|
|
||||||
import type { Artist } from "@/types/artist";
|
import type { Artist } from "@/types/artist";
|
||||||
import { TAG_LABEL } from "@/types/artist";
|
import { TAG_LABEL } from "@/types/artist";
|
||||||
import ArtistPortrait from "@/components/cards/ArtistPortrait";
|
import ArtistPortrait from "@/components/cards/ArtistPortrait";
|
||||||
@ -23,43 +12,61 @@ import RankCard from "./RankCard";
|
|||||||
import PerformanceVideo from "./PerformanceVideo";
|
import PerformanceVideo from "./PerformanceVideo";
|
||||||
import PerformanceGallery from "./PerformanceGallery";
|
import PerformanceGallery from "./PerformanceGallery";
|
||||||
import FloatingVoteButton from "@/components/FloatingVoteButton";
|
import FloatingVoteButton from "@/components/FloatingVoteButton";
|
||||||
import FloatingBackButton from "@/components/FloatingBackButton";
|
import { useVoteStore, selectArtist } from "@/lib/store";
|
||||||
import { useVoteStore, selectArtist, selectHasVoted } from "@/lib/store";
|
|
||||||
import { useVoteAction } from "@/hooks/useVoteAction";
|
import { useVoteAction } from "@/hooks/useVoteAction";
|
||||||
import { cn } from "@/lib/cn";
|
|
||||||
|
|
||||||
interface ArtistDetailContentProps {
|
interface ArtistDetailContentProps {
|
||||||
artist: Artist;
|
artist: Artist;
|
||||||
allArtists: Artist[];
|
allArtists: Artist[];
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 把 "、 / , ," 分隔的串切成 chip 数组 */
|
|
||||||
function parseChips(text?: string): string[] {
|
|
||||||
if (!text) return [];
|
|
||||||
return text
|
|
||||||
.split(/[、,,/]/)
|
|
||||||
.map((s) => s.trim())
|
|
||||||
.filter(Boolean);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ArtistDetailContent({
|
export default function ArtistDetailContent({
|
||||||
artist: initialArtist,
|
artist: initialArtist,
|
||||||
allArtists: initialAll,
|
allArtists: initialAll,
|
||||||
}: ArtistDetailContentProps) {
|
}: ArtistDetailContentProps) {
|
||||||
// 用 store 数据覆盖(投票后票数能马上变)
|
// 用 store 数据覆盖(这样投票后票数能马上变)
|
||||||
const storeArtist = useVoteStore(selectArtist(initialArtist.id));
|
const storeArtist = useVoteStore(selectArtist(initialArtist.id));
|
||||||
const storeAll = useVoteStore((s) => s.artists);
|
const storeAll = useVoteStore((s) => s.artists);
|
||||||
const hasVoted = useVoteStore(selectHasVoted(initialArtist.id));
|
|
||||||
|
|
||||||
const artist = storeArtist ?? initialArtist;
|
const artist = storeArtist ?? initialArtist;
|
||||||
const allArtists = storeAll.length ? storeAll : initialAll;
|
const allArtists = storeAll.length ? storeAll : initialAll;
|
||||||
|
|
||||||
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
|
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
|
||||||
useVoteAction();
|
useVoteAction();
|
||||||
|
|
||||||
|
const handleShare = async () => {
|
||||||
|
const url =
|
||||||
|
typeof window !== "undefined"
|
||||||
|
? `${window.location.origin}/artist/${artist.id}`
|
||||||
|
: `/artist/${artist.id}`;
|
||||||
|
const shareData = {
|
||||||
|
title: `${artist.name} · CYBER STAR`,
|
||||||
|
text: `为 ${artist.name}(${artist.enName})打 Call!${artist.slogan}`,
|
||||||
|
url,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 优先用 Web Share API(移动端 / Safari 支持)
|
||||||
|
if (typeof navigator !== "undefined" && navigator.share) {
|
||||||
|
try {
|
||||||
|
await navigator.share(shareData);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// 用户取消分享:不报错
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兜底:复制到剪贴板
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
toast.success("链接已复制,去粘贴给朋友吧~");
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败,请手动复制地址栏");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* 面包屑 */}
|
|
||||||
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pt-4">
|
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pt-4">
|
||||||
<div className="h-12 flex items-center gap-3 text-sm">
|
<div className="h-12 flex items-center gap-3 text-sm">
|
||||||
<Link
|
<Link
|
||||||
@ -70,91 +77,142 @@ export default function ArtistDetailContent({
|
|||||||
全部艺人
|
全部艺人
|
||||||
</Link>
|
</Link>
|
||||||
<span className="text-white/30">/</span>
|
<span className="text-white/30">/</span>
|
||||||
<span className="text-white/85 truncate">{artist.name}</span>
|
<span className="text-white/85">艺人详情</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleShare}
|
||||||
|
className="ml-auto inline-flex items-center gap-1.5 text-purple-300 hover:text-purple-200 text-xs"
|
||||||
|
>
|
||||||
|
<Share2 size={14} />
|
||||||
|
<span className="hidden sm:inline">分享</span>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* HERO · 立绘 + 身份信息 */}
|
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-12">
|
||||||
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-10">
|
<div
|
||||||
<HeroPanel
|
className="rounded-2xl border border-white/[0.06] overflow-hidden p-5 sm:p-8 grid gap-6 lg:grid-cols-[340px_1fr] lg:gap-8"
|
||||||
artist={artist}
|
style={{
|
||||||
allArtists={allArtists}
|
background:
|
||||||
onVote={() => openVote(artist)}
|
"linear-gradient(135deg, rgba(139,92,246,0.06) 0%, rgba(13,10,36,0.6) 100%)",
|
||||||
hasVoted={hasVoted}
|
}}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<ArtistPortrait
|
||||||
|
artist={artist}
|
||||||
|
rounded="rounded-xl"
|
||||||
|
className="w-full aspect-[4/5] shadow-card"
|
||||||
|
/>
|
||||||
|
<div className="mt-3 flex items-center gap-2 px-3 py-2 rounded-lg bg-black/35 border border-white/10">
|
||||||
|
<span
|
||||||
|
className="w-4 h-4 rounded-full ring-2 ring-white/20"
|
||||||
|
style={{ background: artist.themeColor }}
|
||||||
|
/>
|
||||||
|
<span className="font-label text-[10px] tracking-widest uppercase text-white/55">
|
||||||
|
应援色
|
||||||
|
</span>
|
||||||
|
<span className="font-mono text-[10px] text-white/45 ml-auto">
|
||||||
|
{artist.themeColor}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/80 mb-2">
|
||||||
|
No.{artist.no}
|
||||||
|
</div>
|
||||||
|
<h1 className="text-3xl sm:text-4xl font-bold text-white tracking-tight mb-1">
|
||||||
|
{artist.name}
|
||||||
|
</h1>
|
||||||
|
<p className="font-display text-lg tracking-[0.2em] uppercase text-purple-300 glow-text-purple">
|
||||||
|
{artist.enName}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{artist.tags.map((t) => (
|
||||||
|
<span
|
||||||
|
key={t}
|
||||||
|
className="px-2.5 py-1 rounded-full bg-purple-500/12 border border-purple-500/30 text-purple-300 text-[11px]"
|
||||||
|
>
|
||||||
|
{TAG_LABEL[t]}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
<MetaCell label="生日" value={artist.birthday} />
|
||||||
|
<MetaCell label="身高" value={`${artist.height} cm`} />
|
||||||
|
<MetaCell label="CV" value={artist.cv ?? "未公开"} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<RankCard artist={artist} allArtists={allArtists} />
|
||||||
|
|
||||||
|
<div className="flex gap-3 pt-1">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
size="lg"
|
||||||
|
pulse
|
||||||
|
className="flex-1"
|
||||||
|
leftIcon={<Heart size={16} fill="currentColor" />}
|
||||||
|
onClick={() => openVote(artist)}
|
||||||
|
>
|
||||||
|
投票
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
leftIcon={<Share2 size={14} />}
|
||||||
|
aria-label="分享"
|
||||||
|
onClick={handleShare}
|
||||||
|
>
|
||||||
|
<span className="hidden sm:inline">关注</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-12">
|
||||||
|
<SectionHeading title="表演视频" subtitle="15s Performance" />
|
||||||
|
<PerformanceVideo themeColor={artist.themeColor} duration="00:15" />
|
||||||
|
<p className="text-xs text-white/40 mt-3">
|
||||||
|
视频不会自动播放,避免流量浪费
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-12">
|
||||||
|
<SectionHeading title="表演图片" subtitle="Performance Gallery" />
|
||||||
|
<PerformanceGallery
|
||||||
|
images={artist.gallery}
|
||||||
|
themeColor={artist.themeColor}
|
||||||
/>
|
/>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* 性格 · 口头禅 */}
|
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
||||||
{(artist.personality || artist.catchphrase) && (
|
<SectionHeading title="艺人简介" subtitle="Biography" />
|
||||||
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-10">
|
<div className="bg-surface/50 backdrop-blur-md border border-white/[0.08] rounded-xl p-5 sm:p-7">
|
||||||
<div className="grid lg:grid-cols-[1.5fr_1fr] gap-4">
|
<p className="text-sm sm:text-base text-white/75 leading-[1.85]">
|
||||||
{artist.personality && <PersonalityCard text={artist.personality} />}
|
{artist.bio}
|
||||||
{artist.catchphrase && <CatchphraseCard text={artist.catchphrase} />}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 核心技能 · 核心赛道 */}
|
|
||||||
{(artist.skills || artist.track) && (
|
|
||||||
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-10">
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
{artist.skills && (
|
|
||||||
<ChipCard
|
|
||||||
label="核心技能"
|
|
||||||
subtitle="Core Skills"
|
|
||||||
icon={<Sparkles size={14} />}
|
|
||||||
chips={parseChips(artist.skills)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{artist.track && (
|
|
||||||
<ChipCard
|
|
||||||
label="核心赛道"
|
|
||||||
subtitle="Career Track"
|
|
||||||
icon={<Compass size={14} />}
|
|
||||||
chips={parseChips(artist.track)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 人物小传 · 长简介 */}
|
|
||||||
{artist.bio && (
|
|
||||||
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-12">
|
|
||||||
<BiographyCard bio={artist.bio} />
|
|
||||||
</section>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 表演视频 · 与版心同宽,首帧自动作为封面,整个视频区域可点击播放/暂停 */}
|
|
||||||
{artist.videoUrl && (
|
|
||||||
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-12">
|
|
||||||
<SectionHeading title="表演视频" subtitle="Solo Performance" />
|
|
||||||
<div className="mt-4">
|
|
||||||
<PerformanceVideo src={artist.videoUrl} poster={artist.videoPoster} />
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-white/40 mt-3">
|
|
||||||
视频不会自动播放,避免流量浪费
|
|
||||||
</p>
|
</p>
|
||||||
</section>
|
<div className="mt-5 pt-5 border-t border-white/[0.06] grid grid-cols-1 sm:grid-cols-3 gap-3 text-sm">
|
||||||
)}
|
<BioMeta label="性格关键词" value={artist.slogan} />
|
||||||
|
<BioMeta label="主推楼曲" value={`《${artist.enName}'s Song》`} />
|
||||||
{/* 表演图片 · 三张氛围图,左对齐,竖向 3:4 */}
|
<BioMeta
|
||||||
{artist.gallery && artist.gallery.filter(Boolean).length > 0 && (
|
label="训练经历"
|
||||||
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-20">
|
value={`${Math.floor(artist.height / 30)} 年声乐 + 舞台`}
|
||||||
<SectionHeading title="表演图片" subtitle="Performance Gallery" />
|
/>
|
||||||
<div className="mt-4">
|
|
||||||
<PerformanceGallery images={artist.gallery} />
|
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
)}
|
</section>
|
||||||
|
|
||||||
<FloatingBackButton fallbackHref="/" />
|
<FloatingVoteButton onClick={() => openVote(artist)} />
|
||||||
<FloatingVoteButton onClick={() => openVote(artist)} hasVoted={hasVoted} />
|
|
||||||
|
|
||||||
<VoteModal
|
<VoteModal
|
||||||
artist={target}
|
artist={target}
|
||||||
remaining={remaining}
|
remaining={remaining}
|
||||||
totalQuota={totalQuota}
|
dailyQuota={dailyQuota}
|
||||||
onClose={closeVote}
|
onClose={closeVote}
|
||||||
onConfirm={confirmVote}
|
onConfirm={confirmVote}
|
||||||
/>
|
/>
|
||||||
@ -162,270 +220,24 @@ export default function ArtistDetailContent({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ============================================================
|
function MetaCell({ label, value }: { label: string; value: string }) {
|
||||||
* 子组件 · 统一品牌紫色,无 per-artist themeColor
|
|
||||||
* ============================================================ */
|
|
||||||
|
|
||||||
interface HeroPanelProps {
|
|
||||||
artist: Artist;
|
|
||||||
allArtists: Artist[];
|
|
||||||
onVote: () => void;
|
|
||||||
hasVoted: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function HeroPanel({ artist, allArtists, onVote, hasVoted }: HeroPanelProps) {
|
|
||||||
return (
|
return (
|
||||||
<div className="relative rounded-2xl border border-purple-500/20 overflow-hidden grid gap-6 lg:grid-cols-[420px_1fr] lg:gap-8 p-5 sm:p-8 bg-[linear-gradient(135deg,rgba(139,92,246,0.10)_0%,rgba(13,10,36,0.6)_100%)]">
|
<div className="bg-deep/60 border border-white/[0.06] rounded-lg px-3 py-2">
|
||||||
{/* 装饰光晕 */}
|
<div className="font-label text-[9px] tracking-widest uppercase text-white/40 mb-0.5">
|
||||||
<div
|
{label}
|
||||||
aria-hidden
|
|
||||||
className="absolute -top-24 -right-16 w-[420px] h-[420px] pointer-events-none bg-[radial-gradient(circle,rgba(139,92,246,0.22)_0%,transparent_60%)]"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 立绘 */}
|
|
||||||
<div className="relative">
|
|
||||||
<ArtistPortrait
|
|
||||||
artist={artist}
|
|
||||||
rounded="rounded-xl"
|
|
||||||
className="w-full aspect-[4/5]"
|
|
||||||
/>
|
|
||||||
{hasVoted && (
|
|
||||||
<div className="absolute top-3 right-3 w-9 h-9 rounded-full bg-purple-500 shadow-[0_0_16px_rgba(139,92,246,0.8)] flex items-center justify-center border-2 border-white/30">
|
|
||||||
<Check size={20} strokeWidth={3} className="text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 身份信息 */}
|
|
||||||
<div className="relative flex flex-col gap-4">
|
|
||||||
{/* 编号 */}
|
|
||||||
<div className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/80">
|
|
||||||
No.{artist.no}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 中文名 / 英文名 */}
|
|
||||||
<div>
|
|
||||||
<h1 className="text-4xl sm:text-5xl font-bold text-white tracking-tight mb-1">
|
|
||||||
{artist.name}
|
|
||||||
</h1>
|
|
||||||
<p className="font-display text-xl sm:text-2xl tracking-[0.22em] uppercase text-purple-300 glow-text-purple">
|
|
||||||
{artist.enName}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 实力标签 */}
|
|
||||||
<div className="flex gap-2 flex-wrap">
|
|
||||||
{artist.tags.map((t) => (
|
|
||||||
<span
|
|
||||||
key={t}
|
|
||||||
className="px-2.5 py-1 rounded-full bg-purple-500/12 border border-purple-500/30 text-purple-300 text-[11px]"
|
|
||||||
>
|
|
||||||
{TAG_LABEL[t]}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 年龄 / 身高 / 性别 */}
|
|
||||||
<div className="grid grid-cols-3 gap-2">
|
|
||||||
<MetaCell
|
|
||||||
icon={<Calendar size={13} />}
|
|
||||||
label="年龄"
|
|
||||||
value={artist.age != null ? `${artist.age} 岁` : "未公开"}
|
|
||||||
/>
|
|
||||||
<MetaCell
|
|
||||||
icon={<Ruler size={13} />}
|
|
||||||
label="身高"
|
|
||||||
value={`${artist.height} cm`}
|
|
||||||
/>
|
|
||||||
<MetaCell
|
|
||||||
icon={<User size={13} />}
|
|
||||||
label="性别"
|
|
||||||
value={
|
|
||||||
artist.gender === "M"
|
|
||||||
? "男生"
|
|
||||||
: artist.gender === "F"
|
|
||||||
? "女生"
|
|
||||||
: "未公开"
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 座右铭 · 品牌紫引文,保持与全站视觉一致 */}
|
|
||||||
{artist.motto && (
|
|
||||||
<div className="relative pl-6 pr-4 py-3.5 rounded-r-lg bg-[linear-gradient(90deg,rgba(139,92,246,0.10)_0%,transparent_100%)] border-l-[3px] border-purple-400">
|
|
||||||
<QuoteIcon size={14} className="absolute top-3 left-2 text-purple-400/50" />
|
|
||||||
<p className="text-base sm:text-lg italic font-medium text-white/95 leading-snug">
|
|
||||||
{artist.motto}
|
|
||||||
</p>
|
|
||||||
<p className="mt-1.5 font-label text-[10px] tracking-[0.25em] uppercase text-purple-300/70">
|
|
||||||
Motto · 座右铭
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 排名卡片 */}
|
|
||||||
<RankCard artist={artist} allArtists={allArtists} />
|
|
||||||
|
|
||||||
{/* 操作按钮 · 仅投票 */}
|
|
||||||
<div className="pt-1">
|
|
||||||
<Button
|
|
||||||
variant={hasVoted ? "outline" : "primary"}
|
|
||||||
size="lg"
|
|
||||||
pulse={!hasVoted}
|
|
||||||
className={cn("w-full", hasVoted && "cursor-not-allowed opacity-80")}
|
|
||||||
leftIcon={
|
|
||||||
hasVoted ? (
|
|
||||||
<Check size={16} strokeWidth={3} />
|
|
||||||
) : (
|
|
||||||
<Heart size={16} fill="currentColor" />
|
|
||||||
)
|
|
||||||
}
|
|
||||||
onClick={onVote}
|
|
||||||
>
|
|
||||||
{hasVoted ? "已投票" : "投票"}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
<div className="text-sm text-white/85 truncate">{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MetaCell({
|
function BioMeta({ label, value }: { label: string; value: string }) {
|
||||||
icon,
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
icon: React.ReactNode;
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
}) {
|
|
||||||
return (
|
return (
|
||||||
<div className="bg-deep/60 border border-white/[0.06] rounded-lg px-3 py-2.5">
|
<div>
|
||||||
<div className="flex items-center gap-1.5 text-purple-300/60 mb-0.5">
|
<span className="font-label text-[10px] tracking-widest uppercase text-purple-300/70 block mb-1">
|
||||||
{icon}
|
{label}
|
||||||
<span className="font-label text-[9px] tracking-widest uppercase">
|
</span>
|
||||||
{label}
|
<span className="text-white/85">{value}</span>
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div className="text-sm text-white/90 truncate">{value}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 性格 / 口头禅 双卡使用完全相同的容器规范:
|
|
||||||
* - 圆角 2xl + 玻璃化 surface 背景 + 同样的 border / padding
|
|
||||||
* - 左上角同样的紫色装饰条
|
|
||||||
* - 同款 SectionHeading
|
|
||||||
* 唯一差异:内容呈现 —— 性格是段落正文,口头禅是大字引号。
|
|
||||||
*/
|
|
||||||
function ProfileInfoCard({
|
|
||||||
title,
|
|
||||||
subtitle,
|
|
||||||
children,
|
|
||||||
}: {
|
|
||||||
title: string;
|
|
||||||
subtitle: string;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="relative rounded-2xl border border-white/[0.08] bg-surface/50 backdrop-blur-md p-6 sm:p-8 overflow-hidden min-h-[200px]">
|
|
||||||
<div
|
|
||||||
aria-hidden
|
|
||||||
className="absolute top-0 left-0 w-1 h-16 rounded-r bg-[linear-gradient(180deg,#a78bfa_0%,transparent_100%)]"
|
|
||||||
/>
|
|
||||||
<SectionHeading title={title} subtitle={subtitle} />
|
|
||||||
<div className="mt-5">{children}</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function PersonalityCard({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<ProfileInfoCard title="性格" subtitle="Personality">
|
|
||||||
<p
|
|
||||||
className="text-sm sm:text-base leading-relaxed"
|
|
||||||
style={{ color: "rgba(255,255,255,0.95)" }}
|
|
||||||
>
|
|
||||||
{text}
|
|
||||||
</p>
|
|
||||||
</ProfileInfoCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function CatchphraseCard({ text }: { text: string }) {
|
|
||||||
return (
|
|
||||||
<ProfileInfoCard title="口头禅" subtitle="Catchphrase">
|
|
||||||
<div className="flex items-start gap-3 h-full">
|
|
||||||
<MessageCircle
|
|
||||||
size={22}
|
|
||||||
className="flex-shrink-0 mt-1 text-purple-400/70"
|
|
||||||
/>
|
|
||||||
<p className="text-lg sm:text-xl font-semibold text-white/95 leading-snug tracking-wide italic">
|
|
||||||
“{text}”
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</ProfileInfoCard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function ChipCard({
|
|
||||||
label,
|
|
||||||
subtitle,
|
|
||||||
icon,
|
|
||||||
chips,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
subtitle: string;
|
|
||||||
icon: React.ReactNode;
|
|
||||||
chips: string[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-white/[0.08] bg-surface/40 backdrop-blur-md p-5 sm:p-6">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<SectionHeading title={label} subtitle={subtitle} />
|
|
||||||
<span className="text-purple-300/60">{icon}</span>
|
|
||||||
</div>
|
|
||||||
{chips.length > 0 ? (
|
|
||||||
<div className="flex flex-wrap gap-2 mt-5">
|
|
||||||
{chips.map((c, i) => (
|
|
||||||
<span
|
|
||||||
key={`${c}-${i}`}
|
|
||||||
className={cn(
|
|
||||||
"px-3 py-1.5 rounded-full border text-xs whitespace-nowrap",
|
|
||||||
"bg-purple-500/12 border-purple-400/30 text-purple-200",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{c}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<p className="mt-4 text-sm text-white/45">未公开</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BiographyCard({ bio }: { bio: string }) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-2xl border border-white/[0.08] bg-surface/50 backdrop-blur-md p-6 sm:p-10">
|
|
||||||
<div className="flex items-baseline justify-between mb-2">
|
|
||||||
<SectionHeading title="人物小传" subtitle="Biography" />
|
|
||||||
<span className="text-purple-300/60">
|
|
||||||
<BookOpen size={14} />
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"mt-5 text-base sm:text-lg text-white/85 leading-[2.05]",
|
|
||||||
// 首字下沉
|
|
||||||
"first-letter:text-4xl sm:first-letter:text-5xl first-letter:font-display first-letter:text-purple-300",
|
|
||||||
"first-letter:mr-2 first-letter:float-left first-letter:leading-none first-letter:mt-1",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{bio}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -438,11 +250,8 @@ function SectionHeading({
|
|||||||
subtitle: string;
|
subtitle: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="inline-flex items-baseline gap-3">
|
<div className="mb-4 flex items-baseline gap-3">
|
||||||
<span
|
<span aria-hidden className="w-1 h-4 rounded-full bg-purple-400 shadow-[0_0_8px_rgba(167,139,250,0.7)]" />
|
||||||
aria-hidden
|
|
||||||
className="w-1 h-4 rounded-full bg-purple-400 shadow-[0_0_8px_rgba(167,139,250,0.7)]"
|
|
||||||
/>
|
|
||||||
<h2 className="font-display text-base sm:text-lg text-white tracking-[0.2em]">
|
<h2 className="font-display text-base sm:text-lg text-white tracking-[0.2em]">
|
||||||
{title}
|
{title}
|
||||||
</h2>
|
</h2>
|
||||||
|
|||||||
@ -5,14 +5,23 @@ import { createPortal } from "react-dom";
|
|||||||
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
import { X, ChevronLeft, ChevronRight } from "lucide-react";
|
||||||
import { AnimatePresence, motion } from "framer-motion";
|
import { AnimatePresence, motion } from "framer-motion";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
interface PerformanceGalleryProps {
|
interface PerformanceGalleryProps {
|
||||||
images: string[];
|
images: string[];
|
||||||
|
/** 当无真实图时,用此颜色生成占位 */
|
||||||
|
themeColor?: string;
|
||||||
|
/** 占位标签(如 "定妆照"、"表演中") */
|
||||||
|
placeholderLabels?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function PerformanceGallery({ images }: PerformanceGalleryProps) {
|
const DEFAULT_LABELS = ["定妆照", "表演中", "幕后花絮", "舞台 1", "舞台 2", "未公开"];
|
||||||
// 过滤掉空字符串,只渲染真实路径
|
|
||||||
images = images.filter(Boolean);
|
export default function PerformanceGallery({
|
||||||
|
images,
|
||||||
|
themeColor = "#8b5cf6",
|
||||||
|
placeholderLabels = DEFAULT_LABELS,
|
||||||
|
}: PerformanceGalleryProps) {
|
||||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
@ -37,31 +46,39 @@ export default function PerformanceGallery({ images }: PerformanceGalleryProps)
|
|||||||
};
|
};
|
||||||
}, [lightboxIndex, images.length]);
|
}, [lightboxIndex, images.length]);
|
||||||
|
|
||||||
if (images.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="rounded-xl border border-dashed border-white/10 p-10 text-center text-white/45 text-sm">
|
|
||||||
暂无表演图片
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 sm:gap-4">
|
<div className="grid grid-cols-3 sm:grid-cols-3 lg:grid-cols-6 gap-2 sm:gap-3">
|
||||||
{images.map((src, i) => (
|
{images.map((src, i) => (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
key={src}
|
key={i}
|
||||||
onClick={() => setLightboxIndex(i)}
|
onClick={() => setLightboxIndex(i)}
|
||||||
className="group relative aspect-[3/4] rounded-xl overflow-hidden border border-white/[0.08] hover:border-purple-500/50 hover:shadow-[0_0_16px_rgba(139,92,246,0.25)] transition-all"
|
className="group relative aspect-[4/3] rounded-lg overflow-hidden border border-white/[0.08] hover:border-purple-500/50 hover:shadow-[0_0_16px_rgba(139,92,246,0.25)] transition-all"
|
||||||
>
|
>
|
||||||
<Image
|
{src ? (
|
||||||
src={src}
|
<Image
|
||||||
alt={`表演图 ${i + 1}`}
|
src={src}
|
||||||
fill
|
alt={`表演图 ${i + 1}`}
|
||||||
sizes="(max-width: 768px) 50vw, 320px"
|
fill
|
||||||
className="object-cover object-top group-hover:scale-105 transition-transform duration-500"
|
sizes="(max-width: 768px) 33vw, 200px"
|
||||||
/>
|
className="object-cover group-hover:scale-105 transition-transform duration-500"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className="absolute inset-0 flex items-center justify-center text-white/55 text-[11px]"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${themeColor}25 0%, #1a1638 70%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span className="font-label tracking-widest uppercase text-[10px]">
|
||||||
|
{placeholderLabels[i] || `Image ${i + 1}`}
|
||||||
|
</span>
|
||||||
|
<span className="absolute top-1.5 right-1.5 text-white/15 text-[10px]">
|
||||||
|
✦
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
@ -136,13 +153,35 @@ export default function PerformanceGallery({ images }: PerformanceGalleryProps)
|
|||||||
transition={{ duration: 0.25 }}
|
transition={{ duration: 0.25 }}
|
||||||
className="relative max-w-5xl w-full mx-6 max-h-[85vh] aspect-[4/3] z-10"
|
className="relative max-w-5xl w-full mx-6 max-h-[85vh] aspect-[4/3] z-10"
|
||||||
>
|
>
|
||||||
<Image
|
{images[lightboxIndex] ? (
|
||||||
src={images[lightboxIndex]!}
|
<Image
|
||||||
alt={`表演图 ${lightboxIndex + 1}`}
|
src={images[lightboxIndex]!}
|
||||||
fill
|
alt={`表演图 ${lightboxIndex + 1}`}
|
||||||
sizes="100vw"
|
fill
|
||||||
className="object-contain"
|
sizes="100vw"
|
||||||
/>
|
className="object-contain"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
"absolute inset-0 rounded-xl flex items-center justify-center",
|
||||||
|
"border border-white/10",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${themeColor}30 0%, #1a1638 70%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="text-center">
|
||||||
|
<span className="font-logo text-6xl text-white/30 tracking-widest">
|
||||||
|
✦
|
||||||
|
</span>
|
||||||
|
<p className="font-label text-xs tracking-widest text-white/55 uppercase mt-3">
|
||||||
|
{placeholderLabels[lightboxIndex] ||
|
||||||
|
`Image ${lightboxIndex + 1}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
|
|
||||||
{/* 索引 */}
|
{/* 索引 */}
|
||||||
|
|||||||
@ -1,66 +1,27 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import {
|
import { useRef, useState } from "react";
|
||||||
MouseEvent as ReactMouseEvent,
|
|
||||||
useEffect,
|
|
||||||
useRef,
|
|
||||||
useState,
|
|
||||||
} from "react";
|
|
||||||
import { Play, Pause, Volume2, VolumeX, Maximize2 } from "lucide-react";
|
import { Play, Pause, Volume2, VolumeX, Maximize2 } from "lucide-react";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
interface PerformanceVideoProps {
|
interface PerformanceVideoProps {
|
||||||
src?: string;
|
src?: string;
|
||||||
poster?: string;
|
poster?: string;
|
||||||
|
duration?: string;
|
||||||
|
themeColor?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function fmtTime(s: number): string {
|
|
||||||
if (!isFinite(s) || s < 0) return "00:00";
|
|
||||||
const m = Math.floor(s / 60);
|
|
||||||
const sec = Math.floor(s % 60);
|
|
||||||
return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PerformanceVideo({
|
export default function PerformanceVideo({
|
||||||
src,
|
src,
|
||||||
poster,
|
poster,
|
||||||
|
duration = "00:15",
|
||||||
|
themeColor = "#8b5cf6",
|
||||||
className,
|
className,
|
||||||
}: PerformanceVideoProps) {
|
}: PerformanceVideoProps) {
|
||||||
const videoRef = useRef<HTMLVideoElement>(null);
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
const progressRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [muted, setMuted] = useState(false);
|
const [muted, setMuted] = useState(false);
|
||||||
const [current, setCurrent] = useState(0);
|
|
||||||
const [duration, setDuration] = useState(0);
|
|
||||||
const [seeking, setSeeking] = useState(false);
|
|
||||||
|
|
||||||
// 视频时间事件 + 首帧封面(loadedmetadata 后 seek 0.001s 让浏览器渲染首帧)
|
|
||||||
useEffect(() => {
|
|
||||||
const v = videoRef.current;
|
|
||||||
if (!v) return;
|
|
||||||
const onTime = () => !seeking && setCurrent(v.currentTime);
|
|
||||||
const onMeta = () => {
|
|
||||||
setDuration(v.duration);
|
|
||||||
// 没有 poster 时强制渲染首帧
|
|
||||||
if (!poster && v.currentTime === 0) {
|
|
||||||
try {
|
|
||||||
v.currentTime = 0.001;
|
|
||||||
} catch {
|
|
||||||
/* noop */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const onEnd = () => setPlaying(false);
|
|
||||||
v.addEventListener("timeupdate", onTime);
|
|
||||||
v.addEventListener("loadedmetadata", onMeta);
|
|
||||||
v.addEventListener("ended", onEnd);
|
|
||||||
return () => {
|
|
||||||
v.removeEventListener("timeupdate", onTime);
|
|
||||||
v.removeEventListener("loadedmetadata", onMeta);
|
|
||||||
v.removeEventListener("ended", onEnd);
|
|
||||||
};
|
|
||||||
}, [seeking, poster]);
|
|
||||||
|
|
||||||
const togglePlay = () => {
|
const togglePlay = () => {
|
||||||
const v = videoRef.current;
|
const v = videoRef.current;
|
||||||
@ -85,166 +46,101 @@ export default function PerformanceVideo({
|
|||||||
videoRef.current?.requestFullscreen?.();
|
videoRef.current?.requestFullscreen?.();
|
||||||
};
|
};
|
||||||
|
|
||||||
// 进度条:点击 / 拖拽 seek
|
|
||||||
const seekTo = (clientX: number) => {
|
|
||||||
const bar = progressRef.current;
|
|
||||||
const v = videoRef.current;
|
|
||||||
if (!bar || !v || !duration) return;
|
|
||||||
const rect = bar.getBoundingClientRect();
|
|
||||||
const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width));
|
|
||||||
const t = ratio * duration;
|
|
||||||
v.currentTime = t;
|
|
||||||
setCurrent(t);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBarMouseDown = (e: ReactMouseEvent<HTMLDivElement>) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setSeeking(true);
|
|
||||||
seekTo(e.clientX);
|
|
||||||
const onMove = (ev: MouseEvent) => seekTo(ev.clientX);
|
|
||||||
const onUp = () => {
|
|
||||||
setSeeking(false);
|
|
||||||
window.removeEventListener("mousemove", onMove);
|
|
||||||
window.removeEventListener("mouseup", onUp);
|
|
||||||
};
|
|
||||||
window.addEventListener("mousemove", onMove);
|
|
||||||
window.addEventListener("mouseup", onUp);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleBarTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSeeking(true);
|
|
||||||
const t = e.touches[0];
|
|
||||||
if (t) seekTo(t.clientX);
|
|
||||||
const onMove = (ev: TouchEvent) => {
|
|
||||||
const tt = ev.touches[0];
|
|
||||||
if (tt) seekTo(tt.clientX);
|
|
||||||
};
|
|
||||||
const onEnd = () => {
|
|
||||||
setSeeking(false);
|
|
||||||
window.removeEventListener("touchmove", onMove);
|
|
||||||
window.removeEventListener("touchend", onEnd);
|
|
||||||
};
|
|
||||||
window.addEventListener("touchmove", onMove, { passive: true });
|
|
||||||
window.addEventListener("touchend", onEnd);
|
|
||||||
};
|
|
||||||
|
|
||||||
const progress = duration > 0 ? (current / duration) * 100 : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative w-full bg-black rounded-xl overflow-hidden border border-white/[0.08] group",
|
"relative w-full aspect-video bg-deep rounded-xl overflow-hidden border border-white/[0.08] group",
|
||||||
// 16:9 比例 + 高度封顶 85svh:在小屏(笔记本)上等比缩窄宽度,确保视频可全显
|
|
||||||
"aspect-video max-w-[calc(85svh*16/9)] mx-auto",
|
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
onClick={src ? togglePlay : undefined}
|
|
||||||
role={src ? "button" : undefined}
|
|
||||||
aria-label={src ? (playing ? "暂停" : "播放") : undefined}
|
|
||||||
>
|
>
|
||||||
{src ? (
|
{src ? (
|
||||||
<video
|
<video
|
||||||
ref={videoRef}
|
ref={videoRef}
|
||||||
src={src}
|
src={src}
|
||||||
poster={poster || undefined}
|
poster={poster}
|
||||||
playsInline
|
playsInline
|
||||||
preload="metadata"
|
onEnded={() => setPlaying(false)}
|
||||||
className="absolute inset-0 w-full h-full object-contain"
|
className="absolute inset-0 w-full h-full object-cover"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="absolute inset-0 flex items-center justify-center bg-[radial-gradient(circle_at_50%_40%,rgba(139,92,246,0.30)_0%,#1a1638_55%,#08051a_100%)]">
|
<div
|
||||||
<p className="font-label text-[10px] tracking-[0.3em] uppercase text-white/40">
|
className="absolute inset-0"
|
||||||
视频暂未上线
|
style={{
|
||||||
</p>
|
background: `radial-gradient(circle at 50% 40%, ${themeColor}55 0%, #1a1638 55%, #08051a 100%)`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{/* 装饰星点 */}
|
||||||
|
<div className="absolute inset-0 opacity-50">
|
||||||
|
<span className="absolute top-[15%] left-[20%] text-white text-xs">
|
||||||
|
✦
|
||||||
|
</span>
|
||||||
|
<span className="absolute top-[70%] left-[80%] text-purple-300 text-sm">
|
||||||
|
✧
|
||||||
|
</span>
|
||||||
|
<span className="absolute top-[30%] left-[70%] text-white/60 text-[10px]">
|
||||||
|
✦
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 中央播放提示(未播放时) · 仅作视觉提示,真正的点击区是整个容器 */}
|
{/* 顶部 15s 标签 */}
|
||||||
{!playing && src && (
|
<div className="absolute top-3 left-3 inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full bg-purple-600/90 backdrop-blur-md text-white text-[10px] font-display tracking-widest uppercase shadow-purple-glow z-10">
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center z-10">
|
▶ 15s Performance
|
||||||
<span className="w-16 h-16 rounded-full bg-white/15 backdrop-blur-md border-2 border-white/40 flex items-center justify-center text-white transition-transform group-hover:scale-110">
|
</div>
|
||||||
|
|
||||||
|
{/* 时长徽章 */}
|
||||||
|
<div className="absolute bottom-3 right-3 px-2 py-0.5 rounded bg-black/65 backdrop-blur-md text-white text-[10px] font-display tracking-wider tabular-nums z-10">
|
||||||
|
{duration}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 中央播放按钮(未播放时) */}
|
||||||
|
{!playing && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={togglePlay}
|
||||||
|
className="absolute inset-0 flex items-center justify-center z-10 group/play"
|
||||||
|
aria-label="播放视频"
|
||||||
|
>
|
||||||
|
<span className="w-16 h-16 rounded-full bg-white/15 backdrop-blur-md border-2 border-white/40 flex items-center justify-center text-white group-hover/play:scale-110 transition-transform">
|
||||||
<Play size={22} fill="white" />
|
<Play size={22} fill="white" />
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 播放中 hover 中央暂停提示 */}
|
{/* 暂停按钮 + 控制条(播放中) */}
|
||||||
{playing && (
|
{playing && (
|
||||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center z-10 opacity-0 group-hover:opacity-100 transition-opacity bg-black/30">
|
<div className="absolute inset-0 flex items-center justify-center z-10 opacity-0 group-hover:opacity-100 transition-opacity bg-black/30">
|
||||||
<span className="w-14 h-14 rounded-full bg-white/15 backdrop-blur-md border-2 border-white/40 flex items-center justify-center text-white">
|
<button
|
||||||
<Pause size={20} />
|
type="button"
|
||||||
</span>
|
onClick={togglePlay}
|
||||||
</div>
|
className="w-14 h-14 rounded-full bg-white/15 backdrop-blur-md border-2 border-white/40 flex items-center justify-center text-white hover:scale-105 transition-transform"
|
||||||
)}
|
aria-label="暂停"
|
||||||
|
|
||||||
{/* 底部控制条 · 进度 + 时间 + 音量 + 全屏。点击控件不触发外层播放/暂停 */}
|
|
||||||
{src && (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute inset-x-0 bottom-0 z-20 px-3 pt-10 pb-3 bg-gradient-to-t from-black/85 via-black/35 to-transparent transition-opacity",
|
|
||||||
playing && !seeking
|
|
||||||
? "opacity-0 group-hover:opacity-100"
|
|
||||||
: "opacity-100",
|
|
||||||
)}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
>
|
|
||||||
{/* 进度条 · 可点击 / 拖拽 */}
|
|
||||||
<div
|
|
||||||
ref={progressRef}
|
|
||||||
onMouseDown={handleBarMouseDown}
|
|
||||||
onTouchStart={handleBarTouchStart}
|
|
||||||
className="relative h-1.5 rounded-full bg-white/15 cursor-pointer group/bar"
|
|
||||||
role="slider"
|
|
||||||
aria-label="视频进度"
|
|
||||||
aria-valuemin={0}
|
|
||||||
aria-valuemax={duration || 0}
|
|
||||||
aria-valuenow={current}
|
|
||||||
>
|
>
|
||||||
<div
|
<Pause size={20} />
|
||||||
className="absolute inset-y-0 left-0 rounded-full bg-purple-400 shadow-[0_0_10px_rgba(167,139,250,0.6)]"
|
</button>
|
||||||
style={{ width: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className="absolute top-1/2 -translate-y-1/2 -translate-x-1/2 w-3.5 h-3.5 rounded-full bg-white shadow-[0_0_8px_rgba(255,255,255,0.7)] opacity-0 group-hover/bar:opacity-100 transition-opacity"
|
|
||||||
style={{ left: `${progress}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-2.5 flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={togglePlay}
|
|
||||||
aria-label={playing ? "暂停" : "播放"}
|
|
||||||
className="text-white/85 hover:text-white"
|
|
||||||
>
|
|
||||||
{playing ? <Pause size={16} /> : <Play size={16} fill="currentColor" />}
|
|
||||||
</button>
|
|
||||||
<span className="font-display text-[11px] text-white/75 tabular-nums">
|
|
||||||
{fmtTime(current)} / {fmtTime(duration)}
|
|
||||||
</span>
|
|
||||||
<div className="ml-auto flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={toggleMute}
|
|
||||||
aria-label={muted ? "取消静音" : "静音"}
|
|
||||||
className="text-white/85 hover:text-white"
|
|
||||||
>
|
|
||||||
{muted ? <VolumeX size={14} /> : <Volume2 size={14} />}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={goFullscreen}
|
|
||||||
aria-label="全屏"
|
|
||||||
className="text-white/85 hover:text-white"
|
|
||||||
>
|
|
||||||
<Maximize2 size={14} />
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 右下:音量 / 全屏 */}
|
||||||
|
<div className="absolute bottom-3 left-3 flex items-center gap-1.5 z-10 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={toggleMute}
|
||||||
|
aria-label={muted ? "取消静音" : "静音"}
|
||||||
|
className="w-7 h-7 rounded-full bg-black/55 backdrop-blur-md flex items-center justify-center text-white/85 hover:text-white"
|
||||||
|
>
|
||||||
|
{muted ? <VolumeX size={12} /> : <Volume2 size={12} />}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={goFullscreen}
|
||||||
|
aria-label="全屏"
|
||||||
|
className="w-7 h-7 rounded-full bg-black/55 backdrop-blur-md flex items-center justify-center text-white/85 hover:text-white"
|
||||||
|
>
|
||||||
|
<Maximize2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,6 @@
|
|||||||
import type { Artist } from "@/types/artist";
|
import type { Artist } from "@/types/artist";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
function formatVotes(v: number): string {
|
|
||||||
if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`;
|
|
||||||
return v.toLocaleString();
|
|
||||||
}
|
|
||||||
|
|
||||||
interface RankCardProps {
|
interface RankCardProps {
|
||||||
artist: Artist;
|
artist: Artist;
|
||||||
/** 全榜单(用于计算与上下名的差距) */
|
/** 全榜单(用于计算与上下名的差距) */
|
||||||
@ -23,9 +18,8 @@ export default function RankCard({ artist, allArtists, className }: RankCardProp
|
|||||||
const leadOver = next ? artist.votes - next.votes : null;
|
const leadOver = next ? artist.votes - next.votes : null;
|
||||||
const trailBehind = prev ? prev.votes - artist.votes : null;
|
const trailBehind = prev ? prev.votes - artist.votes : null;
|
||||||
|
|
||||||
// 0 票时即便 rank=1 也不是真的"冠军",按编号兜底而已
|
const isFirst = artist.rank === 1;
|
||||||
const isFirst = artist.rank === 1 && artist.votes > 0;
|
const inTop12 = artist.rank <= 12;
|
||||||
const inTop12 = artist.rank <= 12 && artist.votes > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -49,7 +43,7 @@ export default function RankCard({ artist, allArtists, className }: RankCardProp
|
|||||||
#{artist.rank}
|
#{artist.rank}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-xs text-white/45 pb-1 tabular-nums">
|
<span className="text-xs text-white/45 pb-1 tabular-nums">
|
||||||
{formatVotes(artist.votes)} 票
|
{(artist.votes / 10000).toFixed(1)}w 票
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{inTop12 && (
|
{inTop12 && (
|
||||||
@ -59,9 +53,9 @@ export default function RankCard({ artist, allArtists, className }: RankCardProp
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 差距信息 · 仅在艺人本身有票时显示 */}
|
{/* 差距信息 */}
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
{artist.votes > 0 && isFirst && leadOver != null ? (
|
{isFirst && leadOver != null ? (
|
||||||
<>
|
<>
|
||||||
<div className="font-label text-[10px] tracking-widest uppercase text-white/40 mb-1">
|
<div className="font-label text-[10px] tracking-widest uppercase text-white/40 mb-1">
|
||||||
领先第二名
|
领先第二名
|
||||||
@ -71,7 +65,7 @@ export default function RankCard({ artist, allArtists, className }: RankCardProp
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-[10px] text-white/40">票</div>
|
<div className="text-[10px] text-white/40">票</div>
|
||||||
</>
|
</>
|
||||||
) : artist.votes > 0 && trailBehind != null ? (
|
) : trailBehind != null ? (
|
||||||
<>
|
<>
|
||||||
<div className="font-label text-[10px] tracking-widest uppercase text-white/40 mb-1">
|
<div className="font-label text-[10px] tracking-widest uppercase text-white/40 mb-1">
|
||||||
距上一名
|
距上一名
|
||||||
|
|||||||
@ -39,13 +39,13 @@ export default function AuthMenu() {
|
|||||||
const user = session?.user;
|
const user = session?.user;
|
||||||
const initial = user?.name?.charAt(0).toUpperCase() ?? "?";
|
const initial = user?.name?.charAt(0).toUpperCase() ?? "?";
|
||||||
|
|
||||||
// 未登录态:与登录后保持一致的紫色实心胶囊
|
// 未登录态:紫色描边胶囊按钮
|
||||||
if (status !== "authenticated") {
|
if (status !== "authenticated") {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => status !== "loading" && openLogin()}
|
onClick={() => status !== "loading" && openLogin()}
|
||||||
className="font-display text-[10px] sm:text-xs tracking-[0.2em] uppercase px-4 sm:px-5 h-9 inline-flex items-center justify-center rounded-full bg-grad-purple text-white shadow-[0_0_14px_rgba(139,92,246,0.45)] ring-1 ring-inset ring-white/15 hover:brightness-110 active:brightness-95 transition-all"
|
className="font-display text-[10px] sm:text-xs tracking-[0.2em] uppercase px-4 sm:px-5 h-9 inline-flex items-center justify-center rounded-full border border-[var(--border-purple)] text-purple-300 hover:bg-purple-500/10 hover:shadow-[0_0_20px_rgba(139,92,246,0.3)] transition-all"
|
||||||
>
|
>
|
||||||
登录 / 注册
|
登录 / 注册
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import { AnimatePresence, motion } from "framer-motion";
|
|||||||
import { signIn } from "next-auth/react";
|
import { signIn } from "next-auth/react";
|
||||||
import { X, Phone, KeyRound, Loader2 } from "lucide-react";
|
import { X, Phone, KeyRound, Loader2 } from "lucide-react";
|
||||||
import Button from "@/components/ui/Button";
|
import Button from "@/components/ui/Button";
|
||||||
|
import Logo from "@/components/Logo";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
interface LoginModalProps {
|
interface LoginModalProps {
|
||||||
@ -108,14 +109,7 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
|
|||||||
redirect: false,
|
redirect: false,
|
||||||
});
|
});
|
||||||
if (result?.error) {
|
if (result?.error) {
|
||||||
console.error("[login] signIn 返回错误:", result);
|
setError("验证码错误或已失效");
|
||||||
// 把 NextAuth 真实错误透出来,避免被"验证码错误或已失效"一刀切掩盖
|
|
||||||
// (例如 server config 错误时,会显示 Configuration 而不是误导成验证码问题)
|
|
||||||
setError(
|
|
||||||
result.error === "CredentialsSignin"
|
|
||||||
? "验证码错误或已失效"
|
|
||||||
: `登录失败:${result.error}`,
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
onClose();
|
onClose();
|
||||||
if (onSuccess) onSuccess();
|
if (onSuccess) onSuccess();
|
||||||
@ -171,15 +165,10 @@ export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps
|
|||||||
<X size={18} />
|
<X size={18} />
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div className="flex flex-col items-center mb-6">
|
<div className="text-center mb-6">
|
||||||
<img
|
<div className="inline-block">
|
||||||
src="/logo-v4.png?v=4"
|
<Logo size="md" href={null} />
|
||||||
alt="银河初星计划 C . S . G"
|
</div>
|
||||||
decoding="async"
|
|
||||||
draggable={false}
|
|
||||||
className="block select-none h-16 sm:h-20 w-auto"
|
|
||||||
style={{ background: "transparent" }}
|
|
||||||
/>
|
|
||||||
<p
|
<p
|
||||||
id="login-modal-title"
|
id="login-modal-title"
|
||||||
className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3"
|
className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3"
|
||||||
|
|||||||
@ -1,35 +1,33 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useSession } from "next-auth/react";
|
import { useSession } from "next-auth/react";
|
||||||
import { useVoteStore, selectRemaining, TOTAL_VOTE_QUOTA } from "@/lib/store";
|
import { useVoteStore, selectRemaining, DAILY_VOTE_QUOTA } from "@/lib/store";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 导航栏的"剩余票数"徽章。
|
* 导航栏右侧的"今日剩余票数"徽章。
|
||||||
* - 始终显示(位于 AuthMenu 左侧)
|
* - 仅登录态显示
|
||||||
* - 未登录:数值固定为 0
|
* - 实时从 vote store 取剩余票
|
||||||
* - 已登录:实时从 vote store 取剩余票
|
* - 视觉上是 **填充紫渐变胶囊**,与 AuthMenu 的描边/头像胶囊明显区分(信息 vs 操作)
|
||||||
* - 视觉为中性轻盈胶囊,与 AuthMenu 的紫色实心胶囊明显区分(信息 vs 操作)
|
|
||||||
*/
|
*/
|
||||||
export default function RemainingVotesBadge() {
|
export default function RemainingVotesBadge() {
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const storeRemaining = useVoteStore(selectRemaining);
|
const remaining = useVoteStore(selectRemaining);
|
||||||
const authed = status === "authenticated";
|
|
||||||
// 未登录显示 0 / 12 引导登录后投票;登录后实时剩余
|
if (status !== "authenticated") return null;
|
||||||
const remaining = authed ? storeRemaining : 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="hidden md:inline-flex items-center gap-1.5 h-9 px-4 rounded-full bg-white/[0.04] border border-white/10"
|
className="hidden md:inline-flex items-center gap-1.5 h-9 px-4 rounded-full bg-white/[0.04] border border-white/10"
|
||||||
aria-label={`剩余 ${remaining} 票`}
|
aria-label={`今日剩余 ${remaining} 票`}
|
||||||
>
|
>
|
||||||
<span className="text-[11px] text-white/75 leading-none tracking-wide">
|
<span className="text-[11px] text-white/75 leading-none tracking-wide">
|
||||||
剩余
|
今日剩余
|
||||||
</span>
|
</span>
|
||||||
<span className="font-display text-sm text-purple-300 tabular-nums leading-none">
|
<span className="font-display text-sm text-purple-300 tabular-nums leading-none">
|
||||||
{remaining}
|
{remaining}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[11px] text-white/45 leading-none">
|
<span className="text-[11px] text-white/45 leading-none">
|
||||||
/ {TOTAL_VOTE_QUOTA}
|
/ {DAILY_VOTE_QUOTA}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,8 +1,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Heart, Check } from "lucide-react";
|
import { Heart } from "lucide-react";
|
||||||
import type { Artist } from "@/types/artist";
|
import type { Artist } from "@/types/artist";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { useVoteStore, selectHasVoted } from "@/lib/store";
|
|
||||||
import ArtistPortrait from "./ArtistPortrait";
|
import ArtistPortrait from "./ArtistPortrait";
|
||||||
|
|
||||||
interface ArtistCardProps {
|
interface ArtistCardProps {
|
||||||
@ -22,15 +21,13 @@ export default function ArtistCard({
|
|||||||
onVote,
|
onVote,
|
||||||
className,
|
className,
|
||||||
}: ArtistCardProps) {
|
}: ArtistCardProps) {
|
||||||
// 「真正进 Top12」必须有票 —— 0 票时编号兜底排出来的前 12 不算
|
const inTop12 = artist.rank <= 12;
|
||||||
const inTop12 = artist.rank <= 12 && artist.votes > 0;
|
|
||||||
const hasVoted = useVoteStore(selectHasVoted(artist.id));
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"group relative rounded-xl overflow-hidden bg-grad-card border transition-all",
|
"group relative rounded-xl overflow-hidden bg-grad-card border transition-all",
|
||||||
(hasVoted || inTop12)
|
inTop12
|
||||||
? "border-purple-500/55 shadow-[0_8px_32px_rgba(0,0,0,0.65),0_0_24px_rgba(139,92,246,0.25)]"
|
? "border-purple-500/55 shadow-[0_8px_32px_rgba(0,0,0,0.65),0_0_24px_rgba(139,92,246,0.25)]"
|
||||||
: "border-white/[0.10] shadow-[0_8px_32px_rgba(0,0,0,0.65)]",
|
: "border-white/[0.10] shadow-[0_8px_32px_rgba(0,0,0,0.65)]",
|
||||||
"hover:-translate-y-1 hover:shadow-[0_12px_36px_rgba(0,0,0,0.7),0_0_24px_rgba(139,92,246,0.25)]",
|
"hover:-translate-y-1 hover:shadow-[0_12px_36px_rgba(0,0,0,0.7),0_0_24px_rgba(139,92,246,0.25)]",
|
||||||
@ -42,8 +39,8 @@ export default function ArtistCard({
|
|||||||
className="block"
|
className="block"
|
||||||
aria-label={`查看 ${artist.name} 详情`}
|
aria-label={`查看 ${artist.name} 详情`}
|
||||||
>
|
>
|
||||||
{/* 立绘区 · Top12 区分仅靠紫色边框 + 辉光,不再降低非 Top12 卡片亮度 */}
|
{/* 立绘区(13+ 卡片轻度暗化) */}
|
||||||
<div className="relative aspect-[4/5]">
|
<div className={cn("relative aspect-[4/5]", !inTop12 && "opacity-[0.78]")}>
|
||||||
<ArtistPortrait
|
<ArtistPortrait
|
||||||
artist={artist}
|
artist={artist}
|
||||||
rounded="rounded-none"
|
rounded="rounded-none"
|
||||||
@ -62,12 +59,8 @@ export default function ArtistCard({
|
|||||||
{artist.rank}
|
{artist.rank}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 已投票角标(右上紫色 ✓) */}
|
{/* 顶部轻微渐变蒙层 */}
|
||||||
{hasVoted && (
|
<div className="absolute inset-x-0 top-0 h-12 bg-gradient-to-b from-black/40 to-transparent pointer-events-none" />
|
||||||
<div className="absolute top-2 right-2 w-6 h-6 rounded-full bg-purple-500 shadow-[0_0_12px_rgba(139,92,246,0.7)] flex items-center justify-center">
|
|
||||||
<Check size={14} strokeWidth={3} className="text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 信息区(黑色背景明显分隔) */}
|
{/* 信息区(黑色背景明显分隔) */}
|
||||||
@ -79,7 +72,7 @@ export default function ArtistCard({
|
|||||||
{artist.name}
|
{artist.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-white/55 truncate mt-0.5">
|
<div className="text-[11px] text-white/55 truncate mt-0.5">
|
||||||
{artist.enName}
|
{artist.slogan}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
@ -93,7 +86,7 @@ export default function ArtistCard({
|
|||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* 投票按钮(hasVoted 时灰化为「已投票」) */}
|
{/* 投票按钮(所有排名统一样式 · 紫色实心) */}
|
||||||
<div className="px-3 pb-3 bg-black/40">
|
<div className="px-3 pb-3 bg-black/40">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@ -103,12 +96,11 @@ export default function ArtistCard({
|
|||||||
}}
|
}}
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-full h-9 rounded-lg font-body font-semibold text-sm transition-all",
|
"w-full h-9 rounded-lg font-body font-semibold text-sm transition-all",
|
||||||
hasVoted
|
"bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)]",
|
||||||
? "bg-white/10 text-white/55 cursor-not-allowed border border-white/15"
|
"hover:brightness-110 active:brightness-95",
|
||||||
: "bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)] hover:brightness-110 active:brightness-95",
|
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{hasVoted ? "✓ 已投票" : "投票"}
|
投票
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -11,7 +11,7 @@ interface ArtistPortraitProps {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 艺人立绘容器。
|
* 艺人立绘容器。
|
||||||
* 有真实图时显示 Image;否则渲染统一品牌紫渐变占位(带英文首字母)。
|
* 有真实图时显示 Image;否则渲染基于 themeColor 的渐变占位(带首字母)。
|
||||||
*/
|
*/
|
||||||
export default function ArtistPortrait({
|
export default function ArtistPortrait({
|
||||||
artist,
|
artist,
|
||||||
@ -26,35 +26,45 @@ export default function ArtistPortrait({
|
|||||||
if (artist.portrait) {
|
if (artist.portrait) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn("relative overflow-hidden bg-deep", rounded, className)}
|
className={cn(
|
||||||
|
"relative overflow-hidden bg-deep",
|
||||||
|
rounded,
|
||||||
|
className,
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={artist.portrait}
|
src={artist.portrait}
|
||||||
alt={`${artist.name} · ${artist.enName}`}
|
alt={`${artist.name} · ${artist.enName}`}
|
||||||
fill
|
fill
|
||||||
sizes="(max-width: 768px) 50vw, 360px"
|
sizes="(max-width: 768px) 50vw, 240px"
|
||||||
// 氛围图大多为全身竖向构图,用 object-cover + object-top
|
className="object-cover"
|
||||||
// 让头部 + 上半身保留在画面里,下半身(腿/脚)自然超出底边被剪裁
|
|
||||||
className="object-cover object-top"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 无立绘 fallback:品牌紫渐变 + 字母占位
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative overflow-hidden flex items-center justify-center",
|
"relative overflow-hidden flex items-center justify-center",
|
||||||
"bg-[linear-gradient(155deg,rgba(139,92,246,0.20)_0%,#1a1638_60%,#0d0a24_100%)]",
|
|
||||||
rounded,
|
rounded,
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(155deg, ${artist.themeColor}33 0%, #1a1638 60%, #0d0a24 100%)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
|
{/* 装饰光晕 */}
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
className="absolute inset-0"
|
||||||
className="absolute inset-0 bg-[radial-gradient(circle_at_50%_30%,rgba(139,92,246,0.35)_0%,transparent_55%)]"
|
style={{
|
||||||
|
background: `radial-gradient(circle at 50% 30%, ${artist.themeColor}55 0%, transparent 55%)`,
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* 装饰星标 */}
|
||||||
|
<span className="absolute top-2 right-2 text-white/15 text-sm">✦</span>
|
||||||
|
<span className="absolute bottom-3 left-3 text-white/10 text-xs">✧</span>
|
||||||
|
{/* 首字母 */}
|
||||||
<span
|
<span
|
||||||
className="font-logo text-5xl text-white/85 glow-text-purple tracking-wider relative z-10"
|
className="font-logo text-5xl text-white/85 glow-text-purple tracking-wider relative z-10"
|
||||||
aria-hidden
|
aria-hidden
|
||||||
|
|||||||
@ -1,16 +1,16 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Check, AlertTriangle } from "lucide-react";
|
import { Heart, AlertTriangle } from "lucide-react";
|
||||||
import type { MySupport } from "@/lib/store";
|
import type { FanSupport } from "@/lib/mock-user";
|
||||||
import ArtistPortrait from "@/components/cards/ArtistPortrait";
|
import ArtistPortrait from "@/components/cards/ArtistPortrait";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
export default function MyFanSupport({ supports }: { supports: MySupport[] }) {
|
export default function MyFanSupport({ supports }: { supports: FanSupport[] }) {
|
||||||
if (supports.length === 0) {
|
if (supports.length === 0) {
|
||||||
return (
|
return (
|
||||||
<div className="rounded-xl border border-dashed border-white/10 p-8 text-center text-white/45 text-sm">
|
<div className="rounded-xl border border-dashed border-white/10 p-8 text-center text-white/45 text-sm">
|
||||||
还没有投过票 ·{" "}
|
还没有应援的艺人 ·{" "}
|
||||||
<Link href="/" className="text-purple-300 hover:underline">
|
<Link href="/" className="text-purple-300 hover:underline">
|
||||||
去为你喜欢的艺人投出第一票
|
去发现
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@ -18,8 +18,8 @@ export default function MyFanSupport({ supports }: { supports: MySupport[] }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||||
{supports.map(({ artist }) => {
|
{supports.map(({ artist, votedCount }) => {
|
||||||
const inTop12 = artist.rank <= 12 && artist.votes > 0;
|
const inTop12 = artist.rank <= 12;
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={artist.id}
|
key={artist.id}
|
||||||
@ -46,9 +46,9 @@ export default function MyFanSupport({ supports }: { supports: MySupport[] }) {
|
|||||||
<div className="text-sm font-semibold text-white truncate">
|
<div className="text-sm font-semibold text-white truncate">
|
||||||
{artist.name}
|
{artist.name}
|
||||||
</div>
|
</div>
|
||||||
<div className="inline-flex items-center gap-1 text-[11px] text-purple-300 mt-0.5 font-display">
|
<div className="inline-flex items-center gap-1 text-[11px] text-purple-300 mt-0.5 font-display tabular-nums">
|
||||||
<Check size={11} strokeWidth={2.5} />
|
<Heart size={10} fill="currentColor" />
|
||||||
已投票
|
已投 {votedCount} 票
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|||||||
@ -1,18 +1,25 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
|
import { UserPlus } from "lucide-react";
|
||||||
|
|
||||||
interface QuotaCardProps {
|
interface QuotaCardProps {
|
||||||
/** 剩余票数 */
|
/** 今日剩余票数 */
|
||||||
remaining: number;
|
remaining: number;
|
||||||
/** 总额度 12(用于"X / 12 票" 展示) */
|
/** 每日总额度(用于「重置为 X 票」展示) */
|
||||||
totalQuota: number;
|
dailyQuota: number;
|
||||||
|
/** 我累计投出的票数(保留 prop 以兼容现有调用方,组件内不直接展示) */
|
||||||
|
cumulative?: number;
|
||||||
|
onInvite?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function QuotaCard({ remaining, totalQuota }: QuotaCardProps) {
|
export default function QuotaCard({
|
||||||
const exhausted = remaining === 0;
|
remaining,
|
||||||
|
dailyQuota,
|
||||||
|
onInvite,
|
||||||
|
}: QuotaCardProps) {
|
||||||
return (
|
return (
|
||||||
<div className="relative overflow-hidden rounded-2xl border border-purple-500/30 bg-gradient-to-br from-purple-500/[0.12] via-purple-500/[0.04] to-transparent shadow-[0_8px_32px_rgba(0,0,0,0.55),0_0_28px_rgba(139,92,246,0.2)]">
|
<div className="relative overflow-hidden rounded-2xl border border-purple-500/30 bg-gradient-to-br from-purple-500/[0.12] via-purple-500/[0.04] to-transparent shadow-[0_8px_32px_rgba(0,0,0,0.55),0_0_28px_rgba(139,92,246,0.2)]">
|
||||||
{/* 装饰:右侧紫色光晕 */}
|
{/* 装饰:右侧紫色光晕 */}
|
||||||
<div
|
<div
|
||||||
aria-hidden
|
aria-hidden
|
||||||
className="absolute right-0 top-0 bottom-0 w-1/2 pointer-events-none"
|
className="absolute right-0 top-0 bottom-0 w-1/2 pointer-events-none"
|
||||||
@ -21,27 +28,46 @@ export default function QuotaCard({ remaining, totalQuota }: QuotaCardProps) {
|
|||||||
"radial-gradient(circle at 70% 50%, rgba(139,92,246,0.45) 0%, transparent 60%)",
|
"radial-gradient(circle at 70% 50%, rgba(139,92,246,0.45) 0%, transparent 60%)",
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
{/* 装饰:右侧"水晶"占位(无素材时用 CSS 渲染的辉光六边形) */}
|
||||||
<CrystalDecoration />
|
<CrystalDecoration />
|
||||||
|
|
||||||
<div className="relative p-6 sm:p-8">
|
<div className="relative p-6 sm:p-8 grid grid-cols-[1fr_auto] gap-4 items-center">
|
||||||
<p className="text-xs text-white/70 tracking-wider">剩余票数</p>
|
<div>
|
||||||
<div className="mt-2 flex items-baseline gap-1">
|
<p className="text-xs text-white/70 tracking-wider">今日剩余票数</p>
|
||||||
<span className="font-display text-5xl sm:text-6xl text-white tabular-nums leading-none">
|
<div className="mt-2 flex items-baseline gap-1">
|
||||||
{remaining}
|
<span className="font-display text-5xl sm:text-6xl text-white tabular-nums leading-none">
|
||||||
</span>
|
{remaining}
|
||||||
<span className="text-xl text-white/85 ml-1">/ {totalQuota} 票</span>
|
</span>
|
||||||
|
<span className="text-xl text-white/85 ml-1">票</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-[11px] text-white/55 mt-3">
|
||||||
|
明日 00:00 重置为{" "}
|
||||||
|
<span className="text-purple-300 font-display tabular-nums">
|
||||||
|
{dailyQuota}
|
||||||
|
</span>{" "}
|
||||||
|
票
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<span className="font-label text-[10px] tracking-widest uppercase text-white/55">
|
||||||
|
获取更多票数
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onInvite}
|
||||||
|
className="inline-flex items-center gap-2 px-4 sm:px-5 h-10 rounded-full bg-grad-purple text-white font-body text-sm shadow-[0_0_14px_rgba(139,92,246,0.45)] hover:brightness-110 transition-all"
|
||||||
|
>
|
||||||
|
<UserPlus size={14} />
|
||||||
|
邀请好友得票
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-white/55 mt-3">
|
|
||||||
{exhausted
|
|
||||||
? "✦ 12 票全部投出 · 感谢支持"
|
|
||||||
: `共 ${totalQuota} 票 · 用满完成投票`}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 右侧装饰:用 CSS 渲染的简易"紫色水晶"效果,作为 3D 素材到位前的占位。 */
|
/** 右侧装饰:用 CSS 渲染的简易"紫色水晶"效果,作为 3D 素材到位前的占位。 */
|
||||||
function CrystalDecoration() {
|
function CrystalDecoration() {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|||||||
@ -1,30 +1,38 @@
|
|||||||
import { Check, Heart } from "lucide-react";
|
import { Sparkles, Star, Calendar, UserPlus } from "lucide-react";
|
||||||
|
import type { MockUser } from "@/lib/mock-user";
|
||||||
|
|
||||||
interface StatsGridProps {
|
const ICON_MAP = {
|
||||||
/** 已投艺人数 */
|
votes: <Sparkles size={14} />,
|
||||||
voted: number;
|
fan: <Star size={14} />,
|
||||||
/** 剩余票数 */
|
signin: <Calendar size={14} />,
|
||||||
remaining: number;
|
invite: <UserPlus size={14} />,
|
||||||
}
|
};
|
||||||
|
|
||||||
export default function StatsGrid({ voted, remaining }: StatsGridProps) {
|
export default function StatsGrid({ user }: { user: MockUser }) {
|
||||||
const stats: { key: string; label: string; value: number; icon: React.ReactNode }[] = [
|
const stats = [
|
||||||
|
{ key: "votes", label: "累计投票", value: user.totalVotes, icon: ICON_MAP.votes },
|
||||||
{
|
{
|
||||||
key: "voted",
|
key: "fan",
|
||||||
label: "已投票数",
|
label: "应援艺人",
|
||||||
value: voted,
|
value: user.supportingIds.length,
|
||||||
icon: <Check size={14} strokeWidth={2.5} />,
|
icon: ICON_MAP.fan,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "remaining",
|
key: "signin",
|
||||||
label: "剩余票数",
|
label: "签到天数",
|
||||||
value: remaining,
|
value: user.signInStreak,
|
||||||
icon: <Heart size={14} fill="currentColor" />,
|
icon: ICON_MAP.signin,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: "invite",
|
||||||
|
label: "邀请好友",
|
||||||
|
value: user.invitedCount,
|
||||||
|
icon: ICON_MAP.invite,
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-2 gap-2 sm:gap-3">
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
|
||||||
{stats.map((s) => (
|
{stats.map((s) => (
|
||||||
<div
|
<div
|
||||||
key={s.key}
|
key={s.key}
|
||||||
|
|||||||
@ -1,24 +1,20 @@
|
|||||||
import { LogOut } from "lucide-react";
|
import { Pencil, Star, LogOut } from "lucide-react";
|
||||||
|
import type { MockUser } from "@/lib/mock-user";
|
||||||
|
|
||||||
interface UserHeaderProps {
|
interface UserHeaderProps {
|
||||||
/** 用户昵称(来自 session) */
|
user: MockUser;
|
||||||
nickname: string;
|
|
||||||
/** 用户 ID(来自 session) */
|
|
||||||
userId: string;
|
|
||||||
onLogout?: () => void;
|
onLogout?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function UserHeader({
|
export default function UserHeader({ user, onLogout }: UserHeaderProps) {
|
||||||
nickname,
|
const initial = user.nickname.charAt(0).toUpperCase();
|
||||||
userId,
|
// 简单的等级算法:每 50 票升 1 级,从 1 起步
|
||||||
onLogout,
|
const level = Math.max(1, Math.floor(user.totalVotes / 50) + 1);
|
||||||
}: UserHeaderProps) {
|
|
||||||
const initial = nickname.charAt(0).toUpperCase();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
{/* 头像 */}
|
{/* 头像 + 等级角标 */}
|
||||||
<div className="flex-shrink-0">
|
<div className="relative flex-shrink-0">
|
||||||
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-full overflow-hidden border-2 border-purple-500/60 shadow-[0_0_16px_rgba(139,92,246,0.4)]">
|
<div className="w-16 h-16 sm:w-20 sm:h-20 rounded-full overflow-hidden border-2 border-purple-500/60 shadow-[0_0_16px_rgba(139,92,246,0.4)]">
|
||||||
<div
|
<div
|
||||||
className="w-full h-full flex items-center justify-center font-logo text-2xl text-white"
|
className="w-full h-full flex items-center justify-center font-logo text-2xl text-white"
|
||||||
@ -30,27 +26,46 @@ export default function UserHeader({
|
|||||||
{initial}
|
{initial}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<span className="absolute -bottom-1 -right-1 inline-flex items-center gap-0.5 px-1.5 h-5 rounded-full bg-purple-500 text-white text-[10px] font-display border-2 border-deep">
|
||||||
|
<Star size={9} fill="currentColor" />
|
||||||
|
Lv.{level}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="text-lg sm:text-xl font-bold text-white truncate">
|
<div className="text-lg sm:text-xl font-bold text-white truncate">
|
||||||
{nickname}
|
{user.nickname}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-white/55 mt-1.5">
|
||||||
|
ID: {user.id}
|
||||||
|
<span className="mx-2 text-white/25">·</span>
|
||||||
|
已连续签到{" "}
|
||||||
|
<span className="text-purple-300 font-display tabular-nums">
|
||||||
|
{user.signInStreak}
|
||||||
|
</span>{" "}
|
||||||
|
天
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-white/55 mt-1.5">ID: {userId}</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 退出登录 · 粉色危险态描边(移动端 = icon-only 圆按钮,桌面端 = 带文案胶囊) */}
|
<div className="hidden sm:flex flex-col items-stretch gap-2">
|
||||||
{onLogout && (
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onLogout}
|
className="inline-flex items-center justify-center gap-1.5 px-3 h-9 rounded-full bg-white/[0.04] border border-white/15 text-white/75 hover:bg-white/10 text-xs transition-colors"
|
||||||
aria-label="退出登录"
|
|
||||||
className="inline-flex items-center justify-center gap-1.5 h-9 rounded-full bg-transparent border border-pink-500/40 text-pink-300 hover:bg-pink-500/[0.08] hover:text-pink-200 hover:border-pink-500/60 text-xs transition-colors w-9 sm:w-auto sm:px-3"
|
|
||||||
>
|
>
|
||||||
<LogOut size={12} />
|
<Pencil size={12} />
|
||||||
<span className="hidden sm:inline">退出登录</span>
|
编辑资料
|
||||||
</button>
|
</button>
|
||||||
)}
|
{onLogout && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onLogout}
|
||||||
|
className="inline-flex items-center justify-center gap-1.5 px-3 h-9 rounded-full bg-white/[0.04] border border-white/10 text-white/55 hover:text-pink-400 hover:border-pink-500/40 text-xs transition-colors"
|
||||||
|
>
|
||||||
|
<LogOut size={12} />
|
||||||
|
退出登录
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,11 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { TrendingUp, AlertTriangle, Check } from "lucide-react";
|
import { TrendingUp, AlertTriangle } from "lucide-react";
|
||||||
import type { Artist } from "@/types/artist";
|
import type { Artist } from "@/types/artist";
|
||||||
import ArtistPortrait from "@/components/cards/ArtistPortrait";
|
import ArtistPortrait from "@/components/cards/ArtistPortrait";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
import { useVoteStore, selectHasVoted } from "@/lib/store";
|
|
||||||
|
|
||||||
interface RankingRowProps {
|
interface RankingRowProps {
|
||||||
artist: Artist;
|
artist: Artist;
|
||||||
@ -30,9 +29,7 @@ export default function RankingRow({
|
|||||||
isRescue = false,
|
isRescue = false,
|
||||||
onVote,
|
onVote,
|
||||||
}: RankingRowProps) {
|
}: RankingRowProps) {
|
||||||
const hasVoted = useVoteStore(selectHasVoted(artist.id));
|
const inTop12 = artist.rank <= 12;
|
||||||
// 「真正进 Top12」必须有票 —— 0 票时编号兜底不算
|
|
||||||
const inTop12 = artist.rank <= 12 && artist.votes > 0;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
@ -40,7 +37,7 @@ export default function RankingRow({
|
|||||||
"grid grid-cols-[56px_48px_1fr_72px_96px_72px] sm:grid-cols-[72px_56px_1fr_96px_120px_88px] items-center gap-2 sm:gap-4 px-3 sm:px-4 py-2.5 border-b border-white/[0.05] transition-all",
|
"grid grid-cols-[56px_48px_1fr_72px_96px_72px] sm:grid-cols-[72px_56px_1fr_96px_120px_88px] items-center gap-2 sm:gap-4 px-3 sm:px-4 py-2.5 border-b border-white/[0.05] transition-all",
|
||||||
inTop12
|
inTop12
|
||||||
? "bg-white/[0.02] hover:bg-purple-500/[0.06]"
|
? "bg-white/[0.02] hover:bg-purple-500/[0.06]"
|
||||||
: "hover:bg-white/[0.03]",
|
: "opacity-[0.78] hover:opacity-100 hover:bg-white/[0.03]",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{/* 排名 */}
|
{/* 排名 */}
|
||||||
@ -54,11 +51,11 @@ export default function RankingRow({
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 头像 */}
|
{/* 头像 */}
|
||||||
<Link href={`/artist/${artist.id}`} className="block relative">
|
<Link href={`/artist/${artist.id}`} className="block">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden border-2",
|
"w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden border-2",
|
||||||
(hasVoted || inTop12) ? "border-purple-500/70" : "border-white/15",
|
inTop12 ? "border-purple-500/70" : "border-white/15",
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<ArtistPortrait
|
<ArtistPortrait
|
||||||
@ -67,11 +64,6 @@ export default function RankingRow({
|
|||||||
className="w-full h-full"
|
className="w-full h-full"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{hasVoted && (
|
|
||||||
<div className="absolute -bottom-0.5 -right-0.5 w-4 h-4 sm:w-5 sm:h-5 rounded-full bg-purple-500 border-2 border-deepest shadow-[0_0_8px_rgba(139,92,246,0.7)] flex items-center justify-center">
|
|
||||||
<Check size={10} strokeWidth={3} className="text-white" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* 姓名 + slogan */}
|
{/* 姓名 + slogan */}
|
||||||
@ -82,7 +74,7 @@ export default function RankingRow({
|
|||||||
<div className="text-sm text-white font-semibold truncate">
|
<div className="text-sm text-white font-semibold truncate">
|
||||||
{artist.name}
|
{artist.name}
|
||||||
<span className="ml-2 text-white/45 font-normal text-[11px]">
|
<span className="ml-2 text-white/45 font-normal text-[11px]">
|
||||||
· {artist.enName}
|
· {artist.slogan}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
@ -120,14 +112,9 @@ export default function RankingRow({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onVote(artist)}
|
onClick={() => onVote(artist)}
|
||||||
className={cn(
|
className="h-8 rounded-lg font-body font-semibold text-xs bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)] hover:brightness-110"
|
||||||
"h-8 rounded-lg font-body font-semibold text-xs transition-all",
|
|
||||||
hasVoted
|
|
||||||
? "bg-white/10 text-white/55 border border-white/15 cursor-not-allowed"
|
|
||||||
: "bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)] hover:brightness-110"
|
|
||||||
)}
|
|
||||||
>
|
>
|
||||||
{hasVoted ? "✓ 已投票" : "投票"}
|
投票
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import { Crown } from "lucide-react";
|
||||||
import type { Artist } from "@/types/artist";
|
import type { Artist } from "@/types/artist";
|
||||||
import ArtistPortrait from "@/components/cards/ArtistPortrait";
|
import ArtistPortrait from "@/components/cards/ArtistPortrait";
|
||||||
import { cn } from "@/lib/cn";
|
import { cn } from "@/lib/cn";
|
||||||
@ -14,101 +15,73 @@ function formatVotes(v: number): string {
|
|||||||
|
|
||||||
export default function Top3Podium({ top3 }: Top3PodiumProps) {
|
export default function Top3Podium({ top3 }: Top3PodiumProps) {
|
||||||
const [first, second, third] = top3;
|
const [first, second, third] = top3;
|
||||||
|
if (!first || !second || !third) return null;
|
||||||
|
|
||||||
// 0 票时按编号兜底排出来的"伪冠军"不算 —— 这些位置当作缺位处理
|
// 视觉排序:第二 / 第一(中央) / 第三
|
||||||
const champ = first && first.votes > 0 ? first : undefined;
|
const order: Array<{ artist: Artist; rank: 1 | 2 | 3 }> = [
|
||||||
const runnerUp = second && second.votes > 0 ? second : undefined;
|
{ artist: second, rank: 2 },
|
||||||
const thirdPlace = third && third.votes > 0 ? third : undefined;
|
{ artist: first, rank: 1 },
|
||||||
|
{ artist: third, rank: 3 },
|
||||||
// 视觉顺序:第二 / 第一(中央) / 第三 · 缺位 = 虚位以待
|
|
||||||
const order: Array<{ artist: Artist | undefined; rank: 1 | 2 | 3 }> = [
|
|
||||||
{ artist: runnerUp, rank: 2 },
|
|
||||||
{ artist: champ, rank: 1 },
|
|
||||||
{ artist: thirdPlace, rank: 3 },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const lead = champ && runnerUp ? champ.votes - runnerUp.votes : null;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
// 冠军卡片更宽更高(1.3fr),亚季军等宽(1fr),三张底部对齐;
|
<div className="grid grid-cols-3 gap-3 sm:gap-5 items-end">
|
||||||
// 整个组合最大宽度受限并横向居中,避免在 1500 版心下占满铺张。
|
|
||||||
<div className="grid grid-cols-[1fr_1.3fr_1fr] items-end gap-8 sm:gap-12 pt-12 sm:pt-14 max-w-[860px] mx-auto">
|
|
||||||
{order.map(({ artist, rank }) => {
|
{order.map(({ artist, rank }) => {
|
||||||
if (!artist) {
|
|
||||||
return <EmptySlot key={`empty-${rank}`} rank={rank} />;
|
|
||||||
}
|
|
||||||
const isFirst = rank === 1;
|
const isFirst = rank === 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={artist.id}
|
key={artist.id}
|
||||||
href={`/artist/${artist.id}`}
|
href={`/artist/${artist.id}`}
|
||||||
className="group relative block"
|
className={cn(
|
||||||
|
"group relative flex flex-col items-center rounded-2xl border transition-all overflow-hidden",
|
||||||
|
"bg-gradient-to-b from-purple-500/[0.12] via-purple-500/[0.04] to-transparent",
|
||||||
|
"border-purple-500/40 hover:border-purple-400/60",
|
||||||
|
"shadow-[0_8px_32px_rgba(0,0,0,0.55),0_0_28px_rgba(139,92,246,0.25)]",
|
||||||
|
isFirst && "shadow-[0_8px_36px_rgba(0,0,0,0.65),0_0_42px_rgba(196,181,253,0.35)]",
|
||||||
|
isFirst ? "py-8 px-3 -translate-y-3" : "py-6 px-3",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{/* 顶部奖牌 SVG · 悬浮在卡片顶边上方 */}
|
{/* 顶部小皇冠(仅第一名) */}
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{isFirst && (
|
||||||
<img
|
<div className="absolute -top-2 left-1/2 -translate-x-1/2 text-purple-300">
|
||||||
src={`/rank-${rank}.svg`}
|
<Crown size={20} fill="currentColor" />
|
||||||
alt={`第 ${rank} 名`}
|
</div>
|
||||||
className={cn(
|
)}
|
||||||
"absolute left-1/2 -translate-x-1/2 z-20 select-none pointer-events-none drop-shadow-[0_4px_12px_rgba(0,0,0,0.5)]",
|
|
||||||
isFirst
|
|
||||||
? "w-16 sm:w-20 -top-9 sm:-top-11"
|
|
||||||
: "w-14 sm:w-16 -top-7 sm:-top-9",
|
|
||||||
)}
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 卡片容器(3:4 比例) · #1 金色渐变描边 + 金色辉光;#2 #3 紫色描边 */}
|
{/* 头像(圆形 + 紫色环) */}
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
"relative aspect-[3/4] rounded-xl overflow-hidden transition-all",
|
"rounded-full overflow-hidden border-2 mb-3",
|
||||||
isFirst
|
"border-purple-500 shadow-[0_0_18px_rgba(139,92,246,0.55)]",
|
||||||
? "p-[2px] shadow-[0_8px_36px_rgba(0,0,0,0.55),0_0_36px_rgba(255,200,120,0.20)]"
|
isFirst ? "w-24 h-24 sm:w-28 sm:h-28" : "w-20 h-20 sm:w-24 sm:h-24",
|
||||||
: "border border-purple-500/40 shadow-[0_8px_32px_rgba(0,0,0,0.55),0_0_24px_rgba(139,92,246,0.18)] hover:border-purple-400/60",
|
|
||||||
)}
|
)}
|
||||||
style={
|
|
||||||
isFirst
|
|
||||||
? {
|
|
||||||
background:
|
|
||||||
"linear-gradient(180deg, #FFDAA8 0%, #A88351 100%)",
|
|
||||||
}
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
{/* 内层 · #1 用 2px 内填让金色渐变作为描边露出 */}
|
<ArtistPortrait
|
||||||
<div
|
artist={artist}
|
||||||
className={cn(
|
rounded="rounded-full"
|
||||||
"relative w-full h-full overflow-hidden bg-deepest",
|
className="w-full h-full"
|
||||||
isFirst ? "rounded-[10px]" : "rounded-[11px]",
|
/>
|
||||||
)}
|
</div>
|
||||||
>
|
|
||||||
{/* 立绘填满卡片 */}
|
|
||||||
<ArtistPortrait
|
|
||||||
artist={artist}
|
|
||||||
rounded="rounded-none"
|
|
||||||
className="absolute inset-0"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* 底部渐隐 + 信息层 */}
|
{/* 名字 */}
|
||||||
<div className="absolute inset-x-0 bottom-0 px-3 pb-3 pt-10 bg-gradient-to-t from-black/90 via-black/65 to-transparent text-center">
|
<div className={cn("font-semibold text-white truncate max-w-full", isFirst ? "text-base sm:text-lg" : "text-sm")}>
|
||||||
<div
|
{artist.name}
|
||||||
className="text-sm sm:text-base font-semibold truncate"
|
</div>
|
||||||
style={{ color: "#ffffff" }}
|
|
||||||
>
|
{/* 票数 */}
|
||||||
{artist.name}
|
<div
|
||||||
</div>
|
className={cn(
|
||||||
<div className="mt-0.5 font-display tabular-nums text-base sm:text-lg text-pink-400">
|
"mt-1 font-display tabular-nums",
|
||||||
{formatVotes(artist.votes)}{" "}
|
isFirst ? "text-lg sm:text-xl text-purple-200" : "text-base text-purple-300",
|
||||||
<span className="text-xs text-pink-300/80">票</span>
|
)}
|
||||||
</div>
|
>
|
||||||
{isFirst && lead != null && lead > 0 && (
|
{formatVotes(artist.votes)}{" "}
|
||||||
<div className="mt-1 text-[11px] text-white/60 tabular-nums">
|
<span className="text-xs opacity-70">票</span>
|
||||||
领先 +{lead.toLocaleString()}
|
</div>
|
||||||
</div>
|
|
||||||
)}
|
{/* 当前排名徽章 */}
|
||||||
</div>
|
<div className="mt-2 inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full bg-purple-500/15 border border-purple-400/40 text-[10px] text-purple-200">
|
||||||
</div>
|
当前 #{artist.rank}
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
);
|
);
|
||||||
@ -116,32 +89,3 @@ export default function Top3Podium({ top3 }: Top3PodiumProps) {
|
|||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 虚位以待 · 任意 rank 缺位时的占位卡片,与正式卡片同 3:4 比例对齐底边 */
|
|
||||||
function EmptySlot({ rank }: { rank: 1 | 2 | 3 }) {
|
|
||||||
const isFirst = rank === 1;
|
|
||||||
return (
|
|
||||||
<div className="relative">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={`/rank-${rank}.svg`}
|
|
||||||
alt={`第 ${rank} 名`}
|
|
||||||
className={cn(
|
|
||||||
"absolute left-1/2 -translate-x-1/2 z-20 select-none pointer-events-none opacity-40 drop-shadow-[0_4px_12px_rgba(0,0,0,0.5)]",
|
|
||||||
isFirst
|
|
||||||
? "w-16 sm:w-20 -top-9 sm:-top-11"
|
|
||||||
: "w-14 sm:w-16 -top-7 sm:-top-9",
|
|
||||||
)}
|
|
||||||
draggable={false}
|
|
||||||
/>
|
|
||||||
<div className="relative aspect-[3/4] rounded-xl border border-dashed border-white/12 bg-white/[0.02] flex items-center justify-center">
|
|
||||||
<div className="text-center px-3">
|
|
||||||
<p className="font-label text-[10px] tracking-widest uppercase text-white/35">
|
|
||||||
Vacant
|
|
||||||
</p>
|
|
||||||
<p className="mt-1 text-xs text-white/55">虚位以待</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,54 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 计算浮动按钮为避让 <footer> 需要额外上推的像素数。
|
|
||||||
*
|
|
||||||
* 返回值 push (px):
|
|
||||||
* - footer 未进视口 → 0,按钮保持原位
|
|
||||||
* - footer 部分/完全在视口 → 等于 footer 顶部进入视口的深度 + gap,
|
|
||||||
* 让按钮始终漂浮在 footer 顶部上方 gap 像素处
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* const push = useFooterPush();
|
|
||||||
* <button style={{ transform: `translateY(-${push}px)` }} ... />
|
|
||||||
*
|
|
||||||
* 实现:scroll + resize 事件 + rAF 节流;比 IntersectionObserver 灵敏,
|
|
||||||
* 能给出连续的 push 数值(IO 只能给二值)。
|
|
||||||
*/
|
|
||||||
export function useFooterPush(gap = 16): number {
|
|
||||||
const [push, setPush] = useState(0);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const footer = document.querySelector("footer");
|
|
||||||
if (!footer) return;
|
|
||||||
|
|
||||||
let raf = 0;
|
|
||||||
const update = () => {
|
|
||||||
const rect = footer.getBoundingClientRect();
|
|
||||||
// overlap = footer 顶部进入视口的深度。footer 未入视口时为负数,clamp 到 0
|
|
||||||
const overlap = window.innerHeight - rect.top + gap;
|
|
||||||
setPush(Math.max(0, overlap));
|
|
||||||
};
|
|
||||||
|
|
||||||
const schedule = () => {
|
|
||||||
if (raf) return;
|
|
||||||
raf = requestAnimationFrame(() => {
|
|
||||||
update();
|
|
||||||
raf = 0;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
update();
|
|
||||||
window.addEventListener("scroll", schedule, { passive: true });
|
|
||||||
window.addEventListener("resize", schedule);
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", schedule);
|
|
||||||
window.removeEventListener("resize", schedule);
|
|
||||||
if (raf) cancelAnimationFrame(raf);
|
|
||||||
};
|
|
||||||
}, [gap]);
|
|
||||||
|
|
||||||
return push;
|
|
||||||
}
|
|
||||||
@ -7,6 +7,8 @@ export interface RankedArtist {
|
|||||||
no: string;
|
no: string;
|
||||||
name: string;
|
name: string;
|
||||||
enName: string;
|
enName: string;
|
||||||
|
slogan: string;
|
||||||
|
themeColor: string;
|
||||||
avatar: string | null;
|
avatar: string | null;
|
||||||
portrait: string | null;
|
portrait: string | null;
|
||||||
voteCount: number;
|
voteCount: number;
|
||||||
|
|||||||
@ -1,63 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect } from "react";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 用 sessionStorage 在同一标签页内保存/恢复滚动位置。
|
|
||||||
*
|
|
||||||
* 用法:在希望被记忆的页面顶层 useScrollRestore("home")。
|
|
||||||
* - mount 时,如果有保存值,把 window 滚动到对应 y 并清除标记
|
|
||||||
* - 用户滚动时,节流写入当前 scrollY
|
|
||||||
* - 切到其他路由后再回来 → mount 重跑 → 恢复
|
|
||||||
*
|
|
||||||
* 注意:
|
|
||||||
* - sessionStorage 跨标签页隔离,所以不会跨窗口"漏读"
|
|
||||||
* - 用 rAF 节流,避免每帧写入卡顿
|
|
||||||
* - 用 requestAnimationFrame 在下一帧恢复,确保 DOM 已经渲染出真实高度
|
|
||||||
*/
|
|
||||||
export function useScrollRestore(key: string) {
|
|
||||||
useEffect(() => {
|
|
||||||
const storageKey = `scroll:${key}`;
|
|
||||||
|
|
||||||
// 恢复 · 用 rAF 等 DOM 真正布局完成后再滚
|
|
||||||
const saved = sessionStorage.getItem(storageKey);
|
|
||||||
if (saved) {
|
|
||||||
const y = parseInt(saved, 10);
|
|
||||||
if (!Number.isNaN(y) && y > 0) {
|
|
||||||
// 两次 rAF 跨过 next.js hydration/挂载首帧,DOM 高度才稳定
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
// 临时禁用 scroll-snap-type:首页用 "y mandatory",
|
|
||||||
// 直接 scrollTo 到 snap 点之外的位置会被浏览器强制吸到最近 snap 点。
|
|
||||||
const html = document.documentElement;
|
|
||||||
const prevSnap = html.style.scrollSnapType;
|
|
||||||
html.style.scrollSnapType = "none";
|
|
||||||
window.scrollTo({ top: y, behavior: "auto" });
|
|
||||||
// 多等一帧让 scrollTo 落定再恢复 snap
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
html.style.scrollSnapType = prevSnap;
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 保存 · scroll 事件高频,用 rAF 合并
|
|
||||||
let raf = 0;
|
|
||||||
const onScroll = () => {
|
|
||||||
if (raf) return;
|
|
||||||
raf = requestAnimationFrame(() => {
|
|
||||||
sessionStorage.setItem(storageKey, String(window.scrollY));
|
|
||||||
raf = 0;
|
|
||||||
});
|
|
||||||
};
|
|
||||||
window.addEventListener("scroll", onScroll, { passive: true });
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
window.removeEventListener("scroll", onScroll);
|
|
||||||
if (raf) cancelAnimationFrame(raf);
|
|
||||||
// unmount 时再保存一次最终值
|
|
||||||
sessionStorage.setItem(storageKey, String(window.scrollY));
|
|
||||||
};
|
|
||||||
}, [key]);
|
|
||||||
}
|
|
||||||
@ -1,82 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
|
||||||
import { useSession, signOut } from "next-auth/react";
|
|
||||||
import { useVoteStore } from "@/lib/store";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 把服务端 /api/me 的真相态同步进本地 vote store。
|
|
||||||
*
|
|
||||||
* - status === "authenticated" → 拉一次 /api/me,用 votedArtists 覆盖本地
|
|
||||||
* - status === "unauthenticated" → 清本地(避免上一个用户的票残留给下一个登录者)
|
|
||||||
* - 切换用户(uid 变化) → 重新拉一次
|
|
||||||
*
|
|
||||||
* 僵尸 session 兜底:NextAuth 用 JWT 策略,cookie 不会因 DB user 被删而失效。
|
|
||||||
* 当 /api/me 返回 401(签名失效) 或 NOT_FOUND(DB 里 user 已不存在) 时,
|
|
||||||
* 自动 signOut() 清 cookie —— 避免页面"假登录"假象(显示已登录但拉不到数据)。
|
|
||||||
*
|
|
||||||
* localStorage 仅作为本设备的缓存加速首屏渲染,服务端永远是唯一真相源。
|
|
||||||
*/
|
|
||||||
export function useSyncMe() {
|
|
||||||
const { data, status } = useSession();
|
|
||||||
const hydrateFromServer = useVoteStore((s) => s.hydrateFromServer);
|
|
||||||
const reset = useVoteStore((s) => s.reset);
|
|
||||||
const lastSyncedUidRef = useRef<string | null>(null);
|
|
||||||
|
|
||||||
const sessionUser = data?.user as { id?: string } | undefined;
|
|
||||||
const uid = sessionUser?.id ?? null;
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (status === "loading") return;
|
|
||||||
|
|
||||||
if (status === "unauthenticated") {
|
|
||||||
if (lastSyncedUidRef.current !== null) {
|
|
||||||
reset();
|
|
||||||
lastSyncedUidRef.current = null;
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// authenticated
|
|
||||||
if (!uid) return;
|
|
||||||
if (lastSyncedUidRef.current === uid) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
const ctrl = new AbortController();
|
|
||||||
const timer = setTimeout(() => ctrl.abort(), 8000);
|
|
||||||
|
|
||||||
fetch("/api/me", {
|
|
||||||
credentials: "include",
|
|
||||||
signal: ctrl.signal,
|
|
||||||
})
|
|
||||||
.then(async (r) => {
|
|
||||||
if (cancelled) return;
|
|
||||||
const res = await r.json().catch(() => null);
|
|
||||||
|
|
||||||
if (r.ok && res?.ok && Array.isArray(res.data?.votedArtists)) {
|
|
||||||
hydrateFromServer(res.data.votedArtists as string[]);
|
|
||||||
lastSyncedUidRef.current = uid;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 僵尸 session:JWT 还有效,但 DB 里 user 已不存在(或鉴权失效)。
|
|
||||||
// 直接登出清 cookie,UI 状态切换为未登录,避免"显示已登录但拉不到数据"。
|
|
||||||
const code: string | undefined = res?.error?.code;
|
|
||||||
if (r.status === 401 || code === "UNAUTHORIZED" || code === "NOT_FOUND") {
|
|
||||||
signOut({ redirect: false });
|
|
||||||
reset();
|
|
||||||
lastSyncedUidRef.current = null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch(() => {
|
|
||||||
// 网络失败容忍 —— 下次 status 变化或手动刷新会再试
|
|
||||||
})
|
|
||||||
.finally(() => clearTimeout(timer));
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
ctrl.abort();
|
|
||||||
clearTimeout(timer);
|
|
||||||
};
|
|
||||||
}, [status, uid, hydrateFromServer, reset]);
|
|
||||||
}
|
|
||||||
@ -6,70 +6,39 @@ import toast from "react-hot-toast";
|
|||||||
import {
|
import {
|
||||||
useVoteStore,
|
useVoteStore,
|
||||||
selectRemaining,
|
selectRemaining,
|
||||||
TOTAL_VOTE_QUOTA,
|
DAILY_VOTE_QUOTA,
|
||||||
} from "@/lib/store";
|
} from "@/lib/store";
|
||||||
import { useLoginModalStore } from "@/lib/login-modal-store";
|
import { useLoginModalStore } from "@/lib/login-modal-store";
|
||||||
import type { Artist } from "@/types/artist";
|
import type { Artist } from "@/types/artist";
|
||||||
|
|
||||||
interface UseVoteActionResult {
|
interface UseVoteActionResult {
|
||||||
/** 当前投票目标艺人(null 时弹窗关闭) */
|
/** 当前投票目标艺人(null 时弹窗关闭) */
|
||||||
target: Artist | null;
|
target: Artist | null;
|
||||||
/** 剩余可投票数(终身 12 票 - 已投艺人数) */
|
/** 今日剩余票数 */
|
||||||
remaining: number;
|
remaining: number;
|
||||||
/** 总额度常量 12(供 UI 文案展示) */
|
/** 每日总额度(常量,供 UI 文案展示) */
|
||||||
totalQuota: number;
|
dailyQuota: number;
|
||||||
/** 触发投票(自动检查登录态 / 已投态 / 额度) */
|
/** 触发投票(自动检查登录态 + 额度) */
|
||||||
openVote: (artist: Artist) => void;
|
openVote: (artist: Artist) => void;
|
||||||
/** 关闭投票弹窗 */
|
/** 关闭投票弹窗 */
|
||||||
closeVote: () => void;
|
closeVote: () => void;
|
||||||
/** 确认投票(已登录态下调用,固定投 1 票) */
|
/** 确认投票(已登录态下调用) */
|
||||||
confirmVote: (artist: Artist) => Promise<void>;
|
confirmVote: (artist: Artist, count: number) => Promise<void>;
|
||||||
}
|
|
||||||
|
|
||||||
interface UseVoteActionOpts {
|
|
||||||
/**
|
|
||||||
* 投票成功且服务端 200 写入后回调 —— 调用方在此触发 useRanking.refresh(),
|
|
||||||
* 让 Top12 / 排行榜立即拉到新票数,而不是等 30s 下次轮询。
|
|
||||||
* 不传则不做任何额外刷新(适合详情页等不显示排名的场景)。
|
|
||||||
*/
|
|
||||||
onVoteSuccess?: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 服务端拉一次 /api/me,把权威态灌进本地 store。用于跨设备状态对齐。 */
|
|
||||||
async function refetchMe(
|
|
||||||
hydrateFromServer: (ids: string[]) => void,
|
|
||||||
): Promise<void> {
|
|
||||||
try {
|
|
||||||
const res = await fetch("/api/me", { credentials: "include" });
|
|
||||||
const data = await res.json();
|
|
||||||
if (data?.ok && Array.isArray(data.data?.votedArtists)) {
|
|
||||||
hydrateFromServer(data.data.votedArtists as string[]);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// 失败容忍 —— 下次页面交互或登录态变化会再同步
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 投票交互统一入口。
|
* 投票交互统一入口。
|
||||||
*
|
*
|
||||||
* 新规则:
|
* 规则:
|
||||||
* - 每用户终身 12 票,每位艺人最多 1 票,不限时,不可撤销
|
* - 每用户每日总额度 = 10 票,跨艺人共享。无单艺人上限。
|
||||||
* - 未登录 → toast 提示并跳登录弹窗
|
* - 未登录 → toast 提示并跳登录页
|
||||||
* - 已投过该艺人 → toast 提示,不打开弹窗
|
* - 已登录但当日票数已用完 → toast 提示,不打开弹窗
|
||||||
* - 12 票已用完 → toast 提示,不打开弹窗
|
* - 弹窗确认后:本地 store 立即扣减 + 调用后端 API(fire-and-forget)
|
||||||
* - 弹窗确认后:乐观更新本地 + await 服务端写入
|
|
||||||
* - 服务端拒绝 → 回滚本地 + 用 /api/me 重新对齐(跨设备真相源)
|
|
||||||
* - API 200 后触发 opts.onVoteSuccess() —— 让排名相关页面立即 refresh,
|
|
||||||
* 解决"票数 +1 / Top12 排位要等 30s"的体感问题
|
|
||||||
*/
|
*/
|
||||||
export function useVoteAction(opts: UseVoteActionOpts = {}): UseVoteActionResult {
|
export function useVoteAction(): UseVoteActionResult {
|
||||||
const { status } = useSession();
|
const { status } = useSession();
|
||||||
const recordVote = useVoteStore((s) => s.vote);
|
const recordVote = useVoteStore((s) => s.vote);
|
||||||
const rollbackVote = useVoteStore((s) => s.rollbackVote);
|
|
||||||
const hydrateFromServer = useVoteStore((s) => s.hydrateFromServer);
|
|
||||||
const remaining = useVoteStore(selectRemaining);
|
const remaining = useVoteStore(selectRemaining);
|
||||||
const votedArtists = useVoteStore((s) => s.votedArtists);
|
|
||||||
const openLogin = useLoginModalStore((s) => s.show);
|
const openLogin = useLoginModalStore((s) => s.show);
|
||||||
const [target, setTarget] = useState<Artist | null>(null);
|
const [target, setTarget] = useState<Artist | null>(null);
|
||||||
|
|
||||||
@ -81,102 +50,48 @@ export function useVoteAction(opts: UseVoteActionOpts = {}): UseVoteActionResult
|
|||||||
setTimeout(openLogin, 350);
|
setTimeout(openLogin, 350);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (votedArtists.includes(artist.id)) {
|
|
||||||
toast(`你已为 ${artist.name} 投过票了`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
toast("你的 12 票已用完,感谢支持");
|
toast("今日票数已用完,明天再来吧");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setTarget(artist);
|
setTarget(artist);
|
||||||
},
|
},
|
||||||
[status, openLogin, remaining, votedArtists],
|
[status, openLogin, remaining],
|
||||||
);
|
);
|
||||||
|
|
||||||
const closeVote = useCallback(() => setTarget(null), []);
|
const closeVote = useCallback(() => setTarget(null), []);
|
||||||
|
|
||||||
const confirmVote = useCallback(
|
const confirmVote = useCallback(
|
||||||
async (artist: Artist) => {
|
async (artist: Artist, count: number) => {
|
||||||
// 1. 乐观更新本地(含已投/已满兜底校验)
|
// 1. 本地 store 立即扣减(包含额度校验)
|
||||||
const result = recordVote(artist.id);
|
const success = recordVote(artist.id, count);
|
||||||
if (!result.ok) {
|
if (!success) {
|
||||||
if (result.reason === "already") {
|
toast.error("今日票数不足");
|
||||||
toast.error(`你已为 ${artist.name} 投过票了`);
|
|
||||||
} else {
|
|
||||||
toast.error("你的 12 票已用完,感谢支持");
|
|
||||||
}
|
|
||||||
setTarget(null);
|
setTarget(null);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
toast.success(`已为 ${artist.name} 投出 ${count} 票`);
|
||||||
// 乐观成功提示;若服务端拒绝再 dismiss + 报错
|
|
||||||
const remainingAfter = remaining - 1;
|
|
||||||
const successMsg =
|
|
||||||
remainingAfter === 0
|
|
||||||
? `完成!你的 12 票已全部投出 ✦`
|
|
||||||
: `已为 ${artist.name} 投票 · 剩余 ${remainingAfter} 票`;
|
|
||||||
const successToastId = toast.success(successMsg, {
|
|
||||||
duration: remainingAfter === 0 ? 4000 : 2800,
|
|
||||||
});
|
|
||||||
setTarget(null);
|
setTarget(null);
|
||||||
|
|
||||||
// 2. await 服务端真实写入。失败 → 回滚 + 用 /api/me 对齐
|
// 2. 后台 fire-and-forget 调用真实 API(5 秒超时,失败静默忽略)
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 8000);
|
const timer = setTimeout(() => ctrl.abort(), 5000);
|
||||||
try {
|
fetch("/api/vote", {
|
||||||
const res = await fetch("/api/vote", {
|
method: "POST",
|
||||||
method: "POST",
|
headers: { "Content-Type": "application/json" },
|
||||||
headers: { "Content-Type": "application/json" },
|
body: JSON.stringify({ artistId: artist.id, count }),
|
||||||
body: JSON.stringify({ artistId: artist.id, count: 1 }),
|
signal: ctrl.signal,
|
||||||
signal: ctrl.signal,
|
})
|
||||||
credentials: "include",
|
.catch(() => {})
|
||||||
});
|
.finally(() => clearTimeout(timer));
|
||||||
const data = await res.json().catch(() => null);
|
|
||||||
|
|
||||||
if (res.ok && data?.ok) {
|
|
||||||
// API 200 → 通知调用方(如首页)立即 refresh 排名,不等 30s 轮询
|
|
||||||
opts.onVoteSuccess?.();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 服务端拒绝 → 回滚乐观更新
|
|
||||||
rollbackVote(artist.id);
|
|
||||||
toast.dismiss(successToastId);
|
|
||||||
|
|
||||||
const code: string | undefined = data?.error?.code;
|
|
||||||
if (code === "ALREADY_VOTED") {
|
|
||||||
toast.error("你已在其他设备为该艺人投过票");
|
|
||||||
} else if (code === "QUOTA_EXHAUSTED") {
|
|
||||||
toast.error("你的 12 票已在其他设备投完");
|
|
||||||
} else if (code === "UNAUTHORIZED") {
|
|
||||||
toast.error("登录已失效,请重新登录");
|
|
||||||
} else if (code === "ACTIVITY_OFF") {
|
|
||||||
toast.error("投票活动暂未开放");
|
|
||||||
} else if (code === "RATE_LIMITED") {
|
|
||||||
toast.error("操作太快了,请稍后再试");
|
|
||||||
} else {
|
|
||||||
toast.error(data?.error?.message ?? "投票失败,请重试");
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跨设备状态对齐:服务端永远是真相源
|
|
||||||
await refetchMe(hydrateFromServer);
|
|
||||||
} catch {
|
|
||||||
// 网络错误 / 超时 → 回滚 + 不强拉 /api/me(网络问题大概率也拉不到)
|
|
||||||
rollbackVote(artist.id);
|
|
||||||
toast.dismiss(successToastId);
|
|
||||||
toast.error("网络异常,投票未生效");
|
|
||||||
} finally {
|
|
||||||
clearTimeout(timer);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[recordVote, rollbackVote, hydrateFromServer, remaining, opts],
|
[recordVote],
|
||||||
);
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
target,
|
target,
|
||||||
remaining,
|
remaining,
|
||||||
totalQuota: TOTAL_VOTE_QUOTA,
|
dailyQuota: DAILY_VOTE_QUOTA,
|
||||||
openVote,
|
openVote,
|
||||||
closeVote,
|
closeVote,
|
||||||
confirmVote,
|
confirmVote,
|
||||||
|
|||||||
@ -30,10 +30,8 @@ export const ERR = {
|
|||||||
VALIDATION: (msg: string) => err("VALIDATION", msg, 422),
|
VALIDATION: (msg: string) => err("VALIDATION", msg, 422),
|
||||||
INTERNAL: (msg = "服务器错误") => err("INTERNAL", msg, 500),
|
INTERNAL: (msg = "服务器错误") => err("INTERNAL", msg, 500),
|
||||||
ACTIVITY_OFF: () => err("ACTIVITY_OFF", "投票活动暂未开放", 409),
|
ACTIVITY_OFF: () => err("ACTIVITY_OFF", "投票活动暂未开放", 409),
|
||||||
QUOTA_EXHAUSTED: (msg = "你的 12 票已全部投出,感谢支持") =>
|
QUOTA_EXHAUSTED: (msg = "今日票数已用完") =>
|
||||||
err("QUOTA_EXHAUSTED", msg, 409),
|
err("QUOTA_EXHAUSTED", msg, 409),
|
||||||
ALREADY_VOTED: (msg = "你已为该艺人投过票") =>
|
|
||||||
err("ALREADY_VOTED", msg, 409),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,564 +0,0 @@
|
|||||||
import type { ArtistTag } from "@/types/artist";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 36 位真实艺人种子数据 · 来源:人物小传.docx
|
|
||||||
* 字段全部从 docx 提取;tags 是筛选用的运行时分类(非小传内容)。
|
|
||||||
*/
|
|
||||||
export interface ArtistSeed {
|
|
||||||
no: string;
|
|
||||||
name: string;
|
|
||||||
enName: string;
|
|
||||||
age: number;
|
|
||||||
gender: "M" | "F";
|
|
||||||
height: number;
|
|
||||||
tags: ArtistTag[];
|
|
||||||
motto?: string;
|
|
||||||
personality?: string;
|
|
||||||
catchphrase?: string;
|
|
||||||
skills?: string;
|
|
||||||
track?: string;
|
|
||||||
bio: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const ARTIST_SEEDS: ArtistSeed[] = [
|
|
||||||
{
|
|
||||||
no: `001`,
|
|
||||||
name: `陈默`,
|
|
||||||
enName: `SIREN`,
|
|
||||||
age: 25,
|
|
||||||
gender: `M`,
|
|
||||||
height: 184,
|
|
||||||
tags: [`rock`] as ArtistTag[],
|
|
||||||
motto: `沉默蓄力,舞台绽放。`,
|
|
||||||
personality: `清冷寡言、内敛克制,不喜喧闹与无效社交,待人谦和温柔、分寸感十足。舞台上沉稳凌厉、爆发力拉满,全程沉浸音乐,用专业演绎传递摇滚态度`,
|
|
||||||
catchphrase: `嗯,知道了。`,
|
|
||||||
skills: `电吉他演奏、摇滚副唱、即兴 solo`,
|
|
||||||
track: `主音电吉他手、摇滚赛道`,
|
|
||||||
bio: `陈默天生不喜喧闹,习惯独处观察生活,久而久之便成了旁人眼中沉默寡言的少年。这份内敛让他拥有了远超同龄人的细腻洞察力,也让他习惯性将所有情绪藏于心底,不轻易外露。十七岁的盛夏,是他人生的重要转折点,一次偶然的街边演出,让他第一次听见重金属摇滚的炸裂音浪,激昂的电吉他旋律瞬间击穿了他平淡压抑的青春。自此,他彻底爱上了摇滚,开启了日复一日的练琴时光,没人知晓他独处时的拼命打磨,只看到他日渐精湛的琴技。为了诠释这份极致的音乐吸引力,他为自己取名 “SIREN”,以此致敬摇滚音乐自带的致命魅力。怀揣着对摇滚的赤诚,他带着专属电吉他扎根地下摇滚赛道,在无数次小型演出中积累舞台经验、打磨即兴 solo 能力。台下的他,始终保持着温柔克制的本性,待人细腻温和、低调内敛;可只要舞台灯光亮起,他便会彻底卸下沉静伪装,以极致炸裂的舞台爆发力掌控全场。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `002`,
|
|
||||||
name: `许则`,
|
|
||||||
enName: `ETHAN`,
|
|
||||||
age: 21,
|
|
||||||
gender: `M`,
|
|
||||||
height: 181,
|
|
||||||
tags: [`rock`] as ArtistTag[],
|
|
||||||
motto: `藏于人海,忠于舞台。`,
|
|
||||||
personality: `内向腼腆、安静疏离,极度不善社交,偏爱独处与二次元世界,心性敏感纯粹。舞台上野性张扬、气场凌厉,对节奏把控极致,爆发力拉满,全然释放压抑情绪`,
|
|
||||||
catchphrase: `别吵,练鼓。`,
|
|
||||||
skills: `架子鼓演奏、重型音乐演绎、双踩即兴`,
|
|
||||||
track: `架子鼓手、重型摇滚赛道`,
|
|
||||||
bio: `许则成长于书香门第,父母深耕学术领域,从小到大,他始终活在 “优秀学子” 的标签之下,被迫循规蹈矩,几乎没有自我表达的空间。性格内向的他不善社交,畏惧人群与热闹,习惯性避开所有人的目光,孤独成了他青春的常态。而二次元世界,是他唯一的避风港。高二那年,一次偶然的机会,他走进街边地下琴行,第一次握住鼓棒、触碰架子鼓,厚重炸裂的节奏瞬间击碎了他长久以来的压抑。他终于找到专属自己的宣泄方式,那些无法言说的委屈、压抑与不甘,都能通过鼓点肆意释放。自此,他彻底沉迷重型摇滚,每日放学后坚守琴行苦练,日复一日打磨双踩技巧、钻研重型音乐节奏,哪怕手指磨破、虎口反复结痂,也从未停歇,凭借极致的毅力练就了扎实的鼓技与超强的即兴能力。如今的他,拥有极致的双面人生:日常里,是戴着降噪耳机、沉默寡言的清冷大学生,低调内敛、不善交际;站上舞台,便会彻底挣脱束缚,化身野性张扬的舞台强者,狂暴有力的鼓点、极具张力的舞台状态,将积压多年的情绪尽数释放。于他而言,鼓声是救赎,是重生,让沉寂的灵魂得以热烈生长。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `003`,
|
|
||||||
name: `夏致`,
|
|
||||||
enName: `SUNNY`,
|
|
||||||
age: 22,
|
|
||||||
gender: `M`,
|
|
||||||
height: 183,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `心怀热忱,不负热爱。`,
|
|
||||||
personality: `阳光热忱、坦荡纯粹,元气开朗、待人亲和,是靠谱的邻家哥哥。舞台上松弛舒展、活力满满,感染力极强,用干净律动与温柔唱腔传递青春温度`,
|
|
||||||
catchphrase: `没问题,交给我!`,
|
|
||||||
skills: `木吉他弹唱、和声、唱跳`,
|
|
||||||
track: `节奏吉他手、校园流行赛道`,
|
|
||||||
bio: `夏致从小在体育大院长大,父亲是专业篮球教练,母亲是小学音乐老师,在这样的环境里,他既练就了一身运动细胞,也早早接触到了音乐。他曾是校篮球队的绝对主力,带领球队拿下过三次市级联赛冠军,是学校里小有名气的篮球校草。他的 “走红” 纯属意外,在校赛夺冠后的庆功宴上,穿着被汗水打湿的球衣、随手抱起队友的木吉他为大家弹唱了一首自己改编的民谣,被路人拍下上传到社交平台,收获了不少关注。视频中他那张自带盐系少年感的校草脸,配合毫无偶像包袱的灿烂笑容,瞬间治愈了很多人的夏天。作为典型的运动达人,夏致身上有着一种难得的纯粹。他拒绝了多家经纪公司的流水线包装和单独出道的邀约,坚持留在乐队担任节奏吉他手,只为守住那份最热血的校园流行情怀。他习惯在球场挥汗如雨后,背起吉他钻进排练室,用最干净的和声回应每一份期待。对他而言,生活从不是刻意经营的舞台,而是一场永远保持热忱、无距离感的邻家冒险。现在,这位元气满满的节奏掌控者,正带着他的阳光与律动,向更多人发出入场邀约。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `004`,
|
|
||||||
name: `陆友祁`,
|
|
||||||
enName: `WIFI`,
|
|
||||||
age: 20,
|
|
||||||
gender: `M`,
|
|
||||||
height: 174,
|
|
||||||
tags: [`hiphop`] as ArtistTag[],
|
|
||||||
motto: `随性而活,清醒自持。`,
|
|
||||||
personality: `活泼跳脱、鬼马通透,幽默随性、自带综艺感,看似玩世不恭实则清醒有分寸。舞台上灵动百变、松弛有度,节奏把控精准,风格多元不被定义,感染力十足`,
|
|
||||||
catchphrase: `笑死,这也太离谱了。`,
|
|
||||||
skills: `Rap 演唱、综艺控场、节奏吉他、大提琴`,
|
|
||||||
track: `鬼马主持担当、嘻哈融合赛道`,
|
|
||||||
bio: `陆友祁出身于正统的音乐世家,祖父是国内知名的大提琴演奏家,父母均是国家级交响乐团的成员,他从三岁起就被家人逼着学习大提琴,本该是穿着定制西装在音乐厅拉大提琴的贵公子,可他偏偏有一身 “反骨”。在枯燥的练琴时光里,他偷偷迷上了嘻哈音乐和脱口秀,常常躲在琴房里跟着视频学 Rap、练段子。在某次正儿八经的家族晚宴上,因为嫌弃背景音乐太催眠,他穿着一身挺括的西服,当众抄起一把破旧的贝斯,对着那群非富即贵的亲戚来了段极其松弛的 Rap 演唱,甚至还当场接梗造梗,把严肃的晚宴变成了大型脱口秀现场。这段反差感拉满的短视频被亲戚随手发到网上,让他在网络上收获了不少关注,被网友称为 “互联网嘴替”。作为搞笑担当,陆友祁在嘻哈融合赛道里完全是野蛮生长。他既能用大提琴拉出高级的音色,也能在后台一边吃着几块钱的冰棒一边跟粉丝直播唠嗑。对他而言,搞音乐不是为了 “起范儿”,纯粹是因为这儿能让他大声说话、自由蹦迪。这位活得通透的跳脱少年,正用他那毫无距离感的魅力,活得真实又自在。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `005`,
|
|
||||||
name: `苏恋和`,
|
|
||||||
enName: `LEON`,
|
|
||||||
age: 19,
|
|
||||||
gender: `M`,
|
|
||||||
height: 178,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `以温柔渡岁月,以初心赴长路。`,
|
|
||||||
personality: `温柔腼腆、干净纯粹,安静内敛、心思细腻,共情力极强。舞台上温柔且有力量,唱腔细腻走心、情绪饱满,沉浸式演绎安静却撼动人心`,
|
|
||||||
catchphrase: `谢谢你们呀。`,
|
|
||||||
skills: `流行演唱、吉他弹唱、流行曲创作`,
|
|
||||||
track: `主唱、日系抒情流行赛道、粉丝陪伴向内容`,
|
|
||||||
bio: `苏恋和成长于单亲家庭,母亲是一名急诊科护士,常年忙于工作,他从小就学会了独立照顾自己,也养成了温柔腼腆、心思细腻的性格。他的被发现,始于高二那年一个雨天,他在学校天台练习吉他弹唱的视频被同学偶然拍下。作为典型的 “日系初恋脸”,他总是带着几分腼腆与细腻,那种干净清澈的少年感,让他即便只是安静地坐着,也像是一封还未拆开的情书。为了和粉丝建立更紧密的连接,他在网络上坚持分享了 365 天的 “粉丝陪伴向” 弹唱记录,每一首歌都精准记录了当下的心情起伏,从考试失利的沮丧到吃到好吃甜品的开心,都毫无保留地分享给大家。这种真诚让他在粉丝眼中成为了无可替代的 “治愈系” 存在。虽然外表温润,但他极强的逻辑思维与专业创作力,让他不仅是一位歌者,更是灵魂创作者。如今,这位习惯用音乐传递细腻情感的少年,正带着他特有的抒情律动,从屏幕后走向聚光灯下。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `006`,
|
|
||||||
name: `傅希文`,
|
|
||||||
enName: `RYAN`,
|
|
||||||
age: 20,
|
|
||||||
gender: `M`,
|
|
||||||
height: 180,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `理性编曲,真诚赴乐。`,
|
|
||||||
personality: `清冷克制、沉稳自律,严肃理性、不喜客套,逻辑思维缜密,专注自我深耕。舞台上极致严谨、精准专业,摒弃情绪化表达,用理性逻辑承载细腻情绪,质感高级。`,
|
|
||||||
catchphrase: `逻辑上是这样的。`,
|
|
||||||
skills: undefined,
|
|
||||||
track: `键盘 / 编曲、智性向内容赛道`,
|
|
||||||
bio: `傅希文出生在一个理工科世家,父母都是大学的物理学教授,在家庭环境的熏陶下,他从小就展现出惊人的逻辑思维和数学天赋,是别人口中 “别人家的孩子”。他从小学开始学习钢琴,和其他孩子不同,他并不把音乐当成感性的艺术,而是将其视为一门严谨的科学。他在音乐圈的初次露面,源于一段在大学图书馆角落戴着耳机、用平板电脑推导复杂音程规律的侧拍视频。这种 “高智性学霸” 的清冷感,配合他键盘前修长而精准的手指,让他迅速在校园中走红,受到很多智性恋偏好者的喜爱。作为键盘手兼编曲者,他并不相信虚无缥缈的灵感,而更倾向于将情感拆解为严密的数学逻辑,通过精准的音程组合和节奏编排来表达情绪。在采访中,他那种自律克制、条理缜密的说话方式,总能让人感受到逻辑思维的魅力。对他而言,作曲是一场音符的推导,而舞台是唯一不需要证明的公理。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `007`,
|
|
||||||
name: `林星`,
|
|
||||||
enName: `STARRY`,
|
|
||||||
age: 24,
|
|
||||||
gender: `F`,
|
|
||||||
height: 171,
|
|
||||||
tags: [`rock`] as ArtistTag[],
|
|
||||||
motto: `于独处中沉淀,于舞台上生长。`,
|
|
||||||
personality: `外表疏离,慢热且极度社恐,不爱社交与客套。舞台上酷飒拽气场全开,私下敏感柔软,只对音乐与信任的人敞开心扉。`,
|
|
||||||
catchphrase: `别管我。`,
|
|
||||||
skills: `贝斯演奏、摇滚副唱、和声编写`,
|
|
||||||
track: `贝斯手、摇滚赛道`,
|
|
||||||
bio: `林星成长于规矩森严、管教严苛的传统家庭,父母秉持传统固化的教育理念,逼着她常年深耕古典乐器,日复一日的枯燥练习、条条框框的规矩束缚,压抑了她所有的天性与情绪,也让她养成了疏离寡言的性格,心底悄悄埋下了叛逆的种子。漫长压抑的青春里,她始终活在他人的期待里,习惯性封闭自我,对外始终保持着拒人千里的冷淡姿态。高中时期,是她人生的重要转折点,一次偶然的机会,她接触到摇滚音乐与贝斯乐器,乐器低沉厚重、充满力量的音色,瞬间击穿了她多年的压抑与桎梏,让她终于找到专属自己的情绪出口。自此,她开启了偷偷练琴、私下写和声的日子,避开家人的管控与反对,在无人知晓的角落深耕摇滚,用琴弦倾诉所有无法言说的情绪与不甘。成年后的她,彻底挣脱了家庭的固化安排,毅然独自闯荡,坚定奔赴热爱的摇滚舞台。平日里的她,极度慢热社恐,沉默寡言,习惯性与人保持距离,敏感柔软的内心只对音乐和极少数信任的人敞开。可一旦站上舞台,她便会彻底切换状态,褪去内敛怯懦,化身气场全开的酷飒贝斯手,她用炸裂的舞台表现力、极具态度的旋律,对抗过往的束缚与规训。于林星而言,贝斯不是简单的乐器,摇滚不是刻意的人设,而是她挣脱桎梏、表达自我、释放本心的唯一方式,是一个叛逆少女与世界温柔对抗的底气。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `008`,
|
|
||||||
name: `田糯`,
|
|
||||||
enName: `NORI`,
|
|
||||||
age: 18,
|
|
||||||
gender: `F`,
|
|
||||||
height: 165,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `心怀赤诚,向阳而生。`,
|
|
||||||
personality: `元气纯粹、热忱真诚,开朗灵动、笑容甜美,心性简单无城府,踏实努力上进。舞台上鲜活灵动、甜度满分,兼具温柔质感与高音爆发力,自带阳光治愈氛围感。`,
|
|
||||||
catchphrase: `哇,好棒!`,
|
|
||||||
skills: `流行唱跳、高音爆发、吉他弹唱`,
|
|
||||||
track: `乐队主唱 / 偶像、流行赛道`,
|
|
||||||
bio: `18 岁的田糯,出生在南方一个宁静的小镇,父母在镇上开了一家小卖部,家境普通但温馨和睦。她从小就展现出过人的音乐天赋,喜欢跟着电视里的歌曲哼唱,邻居们都叫她 “小百灵”。为了实现音乐梦想,她用攒了好几年的压岁钱买了一把二手吉他,每天放学写完作业后,就对着镜子练习弹唱和舞蹈。她曾是那个在南方小镇巷口,背着比自己还大的吉他盒赶车的普通女孩,周末会去镇上的集市表演,赚点零花钱补贴家用。一个在小镇集市举办的简陋歌唱比赛,她凭着 “灵动甜美” 的气质以及惊人的高音爆发力,演唱片段被传到网上后迅速走红。她身上那种接地气无距离感以及满满的元气,源于最真实的追梦热血。作为偶像主唱,田糯不仅拥有绝佳的舞台表现力,更有着一种能让观众 “多云转晴” 的治愈感染力。从宁静小镇到万众瞩目的舞台,不变的是那颗真诚感恩的初心。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `009`,
|
|
||||||
name: `蒋莎莎`,
|
|
||||||
enName: `SASA`,
|
|
||||||
age: 21,
|
|
||||||
gender: `F`,
|
|
||||||
height: 168,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `以实力立身,以专业立舞台。`,
|
|
||||||
personality: `飒爽直爽、干练果决,独立强势、自带拽姐气场,对待专业极致严谨自律。舞台上气场全开、掌控力拉满,唱功扎实爆发力强,临场应变顶尖,用实力锁定全场。`,
|
|
||||||
catchphrase: `再来一遍。`,
|
|
||||||
skills: `实力唱跳、高音演绎、欧美流行演唱`,
|
|
||||||
track: `主唱 / 高音担当、欧美流行赛道`,
|
|
||||||
bio: `蒋莎莎 16 岁时独自一人远赴韩国成为练习生,在严苛的训练体系下度过了整整五年。这五年里,她每天练习超过 12 个小时,经历过三次出道机会的落空,也曾因为高强度的训练累到晕倒在练习室,但她从未想过放弃。这段经历不仅练就了她过硬的唱跳实力,也塑造了她飒爽自信、永不言败的性格。她的出现,是音乐节现场一次令人印象深刻的 “意外补位”。在音响设备突发故障的极端环境下,凭借一副富有磁性的 “欧美大烟嗓” 直接清唱全场,极具穿透力的高音瞬间撕裂了嘈杂。那种飒爽自信、专业强势的临场感,让台下观众亲眼见证了她扎实的唱跳实力。她天生自带一种 “拽姐气场”,性格直爽、从不拖泥带水。作为主唱,她不仅是团队中的高音担当,更是舞台上的精神领袖。现在,这位性格火辣、实力硬核的舞台新人已准备就绪,即将用最直接的方式,定义属于她的黄金时代。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `010`,
|
|
||||||
name: `凌桀`,
|
|
||||||
enName: `AVRIL`,
|
|
||||||
age: 22,
|
|
||||||
gender: `F`,
|
|
||||||
height: 166,
|
|
||||||
tags: [`rock`] as ArtistTag[],
|
|
||||||
motto: `不被定义,不受束缚。`,
|
|
||||||
personality: `叛逆随性、个性鲜明,清冷不羁、不迎合世俗,自带朋克内核,拒绝循规蹈矩。舞台上野性张扬、张力拉满,风格锐利态度鲜明,用极具破坏性的演绎诠释摇滚精神。`,
|
|
||||||
catchphrase: `随便,无所谓。`,
|
|
||||||
skills: `朋克摇滚演唱、架子鼓手、电吉他演奏、词曲创作`,
|
|
||||||
track: `朋克摇滚主唱、欧美摇滚赛道`,
|
|
||||||
bio: `凌桀是中英混血,父亲是英国的摇滚乐队贝斯手,母亲是中国的美术老师,她从小在英国伦敦长大,在父亲的影响下,三岁就开始接触摇滚音乐,七岁学习电吉他,十岁就能独立创作简单的歌曲。她骨子里带着朋克摇滚的叛逆精神,不喜欢循规蹈矩的生活,16 岁时不顾家人反对,独自一人回到中国,在上海组建了自己的第一支地下朋克乐队。她第一次出现在大众视野是因一段在雨中废弃工厂进行的地下 live 路透。镜头里的她画着凌厉的眼线,手持电吉他,在简陋的架子鼓节奏中疯狂扫弦,那种 “混血甜酷感” 配合极具破坏性的舞台张力,瞬间点燃了评论区。生而叛逆、个性鲜明的她不仅是乐队的灵魂主唱,更是拥有极强词曲创作能力的先锋玩家。她拒绝被定义,更拒绝平庸的温和,所有的创作都带着一种撕裂空气的野性美。对凌桀而言,摇滚不是表演,而是她与生俱来的呼吸方式。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `011`,
|
|
||||||
name: `苏清沅`,
|
|
||||||
enName: `JANE`,
|
|
||||||
age: 20,
|
|
||||||
gender: `F`,
|
|
||||||
height: 168,
|
|
||||||
tags: [`chinese`, `pop`] as ArtistTag[],
|
|
||||||
motto: `以细节筑精品,以初心承国风。`,
|
|
||||||
personality: `温柔内向、细腻敏感,安静温婉、恬淡素雅,自带江南书香门第的古典气韵。舞台上极致严谨、精益求精,自带轻微舞台强迫症,用细腻国风旋律营造温柔氛围。`,
|
|
||||||
catchphrase: `麻烦你了。`,
|
|
||||||
skills: `钢琴演奏、古风演奏、国风编曲、抒情演唱`,
|
|
||||||
track: `键盘 / 和声、民乐担当、抒情流行赛道、国风赛道`,
|
|
||||||
bio: `苏清沅出生在江南苏州的书香门第,爷爷是著名的国画家,她从小在墨香与琴声中长大,四岁开始学习钢琴,六岁学习古筝,深受中国传统文化的熏陶,身上自带一种与生俱来的古典韵味。她的 “出圈”,源于一场民乐系毕业晚会的侧拍。20 岁的她一袭素衣坐在钢琴前,清冷易碎的 “淡颜小白花” 气质与指尖流淌出的古风编曲完美融合,被网友盛赞为 “校园清冷白月光”。虽然外表温柔内向,但苏清沅在专业上有着近乎偏执的 “舞台强迫症”:每一个音符的力度、每一次国风元素的切入、每一个舞台动作的细节,她都要求达到极致的精准。作为乐队的键盘与民乐担当,她不仅擅长钢琴,更能将古筝、竹笛等多种民乐元素巧妙融入现代编曲中。对她而言,音乐是她安静构建世界的砖瓦,每一个音符都承载着她细腻敏感的内心。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `012`,
|
|
||||||
name: `夏晚`,
|
|
||||||
enName: `WENDY`,
|
|
||||||
age: 30,
|
|
||||||
gender: `F`,
|
|
||||||
height: 173,
|
|
||||||
tags: [`jazz`] as ArtistTag[],
|
|
||||||
motto: `岁月沉淀温柔,旋律治愈人心。`,
|
|
||||||
personality: `松弛慵懒、温柔知性,成熟通透、从容淡然,历经岁月沉淀自带温润治愈质感。舞台上舒展随性、松弛自然,摒弃炫技与浮夸,用旋律叙事抒情,氛围感极致拉满。`,
|
|
||||||
catchphrase: `慢慢来,不着急。`,
|
|
||||||
skills: `萨克斯演奏、爵士演唱、即兴编曲`,
|
|
||||||
track: `萨克斯手、爵士 / 流行融合赛道`,
|
|
||||||
bio: `夏晚从小在香港的老街区长大,父亲是一名爵士乐手,在当地的酒吧驻唱多年,她从小听着港乐和爵士乐长大,耳濡目染之下爱上了这种充满故事感的音乐。大学时,她考入香港演艺学院主修爵士乐,专攻萨克斯演奏。毕业后,她没有进入主流乐坛,而是留在香港的爵士酒吧驻唱,这一唱就是八年。八年的驻唱生涯,让她积累了丰富的舞台经验,也形成了她松弛慵懒、娓娓道来的演唱风格。她的被关注,源于一段在深夜老街长镜头下的即兴萨克斯演奏。30 岁的她,身上有着一种岁月沉淀后的松弛慵懒,举手投足间尽是港风电影里的复古氛围感,被网友称赞为氛围感十足。夏晚并不追求炫技,她更擅长用萨克斯的叙事感填充音乐里的留白,将爵士与流行完美融合。她大气专业的舞台状态,从不是刻意表演,而是与听众分享岁月里的遗憾与和解。对夏晚而言,舞台不需要喧嚣,只需一盏暖光。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `013`,
|
|
||||||
name: `虞浓`,
|
|
||||||
enName: `LYDIA`,
|
|
||||||
age: 28,
|
|
||||||
gender: `F`,
|
|
||||||
height: 172,
|
|
||||||
tags: [`rock`] as ArtistTag[],
|
|
||||||
motto: `以锋芒立舞台,以真诚待世人。`,
|
|
||||||
personality: `明艳大气、爽朗通透,可盐可甜、坦荡豁达,待人真诚大方,自带异域氛围感。舞台上掌控力十足、表现力超强,气场张扬自带焦点,既能驾驭摇滚也能演绎温柔。`,
|
|
||||||
catchphrase: `放心,交给我。`,
|
|
||||||
skills: `摇滚主唱、贝斯演奏、舞台门面把控`,
|
|
||||||
track: `颜值赛道/摇滚主唱、流行摇滚赛道`,
|
|
||||||
bio: `虞浓是来自新疆乌鲁木齐的维吾尔族姑娘,从小在能歌善舞的民族氛围中长大,天生拥有过人的艺术天赋和舞台表现力。18 岁独自远赴北京闯荡,凭借优越的浓颜五官和高挑身材成为平面模特,深耕时尚杂志与广告拍摄,积累了极强的镜头感和时尚气场。但她始终怀揣着音乐梦想,尤其痴迷硬核流行摇滚,偶然一次地下 live 演出彻底坚定了她转型的决心。她从零开始深耕贝斯与声乐,日复一日泡在排练室打磨技艺,摒弃模特光环潜心深耕音乐领域。她因一组音乐节雨后后台生图在网络上收获了大量关注,明艳浓烈的异域五官、大气张扬的气质,瞬间俘获了很多人的目光。看似惊艳的颜值是她的舞台门面,扎实的摇滚功底才是她的核心底气。站上舞台的她掌控力十足,拨动琴弦、开嗓演唱的瞬间便能锁定全场视线,可盐可甜的性格反差,让她既能驾驭炸裂的摇滚舞台,也能温柔亲和地与粉丝互动。对虞浓而言,异域风情只是她的点缀,深耕热爱、肆意发光的摇滚灵魂,才是她征服舞台的终极底气。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `014`,
|
|
||||||
name: `温知予`,
|
|
||||||
enName: `YUKI`,
|
|
||||||
age: 23,
|
|
||||||
gender: `F`,
|
|
||||||
height: 166,
|
|
||||||
tags: [`chinese`] as ArtistTag[],
|
|
||||||
motto: `静水流深,初心不改。`,
|
|
||||||
personality: `清冷温婉、安静纯粹,恬淡内敛、温润素雅,自带江南古韵仙气,不慕浮华喧嚣。舞台上沉稳从容、气韵独特,摒弃浮夸动作,用古典底蕴碰撞流行,故事感十足。`,
|
|
||||||
catchphrase: `好,知道了。`,
|
|
||||||
skills: `大提琴演奏、竖琴演奏、抒情演唱`,
|
|
||||||
track: `大提琴手 / 抒情主唱、古典跨界赛道`,
|
|
||||||
bio: `温知予出生在浙江杭州的艺术世家,长辈深耕昆曲与古典音乐领域,自幼浸润在江南古韵与优雅旋律之中,三岁习竖琴,五岁练大提琴,多年的深耕沉淀出一身温润清冷的古典气质。她毕业于中央音乐学院大提琴专业,科班功底扎实,却不局限于古典音乐的固有框架。一段江南古桥细雨中的大提琴即兴演奏视频,让她意外出圈,温柔温婉的姿态、仙气十足的气质,搭配悠扬治愈的旋律,成为很多人心中的清冷白月光。她始终低调纯粹,拒绝流水线影视邀约与商业包装,一心深耕古典与现代音乐的跨界融合。在乐队中,她以大提琴的深沉搭配竖琴的灵动,中和现代旋律的凌厉,用温柔细腻的唱腔赋予歌曲别样的故事感。她的舞台从不用夸张的动作博眼球,仅凭音符与气质便能治愈人心,用古典底蕴碰撞现代流行,走出独属于自己的长线音乐之路。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `015`,
|
|
||||||
name: `韩臻臻`,
|
|
||||||
enName: `JOJO`,
|
|
||||||
age: 30,
|
|
||||||
gender: `F`,
|
|
||||||
height: 169,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `实力为基,坚持为翼。`,
|
|
||||||
personality: `飒爽沉稳、低调务实,成熟干练、内敛谦和,对待音乐极致严谨,坚守初心。舞台上专业度拉满、气场稳重从容,唱功全能发挥稳定,兼具力量感与共情力。`,
|
|
||||||
catchphrase: `我们可以的。`,
|
|
||||||
skills: `流行主唱、高音演唱、词曲创作`,
|
|
||||||
track: `实力主唱、流行赛道`,
|
|
||||||
bio: `韩臻臻自幼展露极致声乐天赋,六岁接受专业声乐训练,十岁便能轻松驾驭高音 C,被业内导师誉为极具潜力的声乐奇才。16 岁登台参加全国青年歌手大奖赛,凭借原创高难度曲目一举夺冠,年少成名。她对待音乐极致严谨,每一首作品都反复打磨、精修细节,绝不敷衍流水线创作。多年舞台历练,让她拥有极强的临场应变能力和舞台掌控力,高音通透有力、共情细腻,既能驾驭大气恢弘的曲风,也能演绎温柔细腻的抒情旋律。她以初心坚守音乐本心,持续用极具穿透力的嗓音,书写属于实力派歌手的音乐之路。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `016`,
|
|
||||||
name: `黎抒`,
|
|
||||||
enName: `LEE`,
|
|
||||||
age: 28,
|
|
||||||
gender: `F`,
|
|
||||||
height: 170,
|
|
||||||
tags: [`folk`] as ArtistTag[],
|
|
||||||
motto: `随心而往,随乐而行。`,
|
|
||||||
personality: `独立通透、随性淡然,安静文艺、清冷松弛,待人真诚坦荡,不刻意迎合世俗。舞台上温柔有力量、叙事感十足,嗓音独特辨识度高,用纯粹民谣治愈人心。`,
|
|
||||||
catchphrase: `ok,都一样。`,
|
|
||||||
skills: `民谣 / 流行演唱、词曲创作、吉他演奏`,
|
|
||||||
track: `创作主唱、独立民谣赛道`,
|
|
||||||
bio: `黎抒是中文系出身,毕业后入职出版社担任文学编辑,过着安稳规整的朝九晚五生活。但骨子里热爱自由、偏爱文字与旋律的她,始终不甘于被平淡的生活束缚。25 岁那年,她毅然辞去稳定工作,背着一把木吉他开启流浪之旅,走遍南方大小城市,在街边酒馆、小众清吧驻唱,把沿途的烟火人间、离别重逢、细碎情绪全部写进歌词。广州小酒馆的一次突发停电,黑暗之中她淡然落座,一把木吉他、一副独特沙哑的嗓音,清唱原创民谣,温柔又清冷的氛围感打动了很多网友。深耕独立民谣赛道的她,拒绝迎合主流市场,不做流水线情歌,只唱真实生活、真心感悟。作为乐队创作主唱,她包揽大量词曲创作,文字功底加持下的歌词细腻有深度,独特声线极具辨识度,安静温柔却充满力量,用最纯粹的民谣旋律,治愈每一个向往自由、心怀故事的听众。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `017`,
|
|
||||||
name: `楚瑶瑶`,
|
|
||||||
enName: `CRYSTAL`,
|
|
||||||
age: 26,
|
|
||||||
gender: `F`,
|
|
||||||
height: 164,
|
|
||||||
tags: [`folk`] as ArtistTag[],
|
|
||||||
motto: `以温柔治愈世界,以善意温暖岁月。`,
|
|
||||||
personality: `温柔暖心、耐心纯粹,温和柔软、接地气,共情力极强,自带邻家姐姐治愈气场。舞台上舒缓温柔、治愈感拉满,用轻柔琴声与温润和声抚平情绪,氛围松弛温暖。`,
|
|
||||||
catchphrase: `没事的,都会好的。`,
|
|
||||||
skills: `键盘演奏、和声、粉丝陪伴向内容`,
|
|
||||||
track: `键盘 / 和声、治愈系流行赛道`,
|
|
||||||
bio: `楚瑶瑶毕业于师范大学音乐系,毕业后成为一名小学音乐代课老师,长期陪伴孩子成长的经历,让她始终保持温柔纯粹、耐心治愈的性格。任教期间,她发现很多学生因学业压力陷入焦虑、情绪低落,便开始尝试创作舒缓温暖的原创旋律,在课堂上教孩子们演唱,用音乐治愈少年心事。后来她将自己的原创弹唱、温柔和声片段发布在网络平台,慢慢积累人气。她没有炸裂的舞台噱头,没有华丽的包装,仅凭长达一年的深夜直播陪伴、温柔细腻的琴声与歌声,陪伴和治愈了很多熬夜、焦虑、内耗的网友。自带邻家姐姐的亲和力,接地气、无距离感,是粉丝心中最温暖的情绪港湾。她擅长键盘演奏与和声编写,用温柔的旋律中和歌曲的凌厉感,为每一首作品注入温暖底色,坚守治愈系音乐赛道,用音乐传递温柔与力量。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `018`,
|
|
||||||
name: `仇甯`,
|
|
||||||
enName: `LYNN`,
|
|
||||||
age: 27,
|
|
||||||
gender: `F`,
|
|
||||||
height: 171,
|
|
||||||
tags: [`rock`] as ArtistTag[],
|
|
||||||
motto: `严于律己,温柔待人。`,
|
|
||||||
personality: `外冷内热、严谨自律,清冷寡言、低调内敛,对专业极致严苛,责任心极强。舞台上甜酷反差、张力十足,将舞蹈功底融入摇滚,唱跳编排质感拉满,表现力出彩。`,
|
|
||||||
catchphrase: `别偷懒,认真点。`,
|
|
||||||
skills: `架子鼓演奏、贝斯演奏、舞台编舞、唱跳演绎`,
|
|
||||||
track: `贝斯手、唱跳摇滚赛道`,
|
|
||||||
bio: `仇甯自幼深耕舞蹈领域,12 岁考入北京舞蹈学院附中专修现代舞,18 岁成为国内顶尖职业舞团的舞者,多年严苛的舞蹈训练,练就了她极致的体态线条、扎实的舞台功底和偏执的专业态度,常年稳居排练室最晚离场名单,被同行称作 “练功狂人”。本该深耕舞蹈舞台的她,却因一次演出意外膝盖重伤,被迫告别高强度舞蹈表演,打碎了多年的职业梦想。低谷期的她偶然接触摇滚音乐,被贝斯的沉稳厚重、架子鼓的强劲节奏深深治愈,从此开启跨界之路,从零开始自学贝斯与架子鼓。深夜排练室独自练琴调试的侧拍视频意外出圈,清冷疏离的神态、优越的体态线条、专注认真的模样收到很多人点赞。她将多年舞蹈功底融入摇滚舞台,独创极具张力的唱跳编排,对舞台细节极致严苛,为团队舞台质感保驾护航,用硬核的舞台实力,活出独属于自己的飒爽锋芒。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `019`,
|
|
||||||
name: `方凌`,
|
|
||||||
enName: `FANNY`,
|
|
||||||
age: 29,
|
|
||||||
gender: `F`,
|
|
||||||
height: 167,
|
|
||||||
tags: [`chinese`] as ArtistTag[],
|
|
||||||
motto: `以匠心传国风,以坚守续传统。`,
|
|
||||||
personality: `温婉古典、清冷雅致,沉稳内敛、低调专注,自带正统民乐气韵,匠心纯粹坚定。舞台上专业极致、匠心满满,演奏沉稳精准,打破传统与现代壁垒,尽显国风温柔力量。`,
|
|
||||||
catchphrase: `再练习一次好吗?`,
|
|
||||||
skills: `二胡演奏、长笛演奏、国风编曲、古风演唱`,
|
|
||||||
track: `民乐担当、国风古典赛道`,
|
|
||||||
bio: `方凌出身正统民乐世家,父母均为国家级民乐演奏家,四岁便开启练琴生涯,每日坚持六小时以上专业训练,数十年从未间断,基本功扎实到极致。18 岁考入中央音乐学院民乐系,毕业后进入中国广播民族乐团,成为乐团里年轻的二胡演奏者,常年参与国内外巡演,深耕正统民乐演奏多年,斩获不少专业奖项。常年的正统民乐熏陶,造就了她温婉清冷、严谨雅致的古典气质。但不甘于固守传统的她,厌倦了千篇一律的复刻演出,一心想要打破民乐与流行音乐的壁垒,让传统国风被更多年轻人看见。一次巡演后台校音花絮意外走红,她古典雅致的气质,收获大批国风爱好者关注。随后她致力于将二胡、长笛等传统民乐乐器与现代编曲融合,深耕国风改编与原创,以极致专业的态度,让传统民乐在新时代舞台焕发全新生机。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `020`,
|
|
||||||
name: `尹卓曜`,
|
|
||||||
enName: `ROY`,
|
|
||||||
age: 29,
|
|
||||||
gender: `M`,
|
|
||||||
height: 185,
|
|
||||||
tags: [`rock`] as ArtistTag[],
|
|
||||||
motto: `自由为魂,热爱为骨。`,
|
|
||||||
personality: `随性洒脱、痞帅温柔,自由不羁、不受拘束,自带港风复古慵懒质感,豁达通透。舞台上松弛感拉满、随性张扬,即兴演奏灵动炸裂,曲风醇厚复古,感染力极强。`,
|
|
||||||
catchphrase: `OK啦`,
|
|
||||||
skills: `主音吉他演奏、单簧管演绎、港风歌曲演绎、即兴 solo`,
|
|
||||||
track: `主音吉他手、港风复古摇滚赛道`,
|
|
||||||
bio: `尹卓曜成长于香港老街区,自幼浸润在黄金时代港乐与复古摇滚的氛围中,家中收藏大量经典老唱片与磁带,早早埋下音乐热爱的种子。少年时期的他洒脱不羁,偏爱机车改装与街头自由氛围,高中毕业后深耕机车改装行业,同时与志同道合的好友组建地下乐队,专职担任主音吉他手,常年游走于香港街头酒吧、露天街头演出。他不被刻板规则束缚,偏爱自由随性的舞台风格,擅长将复古港风旋律与摇滚律动结合,还自学单簧管,将小众乐器音色融入原创编曲,打造独属于自己的复古摇滚风格。一场暴雨后的街头义演路透,让他收获了不少关注,机车上随性弹吉他的模样,痞帅松弛的港风气质、自由狂野的舞台状态,瞬间击中很多路人。他的舞台从无刻意设计,全程松弛自然,即兴 solo 灵动炸裂,始终坚守自由热血的摇滚初心,用复古旋律致敬港乐黄金时代。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `021`,
|
|
||||||
name: `温景然`,
|
|
||||||
enName: `KINGSTON`,
|
|
||||||
age: 27,
|
|
||||||
gender: `M`,
|
|
||||||
height: 178,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `默默深耕,极致求精。`,
|
|
||||||
personality: `温润儒雅、沉稳低调,内敛谦和、温润通透,不爱张扬造势,做事踏实细致。舞台上专注极致、细腻走心,编曲演奏精准深情,擅长用温柔旋律勾勒情绪,共情力强。`,
|
|
||||||
catchphrase: `我来吧。`,
|
|
||||||
skills: `钢琴演奏、音乐制作、作曲编曲`,
|
|
||||||
track: `音乐制作人、钢琴演奏、抒情流行赛道`,
|
|
||||||
bio: `温景然年少展露极致音乐天赋,三岁学钢琴,十岁独立完成原创作曲,音乐感知力与创作天赋远超同龄人。本科考入中央音乐学院音乐制作专业,科班功底扎实,毕业后入职国内唱片公司,从事幕后编曲制作多年,是业内小有名气的音乐制作人。他为人低调内敛,极少公开露面,常年隐身幕后。一场颁奖礼突发演奏嘉宾缺席事故,作为幕后编曲的他临时救场,一袭西装端坐钢琴前,行云流水的即兴独奏、温润儒雅的气质引发热捧。作为音乐制作人,他擅长把控编曲、作曲与音乐质感,偏爱细腻治愈的抒情流行曲风,擅长用精准的乐理编排勾勒细腻情绪。沉稳温柔的性格、顶级的专业实力,让他成为团队最可靠的灵魂核心。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `022`,
|
|
||||||
name: `林铮`,
|
|
||||||
enName: `CHAIN`,
|
|
||||||
age: 33,
|
|
||||||
gender: `M`,
|
|
||||||
height: 186,
|
|
||||||
tags: [`rock`] as ArtistTag[],
|
|
||||||
motto: `以担当护团队,以真诚待伙伴。`,
|
|
||||||
personality: `沉稳可靠、幽默豁达,真诚坦荡、接地气,责任心极强,是团队的定心丸。舞台上稳重笃定、掌控全局,贝斯律动扎实稳健,统筹守护团队,低调靠谱担当十足。`,
|
|
||||||
catchphrase: `有我呢。`,
|
|
||||||
skills: `贝斯演奏、编曲制作、团队统筹`,
|
|
||||||
track: `队长 / 贝斯手、摇滚 / 流行赛道`,
|
|
||||||
bio: `土生土长的东北人林铮,18 岁应征入伍,五年武警军旅生涯,磨练出坚韧沉稳的意志、极强的责任心与绝佳的团队统筹能力,自带一身正直可靠的硬汉气质。退伍后,他从事健身教练工作,同时始终坚守年少热爱,自学贝斯演奏,深耕摇滚音乐领域,和好友组建乐队并担任队长。多年军旅生涯造就了他雷厉风行、靠谱稳重的处事风格,同时自带东北人天生的幽默豁达,待人真诚接地气,是团队公认的定海神针。一场户外义演的设备突发故障,现场支架即将倾倒,他单手稳稳稳住设备,从容安抚全场观众,冷静靠谱的救场画面在网络上传播,收获了少量粉丝。作为乐队队长与贝斯手,他统筹团队大小事务,把控舞台安全与作品质量,深耕摇滚与流行融合曲风,用沉稳的贝斯律动稳住全队节奏,以极强的责任感,守护团队稳步前行。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `023`,
|
|
||||||
name: `高翊宸`,
|
|
||||||
enName: `ALSTON`,
|
|
||||||
age: 30,
|
|
||||||
gender: `M`,
|
|
||||||
height: 184,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `以艺术为信仰,以专业为基石。`,
|
|
||||||
personality: `温润贵气、专业严谨,儒雅通透、谦和有礼,对待艺术心怀敬畏,不慕浮华。舞台上端庄雅致、表现力高级,美声跨界兼具力量与美感,舞台叙事感饱满,质感拉满。`,
|
|
||||||
catchphrase: `完美。`,
|
|
||||||
skills: `美声高音演唱、音乐剧跨界、小提琴演奏`,
|
|
||||||
track: `高音担当 / 主唱、音乐剧跨界赛道`,
|
|
||||||
bio: `高翊宸出身艺术世家,自小浸泡在正统古典艺术氛围中,三岁启蒙小提琴,五岁系统学习美声唱法,从小接受严苛的艺术训练,深耕古典艺术领域二十余年,练就了极致扎实的科班功底与端正雅致的艺术气质。他自幼对艺术怀揣极致敬畏之心,做任何演绎、练习都力求精益求精,从不敷衍潦草,常年的打磨让他拥有了精准到极致的艺术感知力。私下的他温润谦和、低调内敛,待人处事分寸得当,自带温润端庄的青年艺术家风骨。剧院中,他兼具力量与细腻的美声跨界演绎,流畅高级的舞台叙事感,让无数观众感受到古典艺术与流行音乐融合的独特魅力。深耕音乐剧与美声跨界赛道的他,打破了古典艺术小众固化的壁垒,摒弃传统美声的刻板厚重,将轻盈的流行律动、细腻的情感表达融入正统唱法之中。站上舞台的他端庄大气、气场雅致,他以正统艺术底蕴赋能现代舞台,用跨界演绎让古典艺术焕发新生,凭极致专业的舞台素养,成为圈内小众且优质的实力派新人。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `024`,
|
|
||||||
name: `江驰`,
|
|
||||||
enName: `CHILL`,
|
|
||||||
age: 26,
|
|
||||||
gender: `M`,
|
|
||||||
height: 179,
|
|
||||||
tags: [`hiphop`] as ArtistTag[],
|
|
||||||
motto: `音乐无国界,潮流我定义。`,
|
|
||||||
personality: `潮酷随性、阳光痞帅,开朗洒脱、真诚坦荡,审美前卫独到,创意十足。舞台上活力四射、气场全开,Rap 节奏精准炸裂,架子鼓充满力量,先锋编曲自带国际质感。`,
|
|
||||||
catchphrase: `酷。`,
|
|
||||||
skills: `Rap 演唱、架子鼓手、先锋编曲、舞台设计`,
|
|
||||||
track: `Rapper / 先锋制作人、国际流行赛道`,
|
|
||||||
bio: `江驰出生于美国洛杉矶的华人家庭,自幼浸润在多元国际文化氛围中,深受欧美嘻哈、先锋流行音乐熏陶。十岁专攻架子鼓,十五岁便能登台洛杉矶地下 Livehouse 演出,是当地小有名气的新生代华人鼓手,常年接触国际前沿的音乐制作与舞台设计理念,审美与实力接轨国际。24 岁,他选择回国发展,立志将海外先锋音乐理念与华语音乐融合,打破本土音乐的风格壁垒。一场街头即兴 Rap Battle 现场,他无彩排即兴控场,节奏切分新潮炸裂,阳光痞帅的气质、潮酷十足的舞台风格,收获了不少年轻粉丝的关注。他身兼 rapper、鼓手、先锋制作人多重身份,亲自打磨编曲、舞台设计、舞台演绎,拒绝流水线套路,主打新潮、先锋、国际化的音乐风格,用顶级的舞台实力与创新思维,打造年轻化、潮流化的华语流行新形态。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `025`,
|
|
||||||
name: `曾辙`,
|
|
||||||
enName: `JACK`,
|
|
||||||
age: 28,
|
|
||||||
gender: `M`,
|
|
||||||
height: 176,
|
|
||||||
tags: [`folk`] as ArtistTag[],
|
|
||||||
motto: `声音有温度,音乐有力量。`,
|
|
||||||
personality: `温柔沉稳、高情商通透,谦和内敛、待人真诚,善于体察情绪,是团队情绪粘合剂。舞台上温润治愈、共情力极强,唱腔醇厚磁性,和声细腻和谐,仅凭歌声打动人心。`,
|
|
||||||
catchphrase: `别急,慢慢来。`,
|
|
||||||
skills: `实力演唱、和声编写、抒情演绎`,
|
|
||||||
track: `主唱 / 和声担当、治愈系流行赛道`,
|
|
||||||
bio: `曾辙毕业于中国传媒大学播音主持专业,拥有极佳的声线条件与语言共情力,毕业后任职深夜电台配音主播,多年深夜播音经历,让他的声线温润醇厚、自带治愈质感,常年陪伴无数失眠听众。工作之余,他始终尝试声乐与和声编写,利用业余时间打磨唱功、创作原创歌曲,唱功扎实、共情力极强。一场户外直播突发设备故障,现场嘈杂混乱,他仅凭一段纯净温柔的和声,瞬间抚平全场躁动。他是擅长捕捉细腻情绪,将平凡的歌词与旋律演绎出满满的画面感,深耕治愈系流行赛道,不追求炸裂噱头,只用温柔纯粹的歌声治愈人心。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `026`,
|
|
||||||
name: `范智演`,
|
|
||||||
enName: `CY`,
|
|
||||||
age: 30,
|
|
||||||
gender: `M`,
|
|
||||||
height: 178,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `颠覆传统,创造未来。`,
|
|
||||||
personality: `特立独行、先锋前卫,清冷孤傲、不随波逐流,对艺术有极致追求,创意大胆。舞台上张力拉满、极具颠覆性,电音制作先锋炸裂,舞台设计冲击力强,视听体验极致。`,
|
|
||||||
catchphrase: `炸了。`,
|
|
||||||
skills: `电音制作、鼓手、Rap 演唱、舞台艺术设计`,
|
|
||||||
track: `音乐总监 / 制作人、先锋电音赛道`,
|
|
||||||
bio: `范智演自幼兼具美术与音乐双重艺术天赋,对视觉、听觉艺术拥有极致敏锐的感知力。大学远赴德国留学,深耕先锋艺术与电子音乐制作,潜心研究极简主义与暴力美学的艺术融合,曾亮相欧洲先锋艺术周,是业内极具辨识度的新锐视觉导演与音乐制作人。他的艺术风格极具颠覆性,擅长打破常规、解构传统,打造极具冲击力的先锋作品。一场午夜先锋 live 秀,他操控鼓棒与采样器,将破碎的电音底噪与炸裂的说唱节奏完美融合,癫狂又极致专业的舞台状态、先锋潮流的艺术质感,受到了大批先锋乐迷的关注。加入乐队担任音乐总监后,他全权把控团队音乐风格与舞台视觉设计,对音色、节奏、舞台细节偏执苛求,擅长解构重组音轨,打造独一无二的先锋电音风格。他不迎合大众主流审美,坚持自我先锋艺术理念,用超强的创作能力与舞台审美,引领小众潮流音乐出圈。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `027`,
|
|
||||||
name: `周新宇`,
|
|
||||||
enName: `JOE`,
|
|
||||||
age: 29,
|
|
||||||
gender: `M`,
|
|
||||||
height: 178,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `用音乐记录都市,用旋律治愈孤独。`,
|
|
||||||
personality: `沉稳内敛、低调务实,理性通透、思维缜密,兼具程序员严谨与音乐人感性。舞台上专注投入、氛围感十足,词曲贴近都市生活,用旋律讲述喜怒哀乐,代入感极强。`,
|
|
||||||
catchphrase: `我试试。`,
|
|
||||||
skills: `词曲创作、吉他演奏、音乐制作`,
|
|
||||||
track: `全能制作人、都市流行赛道`,
|
|
||||||
bio: `周新宇本科攻读计算机专业,毕业后成为一名程序员,深耕互联网行业。但他从未放弃自幼热爱的音乐,利用下班、深夜的碎片化时间,自学吉他、词曲创作、后期混音,从零搭建个人小型音乐工作室,被圈内人称作 “深夜搬砖音乐人”。常年的都市职场生活,让他深谙当代年轻人的压力与孤独,创作灵感大多源于真实的都市烟火。他因发起 “一人成军” 创作挑战受到关注,狭小的出租屋内,他独自包揽词曲、演奏、后期全流程,融入地铁、雨夜、街道等真实城市白噪音,打造极具代入感的都市流行旋律。他凭借顶级的全能创作能力,主打生活化、共情力极强的都市流行曲风,拒绝流水线模板化创作,每一首作品都扎根现实、贴近人心,用细腻的旋律,讲述当代都市人的喜怒哀乐。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `028`,
|
|
||||||
name: `刘影`,
|
|
||||||
enName: `SHADOW`,
|
|
||||||
age: 38,
|
|
||||||
gender: `F`,
|
|
||||||
height: 170,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `音乐是灵魂的栖息地。`,
|
|
||||||
personality: `清冷疏离、特立独行,安静淡然、不慕名利,厌倦商业化束缚,偏爱山野生活。舞台上空灵治愈、仙气十足,嗓音天籁极具辨识度,另类摇滚演绎充满生命力。`,
|
|
||||||
catchphrase: `随缘。`,
|
|
||||||
skills: `流行演唱、词曲创作、空灵抒情演绎`,
|
|
||||||
track: `抒情主唱、长线 IP 赛道`,
|
|
||||||
bio: `刘影曾是华语乐坛黄金时代出道的歌手,19 岁正式出道,凭借独一无二的空灵天籁嗓音、小众高级的音乐风格收获了一些关注,留下过传唱度较高的歌曲。事业上升期的她,厌倦了流量内卷与规则束缚,毅然选择淡出乐坛,隐居大理山野,远离喧嚣浮华,潜心沉淀自我、打磨音乐心境。隐居多年,她极少公开露面,唯一的动态是一段深山溶洞无伴奏清唱视频,纯天然的空灵嗓音、不带烟火气的纯粹唱腔,搭配自然水声回响,让无数老粉与文艺爱好者为之动容,悄然回归大众视野。她坚持原创、坚守艺术本心,不迎合流量、不妥协市场,用空灵治愈的声线,打造长线艺术生命力。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `029`,
|
|
||||||
name: `君墨`,
|
|
||||||
enName: `CHAO`,
|
|
||||||
age: 37,
|
|
||||||
gender: `F`,
|
|
||||||
height: 172,
|
|
||||||
tags: [`chinese`, `rock`] as ArtistTag[],
|
|
||||||
motto: `以戏曲为根,以摇滚为魂。`,
|
|
||||||
personality: `英气沉稳、专业严谨,坚韧独立、做事认真,自带戏曲端庄气场,敢于跨界突破。舞台上气场全开、英气逼人,越剧与摇滚完美融合,雌雄莫辨的气场极具颠覆性。`,
|
|
||||||
catchphrase: `可。`,
|
|
||||||
skills: `越剧演唱、架子鼓演奏、国风编曲`,
|
|
||||||
track: `国风担当、国风摇滚赛道`,
|
|
||||||
bio: `君墨出身绍兴越剧世家,母亲是国家级越剧表演艺术家,自幼泡在剧团长大,耳濡目染深耕戏曲艺术,15 岁正式拜师学戏,专攻小生反串,身段利落、唱腔醇厚,年纪轻轻便成为剧团台柱子,是业内公认的越剧新星。看似深耕传统戏曲的她,骨子里藏着极致的叛逆与热爱,私下痴迷摇滚音乐,无人教导便凭着极致的自律与专业,自学架子鼓、国风编曲,默默探索戏曲与摇滚的融合之路。一次下乡公益演出,现场伴奏设备突发故障,舞台中断,她临场应变,褪去戏袍、卸下戏曲妆容,手持鼓棒即兴敲出强劲摇滚节奏,将经典越剧唱段与硬核摇滚旋律完美融合,雌雄莫辨的英气气场、颠覆性的舞台演绎,让在场的所有人印象深刻。自此,她彻底打破传统戏曲与现代音乐的壁垒,走出独一无二的国风摇滚跨界之路。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `030`,
|
|
||||||
name: `李叙`,
|
|
||||||
enName: `SEAN`,
|
|
||||||
age: 28,
|
|
||||||
gender: `M`,
|
|
||||||
height: 176,
|
|
||||||
tags: [`folk`] as ArtistTag[],
|
|
||||||
motto: `平凡日子里,也有温柔回响。`,
|
|
||||||
personality: `温和沉稳、接地气通透,内敛真诚、高情商,深谙普通人辛酸,习惯平视生活。舞台上温暖治愈、共情力极强,嗓音自带生活磨砺质感,用简单旋律戳中内心柔软。`,
|
|
||||||
catchphrase: `好的。`,
|
|
||||||
skills: `民谣弹唱、词曲创作、木吉他演奏`,
|
|
||||||
track: `民谣主唱、都市治愈民谣赛道`,
|
|
||||||
bio: `李叙大学毕业后,进入一家互联网公司做产品经理,一做就是五年。这五年里,他每天过着 996 的生活,加班到深夜是家常便饭,经历过无数次的项目改稿和职场内卷,深知社畜的辛酸与不易。为了排解工作和生活的压力,他开始自学吉他,把自己的经历和感受写进歌里。他的故事没有奇迹,只有共鸣:他没有在专业的录音棚,而是在合租房窄小的阳台上,就着路灯的光,抱着那把边角磨损的木吉他,用那副自带生活磨砺感的温暖烟嗓,平淡地唱出一句关于 “下班后在车里发呆” 的原创歌词,戳中了无数社畜内心最隐秘的孤独。他亲自操刀词曲创作,坚持保留音乐里那份最原始的呼吸感,用最简单的旋律,为每一个在深夜里自我怀疑的灵魂,点燃一盏不必太亮却足够暖心的灯。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `031`,
|
|
||||||
name: `金晓璐`,
|
|
||||||
enName: `KIM`,
|
|
||||||
age: 31,
|
|
||||||
gender: `F`,
|
|
||||||
height: 168,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `以琴为始,以歌为终。`,
|
|
||||||
personality: `温婉大气、低调谦和,沉稳内敛、待人真诚,毫无明星架子,亲和力拉满。舞台上从容优雅、气场温润,钢琴演奏行云流水,将古典功底与流行完美融合。`,
|
|
||||||
catchphrase: `谢谢。`,
|
|
||||||
skills: `流行演唱、钢琴演奏`,
|
|
||||||
track: `主唱 / 键盘手、经典流行赛道`,
|
|
||||||
bio: `金晓璐出生在一个钢琴世家,父亲是著名的钢琴教育家,母亲是钢琴演奏家。她三岁开始学习钢琴,五岁登台演出,十岁获得全国钢琴比赛冠军,被誉为 “钢琴神童”。长大后,她成为了一名职业钢琴演奏家,在国内外举办过多场个人独奏音乐会,是业内公认的实力派钢琴家。但她内心一直渴望尝试不同的音乐风格,不满足于只演奏古典音乐。她的转折点发生在一次极其 “失态” 的意外:在某次严肃的古典音乐会现场,面对台下昏昏欲睡的观众,她突然中断了练习过万次的肖邦,转而用那副被乐评人称为 “温婉大气” 却暗藏力量的嗓音,即兴拆解了一段流行金曲。这种对规则的低调真诚反击,让她在社交媒体上凭借极具辨识度的经典淡颜感,收获了大量关注。她不再甘于只做背景板,而是以另一种身份重回经典流行赛道,比起精致的滤镜,她更愿意展示自己在琴房练到指尖红肿的真实,用成熟女性的韧性,重新定义自己的音乐之路。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `032`,
|
|
||||||
name: `莫晴`,
|
|
||||||
enName: `MOMO`,
|
|
||||||
age: 11,
|
|
||||||
gender: `F`,
|
|
||||||
height: 145,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `音乐是我的好朋友。`,
|
|
||||||
personality: `元气灵动、懂事乖巧,开朗活泼、心思细腻,共情力远超同龄人,自律坚韧。舞台上清澈治愈、元气满满,童声干净透亮,小提琴悠扬动听,自然纯真无表演痕迹。`,
|
|
||||||
catchphrase: `好呀好呀。`,
|
|
||||||
skills: `童声演唱、小提琴演奏、舞台表演、短视频陪伴内容创作`,
|
|
||||||
track: `乐队童声主唱、乐队小提琴手、养成系偶像赛道`,
|
|
||||||
bio: `莫晴出生在杭州,父母均为浙江音乐学院的小提琴教师,她从 3 岁起就踮着脚趴在琴凳上看父母练琴,展现出远超同龄人的音乐感知力。5 岁时她就能完整演奏《小星星变奏曲》,6 岁首次登台参加全国少儿小提琴比赛便斩获金奖,被评委誉为 “极具潜力的小提琴神童”。妈妈随手分享的一段睡前练琴视频:扎着羊角辫的她抱着比自己还高的小提琴,认真拉完一曲后,对着镜头露出缺了一颗门牙的可爱笑容,治愈了很多网友。虽然年纪尚小,但莫晴格外懂事自律,每天坚持练琴 ,同时从未落下学业,还会在短视频平台分享自己的练琴日常和暖心小故事。她清澈透亮的童声与悠扬的小提琴声,为观众增添了独一无二的治愈色彩。对她而言,音乐不是任务,而是和世界对话的方式。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `033`,
|
|
||||||
name: `裴靖`,
|
|
||||||
enName: `JAY`,
|
|
||||||
age: 14,
|
|
||||||
gender: `M`,
|
|
||||||
height: 168,
|
|
||||||
tags: [`pop`] as ArtistTag[],
|
|
||||||
motto: `摇滚少年,永不言弃。`,
|
|
||||||
personality: `阳光开朗、活泼好动,热情真诚、精力旺盛,自带少年元气冲劲,坚韧不服输。舞台上活力四射、爆发力强,电吉他演奏灵动炸裂,充满少年热血与青春激情。`,
|
|
||||||
catchphrase: `冲!`,
|
|
||||||
skills: `电吉他演奏、木吉他弹唱、架子鼓演奏、舞台表演`,
|
|
||||||
track: `节奏 / 主音吉他手、养成系偶像赛道`,
|
|
||||||
bio: `裴靖的父亲曾是北京一支地下摇滚乐队的主音吉他手,受父亲影响,他从 6 岁起就抱着比自己还大的电吉他摸索琴弦,展现出惊人的天赋。9 岁时他就能完成高难度的速弹即兴 solo,12 岁获得全国少儿电吉他大赛总冠军,被业内前辈称为 “未来的吉他新星”。他性格活泼好动,是学校篮球队的主力后卫,走到哪里都带着满满的元气。穿着校服的他抱着电吉他在舞台上尽情挥洒汗水,阳光帅气的脸庞和极具爆发力的演奏,收获了不少同学的喜爱。虽然年纪不大,但裴靖对音乐有着近乎偏执的热爱,每天放学后都会泡在排练室练琴到天黑,手指磨出茧子也从不叫苦。作为吉他手,他用充满活力的演奏,为世界注入了年轻的热血与朝气。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `034`,
|
|
||||||
name: `Kael Walker`,
|
|
||||||
enName: `KAEL`,
|
|
||||||
age: 20,
|
|
||||||
gender: `M`,
|
|
||||||
height: 190,
|
|
||||||
tags: [`folk`] as ArtistTag[],
|
|
||||||
motto: `Music brighten everywhere.`,
|
|
||||||
personality: `安静内敛、温柔绅士,谦和有礼、待人真诚,自带加州松弛感,偏爱独处创作。舞台上判若两人、感染力极强,电吉他演奏充满力量,演唱温柔治愈,反差魅力独特。`,
|
|
||||||
catchphrase: `Cheers.`,
|
|
||||||
skills: `电吉他演奏、木吉他弹唱、流行演唱、即兴词曲创作`,
|
|
||||||
track: `主音吉他手、欧美流行赛道、治愈系偶像赛道`,
|
|
||||||
bio: `Kael Walker 出生在美国加州圣塔莫尼卡的一个艺术家庭,父亲是知名音乐制作人,母亲是前维多利亚的秘密超模。他从小在阳光明媚的海边长大,12 岁生日时父亲送了他一把电吉他,从此便与音乐结下不解之缘。16 岁时,他在 YouTube 上传了一段自己在海边弹唱《Here Comes The Sun》的视频,凭借干净帅气的外形和温柔治愈的烟嗓受到关注,被网友亲切地称为 “加州阳光”。私下里的 Kael 安静内敛,最大的爱好是冲浪、摄影和写歌,常常一个人带着吉他去海边,把海风、日落和海浪的声音写进旋律里。舞台上的他却判若两人,抱着电吉他时眼神坚定,演奏充满力量。他拒绝了多家主流唱片公司的单独签约邀约,选择加入乐队,希望和伙伴们一起创造出更有温度、更有灵魂的音乐。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `035`,
|
|
||||||
name: `Anthony Williams`,
|
|
||||||
enName: `TRUMPET`,
|
|
||||||
age: 20,
|
|
||||||
gender: `M`,
|
|
||||||
height: 190,
|
|
||||||
tags: [`jazz`] as ArtistTag[],
|
|
||||||
motto: `Music is the voice of the soul.`,
|
|
||||||
personality: `慵懒随性、清冷孤傲,独立自我、不被束缚,自带贵族高级疏离感,浪漫细腻。舞台上慵懒迷人、氛围感拉满,爵士小号悠扬慵懒,融合复古与现代,气质独一无二。`,
|
|
||||||
catchphrase: `Well.`,
|
|
||||||
skills: `爵士小号演奏、高级时装走秀、黑胶采样制作、即兴编曲`,
|
|
||||||
track: `小号手、时尚先锋模特赛道、爵士融合赛道`,
|
|
||||||
bio: `Anthony Williams 出身于英国伦敦的一个贵族家庭,从小接受严格的精英教育,学习钢琴、马术和古典绘画,但他唯独对爵士小号情有独钟。7 岁时,他在一场爵士音乐节上听到小号演奏家 Miles Davis 的演出,瞬间被那种慵懒又充满故事感的音色吸引,从此便全身心投入到爵士小号的学习中,师从英国著名爵士小号手 。14 岁时,他在伦敦街头被顶级时尚星探发掘,凭借近乎雕塑般的深邃五官和 190CM 的优越身材,迅速成为时尚界的宠儿,16 岁首次登上T台,为品牌走秀。即使在时尚界声名鹊起,Anthony 也从未放弃自己的音乐梦想,他常常在走秀间隙带着小号去伦敦的老爵士酒吧即兴演奏,那种将高级时尚与复古爵士完美融合的独特气质,让人印象深刻。`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
no: `036`,
|
|
||||||
name: `Sylvia Ross`,
|
|
||||||
enName: `SYLVIA`,
|
|
||||||
age: 18,
|
|
||||||
gender: `F`,
|
|
||||||
height: 172,
|
|
||||||
tags: [`rock`] as ArtistTag[],
|
|
||||||
motto: `Girls Bravo!`,
|
|
||||||
personality: `直爽开朗、率真可爱,独立坚强、敢想敢做,热爱自由,喜欢小动物反差感十足。舞台上气场全开、爆发力极强,全能驾驭多种乐器,用摇滚打破对女孩的刻板印象`,
|
|
||||||
catchphrase: `I love it!`,
|
|
||||||
skills: `架子鼓演奏、贝斯演奏、电吉他 / 木吉他弹唱、摇滚演唱`,
|
|
||||||
track: `全能乐器担当、欧美摇滚赛道、酷飒偶像赛道`,
|
|
||||||
bio: `Sylvia Ross 出生在加拿大温哥华的一个摇滚家庭,父母都是当地知名朋克乐队的成员,她从小在排练室长大,耳边永远是吉他和鼓声的轰鸣,摇篮曲都是父母演奏的摇滚歌曲。受家庭氛围熏陶,她 5 岁开始学习架子鼓,7 岁学贝斯,10 岁学吉他,15 岁就和三个好朋友组建了自己的少女摇滚乐队 “Silver Hair”,在温哥华的地下 Livehouse 演出,凭借极具爆发力的演奏和酷飒的舞台风格,成为当地小有名气的少女摇滚偶像。留着标志性银白发的她在舞台上疯狂打鼓,汗水顺着发丝滴落,眼神里充满了野性的生命力,酷飒的舞台风格收获了不少年轻粉丝的喜爱。私下里的 Sylvia 直爽开朗,喜欢滑板、涂鸦和收养流浪猫,毫无偶像包袱。她能熟练驾驭多种乐器,用自己的音乐,打破人们对女孩的刻板印象。`,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import NextAuth, { type NextAuthConfig } from "next-auth";
|
import NextAuth, { type NextAuthConfig } from "next-auth";
|
||||||
import Credentials from "next-auth/providers/credentials";
|
import Credentials from "next-auth/providers/credentials";
|
||||||
import { prisma } from "./prisma";
|
import { prisma } from "./prisma";
|
||||||
import { consumeOtp } from "./otp-store";
|
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -102,15 +101,23 @@ export const authConfig: NextAuthConfig = {
|
|||||||
export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);
|
export const { handlers, signIn, signOut, auth } = NextAuth(authConfig);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 校验 OTP 验证码。
|
* 校验 OTP 验证码(开发态:固定接受 "123456")。
|
||||||
* - 开发态万能码 "123456" 始终通过(仅 NODE_ENV !== "production",不走 SMS)
|
* 生产态:从 Redis 读取并比对,校验后删除避免重放。
|
||||||
* - 真实码通过 consumeOtp 核对(Redis 或进程内 Map,由 otp-store 决定);
|
|
||||||
* 校验通过后立即销毁该码防止重放
|
|
||||||
*/
|
*/
|
||||||
async function verifyOtp(phone: string, code: string): Promise<boolean> {
|
async function verifyOtp(phone: string, code: string): Promise<boolean> {
|
||||||
if (process.env.NODE_ENV !== "production" && code === "123456") {
|
if (process.env.NODE_ENV !== "production" && code === "123456") {
|
||||||
console.log(`[dev-otp] 手机号 ${phone} 使用万能码 123456 通过`);
|
console.log(`[dev-otp] 手机号 ${phone} 使用万能码 123456 通过`);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return await consumeOtp(phone, code);
|
|
||||||
|
// TODO[团队]: 接入 Redis
|
||||||
|
// const redis = getRedis();
|
||||||
|
// if (!redis) return false;
|
||||||
|
// const key = `sms:otp:${phone}`;
|
||||||
|
// const stored = await redis.get(key);
|
||||||
|
// if (!stored || stored !== code) return false;
|
||||||
|
// await redis.del(key);
|
||||||
|
// return true;
|
||||||
|
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +0,0 @@
|
|||||||
/**
|
|
||||||
* 日期工具 · 跨 API 共享, 避免日期 / 时区 相关 bug。
|
|
||||||
*
|
|
||||||
* 为什么用 UTC 而不是本地时区?
|
|
||||||
* MySQL @db.Date 列存储时只保留日期部分。如果用 setHours(0,0,0,0) 取本地午夜,
|
|
||||||
* JS Date 对应的 UTC 时刻在 +8 区是当日 16:00 UTC, 经过 Prisma 序列化 + MySQL
|
|
||||||
* TZ 转换后, 存的"日期"和读的"日期"可能差一天, 导致 dailyQuota 复合键 (userId, date)
|
|
||||||
* 找不到刚 upsert 的行 (P2025)。
|
|
||||||
*
|
|
||||||
* 用 setUTCHours(0,0,0,0) 拿到的是 "今天的 UTC 0 点", 无论服务器 / 客户端 TZ,
|
|
||||||
* 存取一致。代价: 中国用户在 08:00 之前(UTC 0 点之前)算"昨天"。对于按日额度场景
|
|
||||||
* 足够好(短期可接受, 长期需要按"运营日"显式划分)。
|
|
||||||
*/
|
|
||||||
export function startOfUtcDay(d = new Date()): Date {
|
|
||||||
const x = new Date(d);
|
|
||||||
x.setUTCHours(0, 0, 0, 0);
|
|
||||||
return x;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 判断两个 Date 是否同一个 UTC 日期 */
|
|
||||||
export function isSameUtcDay(a: Date, b: Date): boolean {
|
|
||||||
return (
|
|
||||||
a.getUTCFullYear() === b.getUTCFullYear() &&
|
|
||||||
a.getUTCMonth() === b.getUTCMonth() &&
|
|
||||||
a.getUTCDate() === b.getUTCDate()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@ -1,95 +1,124 @@
|
|||||||
import type { Artist } from "@/types/artist";
|
import type { Artist, ArtistTag } from "@/types/artist";
|
||||||
import { ARTIST_SEEDS } from "./artist-bios";
|
|
||||||
import { tosUrl } from "./tos";
|
|
||||||
|
|
||||||
/**
|
const STAGE_NAMES: Array<[string, string, string]> = [
|
||||||
* 真实艺人数据装配层。
|
["艺奈", "AURORA", "破晓极光"],
|
||||||
* 所有人物字段(姓名 / 性别 / 年龄 / 身高 / 性格 / 技能 / 赛道 / 口头禅 /
|
["路米", "LUMI", "暖光治愈"],
|
||||||
* 座右铭 / 长简介)来自《36 位虚拟艺人人物小传.docx》。
|
["星澪", "NEBULA", "星云吟唱"],
|
||||||
* 立绘 / 三视图 / 氛围图 / solo 视频 走火山 TOS 桶(webp + mp4 已压缩到 1/10)。
|
["凯", "KAI", "海岸少年"],
|
||||||
*
|
["回音", "ECHO", "声波女王"],
|
||||||
* 票数 / 排名是运行时计算(store 投票后会更新)。除此之外不再有任何虚构数据。
|
["薇尔", "VEIL", "薄雾低语"],
|
||||||
*/
|
["艾莉雅", "ARIA", "咏叹之声"],
|
||||||
|
["怜", "REN", "莲华少女"],
|
||||||
|
["米拉", "MIRA", "镜面舞者"],
|
||||||
|
["诺娃", "NOVA", "超新星"],
|
||||||
|
["纪罗", "KIRO", "Rap 制造机"],
|
||||||
|
["瑞", "ZUI", "醉月夜"],
|
||||||
|
["阳", "SOL", "阳光少年"],
|
||||||
|
["凛", "LIN", "学院偶像"],
|
||||||
|
["律", "LYRA", "竖琴公主"],
|
||||||
|
["昕", "DAWN", "晨曦少女"],
|
||||||
|
["天", "SKY", "天空之翼"],
|
||||||
|
["语", "ARIE", "诗与远方"],
|
||||||
|
["翼", "WING", "飞翔之翼"],
|
||||||
|
["铃", "CHIME", "风铃声"],
|
||||||
|
["夜", "NYX", "暗夜女神"],
|
||||||
|
["晴", "SUNNY", "晴空万里"],
|
||||||
|
["月", "LUNA", "月光女神"],
|
||||||
|
["岚", "STORM", "暴风之子"],
|
||||||
|
["雷", "BOLT", "雷霆速度"],
|
||||||
|
["焰", "FLARE", "火焰之心"],
|
||||||
|
["雪", "FROST", "霜花少女"],
|
||||||
|
["林", "LEAF", "森林精灵"],
|
||||||
|
["渊", "ABYSS", "深渊之声"],
|
||||||
|
["瑶", "JADE", "翡翠少女"],
|
||||||
|
["晨", "AURIA", "金色晨光"],
|
||||||
|
["岩", "ROCK", "硬核摇滚"],
|
||||||
|
["翔", "SOAR", "翱翔天际"],
|
||||||
|
["茉", "MOLLY", "茉莉芬芳"],
|
||||||
|
["梓", "AZUR", "蓝调诗人"],
|
||||||
|
];
|
||||||
|
|
||||||
/** 没有 solo.mp4 的艺人编号(docx 标注"缺视频")。
|
// 4 个新主标签均匀分布。每位艺人 1-2 个标签,便于筛选器命中。
|
||||||
003/010/017/027 在 v2 物料里已补上,033 已替换,这些都从集合里移除。 */
|
const TAG_POOL: ArtistTag[][] = [
|
||||||
const MISSING_VIDEO: ReadonlySet<string> = new Set<string>();
|
["vocal"],
|
||||||
|
["dance"],
|
||||||
|
["all-rounder"],
|
||||||
|
["rap"],
|
||||||
|
["vocal", "all-rounder"],
|
||||||
|
["dance", "all-rounder"],
|
||||||
|
["rap", "all-rounder"],
|
||||||
|
["vocal"],
|
||||||
|
["dance"],
|
||||||
|
["all-rounder"],
|
||||||
|
["rap"],
|
||||||
|
["vocal", "dance"],
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
const THEME_COLORS = [
|
||||||
* 自定义封面:这些艺人的卡片/详情主立绘改用单独上传的 cover 图,
|
"#8b5cf6",
|
||||||
* 而不是默认的氛围图1。氛围图1/2/3 在画廊里保持不变。
|
"#ec4899",
|
||||||
* (调整.xlsx 里 "封面图用氛围图N" 的实现 —— 新封面已转 webp 上传到
|
"#06b6d4",
|
||||||
* portraits/{no}-cover.webp,这里把 portrait 字段切到该路径。)
|
"#f59e0b",
|
||||||
*/
|
"#10b981",
|
||||||
const CUSTOM_COVERS: ReadonlySet<string> = new Set([
|
"#ef4444",
|
||||||
"002",
|
"#a78bfa",
|
||||||
"003",
|
"#f472b6",
|
||||||
"005",
|
"#38bdf8",
|
||||||
"012",
|
"#fbbf24",
|
||||||
"013",
|
"#34d399",
|
||||||
"014",
|
"#fb7185",
|
||||||
"019",
|
];
|
||||||
"025",
|
|
||||||
]);
|
|
||||||
|
|
||||||
/** 画廊 = 三张氛围图(1/2/3)。不包含三视图,因为长宽比与卡片不一致。 */
|
|
||||||
function buildGallery(no: string): string[] {
|
|
||||||
return [
|
|
||||||
tosUrl(`portraits/${no}.webp`),
|
|
||||||
tosUrl(`portraits/${no}-2.webp`),
|
|
||||||
tosUrl(`portraits/${no}-3.webp`),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
|
/** 生成确定性 35 位艺人 mock 数据 */
|
||||||
function buildArtists(): Artist[] {
|
function buildArtists(): Artist[] {
|
||||||
return ARTIST_SEEDS.map((seed, idx) => {
|
return STAGE_NAMES.map(([name, enName, slogan], idx) => {
|
||||||
|
const no = String(idx + 1).padStart(3, "0");
|
||||||
|
const rank = idx + 1;
|
||||||
|
// 票数采用反比例衰减(确定性 · 避免 SSR/CSR hydration 不一致)
|
||||||
|
// 主曲线:125000/√rank · 用 idx 推导抖动量保持稳定分布
|
||||||
|
const jitter = ((idx * 1103) % 2999) - 1499;
|
||||||
|
const votes = Math.round(125000 / Math.sqrt(rank) + jitter);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: seed.no,
|
id: no,
|
||||||
no: seed.no,
|
no,
|
||||||
name: seed.name,
|
name,
|
||||||
enName: seed.enName,
|
enName,
|
||||||
age: seed.age,
|
slogan,
|
||||||
gender: seed.gender,
|
bio: `来自虚拟星域的偶像候选人 ${enName},从小热爱音乐与舞蹈。性格${
|
||||||
bio: seed.bio,
|
rank % 2 === 0 ? "温柔" : "活泼"
|
||||||
portrait: tosUrl(
|
},擅长${
|
||||||
CUSTOM_COVERS.has(seed.no)
|
idx % 3 === 0 ? "抒情曲" : idx % 3 === 1 ? "舞台表演" : "Rap 创作"
|
||||||
? `portraits/${seed.no}-cover.webp`
|
}。曾获得多项虚拟偶像新人奖项,代表作品深受粉丝喜爱。立志成为 Top12 出道阵容的一员,用音乐传递梦想与力量。`,
|
||||||
: `portraits/${seed.no}.webp`,
|
portrait: "",
|
||||||
),
|
|
||||||
avatar: "",
|
avatar: "",
|
||||||
gallery: buildGallery(seed.no),
|
gallery: ["", "", "", "", ""],
|
||||||
videoUrl: MISSING_VIDEO.has(seed.no)
|
videoUrl: undefined,
|
||||||
? undefined
|
|
||||||
: tosUrl(`videos/artists/${seed.no}.mp4`),
|
|
||||||
// 不设置 poster,由播放器运行时 seek 到 0.001s 渲染首帧作为封面
|
|
||||||
videoPoster: "",
|
videoPoster: "",
|
||||||
tags: seed.tags,
|
tags: TAG_POOL[idx % TAG_POOL.length]!,
|
||||||
height: seed.height,
|
birthday: `${String(((idx * 7) % 12) + 1).padStart(2, "0")}-${String(
|
||||||
// 初始全部 0 票,等用户投票产生真实排名;rank 按 no 顺序兜底
|
((idx * 13) % 28) + 1
|
||||||
votes: 0,
|
).padStart(2, "0")}`,
|
||||||
rank: idx + 1,
|
height: 158 + (idx % 12),
|
||||||
motto: seed.motto,
|
cv: idx < 12 ? `CV 配音 #${idx + 1}` : undefined,
|
||||||
personality: seed.personality,
|
themeColor: THEME_COLORS[idx % THEME_COLORS.length]!,
|
||||||
catchphrase: seed.catchphrase,
|
votes,
|
||||||
skills: seed.skills,
|
rank,
|
||||||
track: seed.track,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ARTISTS: Artist[] = buildArtists();
|
export const ARTISTS: Artist[] = buildArtists();
|
||||||
|
|
||||||
/** 排序方式 · "votes" = 实时排名(票数倒序),"no" = 编号顺序 */
|
export const TOP_12 = ARTISTS.slice(0, 12);
|
||||||
export type SortKey = "votes" | "no";
|
export const CANDIDATES = ARTISTS.slice(12);
|
||||||
|
|
||||||
/** 票数相同时按编号升序兜底,保证排序稳定 */
|
/** 按当前排序方式获取艺人列表 */
|
||||||
|
export type SortKey = "votes" | "no" | "recent";
|
||||||
export function sortArtists(list: Artist[], key: SortKey = "votes"): Artist[] {
|
export function sortArtists(list: Artist[], key: SortKey = "votes"): Artist[] {
|
||||||
const sorted = [...list];
|
const sorted = [...list];
|
||||||
if (key === "votes") {
|
if (key === "votes") sorted.sort((a, b) => b.votes - a.votes);
|
||||||
sorted.sort((a, b) => b.votes - a.votes || a.no.localeCompare(b.no));
|
else if (key === "no") sorted.sort((a, b) => a.no.localeCompare(b.no));
|
||||||
} else {
|
|
||||||
sorted.sort((a, b) => a.no.localeCompare(b.no));
|
|
||||||
}
|
|
||||||
return sorted;
|
return sorted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
52
src/lib/mock-user.ts
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import { ARTISTS } from "./mock-data";
|
||||||
|
import type { Artist } from "@/types/artist";
|
||||||
|
|
||||||
|
export interface MockUser {
|
||||||
|
id: string;
|
||||||
|
nickname: string;
|
||||||
|
/** 头像 URL(无则使用首字母占位) */
|
||||||
|
avatar: string;
|
||||||
|
signInStreak: number;
|
||||||
|
/** 本周签到状态:每天 true/false(按周一开始的一周 7 天) */
|
||||||
|
weeklySignIn: boolean[];
|
||||||
|
/** 今日是否已签到 */
|
||||||
|
todaySignedIn: boolean;
|
||||||
|
/** 累计投票数 */
|
||||||
|
totalVotes: number;
|
||||||
|
/** 应援的艺人 ID 列表 */
|
||||||
|
supportingIds: string[];
|
||||||
|
/** 邀请好友数 */
|
||||||
|
invitedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FanSupport {
|
||||||
|
artist: Artist;
|
||||||
|
votedCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MOCK_USER: MockUser = {
|
||||||
|
id: "12345678",
|
||||||
|
nickname: "粉丝昵称",
|
||||||
|
avatar: "",
|
||||||
|
signInStreak: 7,
|
||||||
|
weeklySignIn: [true, true, true, true, true, true, false],
|
||||||
|
todaySignedIn: false,
|
||||||
|
totalVotes: 87,
|
||||||
|
supportingIds: ["001", "005", "014"],
|
||||||
|
invitedCount: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
export function getFanSupports(): FanSupport[] {
|
||||||
|
// 已应援票数采用确定性映射(避免 hydration 不一致)
|
||||||
|
const VOTED_BY_ID: Record<string, number> = { "001": 42, "005": 28, "014": 17 };
|
||||||
|
return MOCK_USER.supportingIds
|
||||||
|
.map((id) => {
|
||||||
|
const artist = ARTISTS.find((a) => a.id === id);
|
||||||
|
if (!artist) return null;
|
||||||
|
return {
|
||||||
|
artist,
|
||||||
|
votedCount: VOTED_BY_ID[id] ?? 10,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((x): x is FanSupport => x !== null);
|
||||||
|
}
|
||||||
@ -1,65 +0,0 @@
|
|||||||
/**
|
|
||||||
* OTP 验证码存储抽象层。
|
|
||||||
*
|
|
||||||
* 路由策略:
|
|
||||||
* - Redis 可用 → 走 Redis (生产, 多实例, 跨重启)
|
|
||||||
* - Redis 缺失 → 降级到进程内 Map (dev 联调; 单实例; 重启丢失但 5min TTL 无所谓)
|
|
||||||
*
|
|
||||||
* 不能"Redis 缺失就直接通过校验" —— 那会复活之前的安全漏洞。
|
|
||||||
* 必须真实生成 / 真实存储 / 真实核对。
|
|
||||||
*/
|
|
||||||
import { getRedis } from "./redis";
|
|
||||||
|
|
||||||
const TTL_SECONDS = 300;
|
|
||||||
const redisKey = (phone: string) => `sms:otp:${phone}`;
|
|
||||||
|
|
||||||
// 通过 globalThis 防止 Next dev HMR 重置模块导致 Map 丢失
|
|
||||||
declare global {
|
|
||||||
// eslint-disable-next-line no-var
|
|
||||||
var __otpMemStore: Map<string, { code: string; expiresAt: number }> | undefined;
|
|
||||||
}
|
|
||||||
const mem: Map<string, { code: string; expiresAt: number }> =
|
|
||||||
globalThis.__otpMemStore ?? (globalThis.__otpMemStore = new Map());
|
|
||||||
|
|
||||||
/** 清理 mem 里过期的条目, 防止长跑后内存膨胀 */
|
|
||||||
function sweep() {
|
|
||||||
const now = Date.now();
|
|
||||||
for (const [phone, entry] of mem) {
|
|
||||||
if (entry.expiresAt <= now) mem.delete(phone);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 存验证码 (覆盖该手机号的旧码) */
|
|
||||||
export async function storeOtp(phone: string, code: string): Promise<void> {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (redis) {
|
|
||||||
await redis.set(redisKey(phone), code, "EX", TTL_SECONDS);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sweep();
|
|
||||||
mem.set(phone, { code, expiresAt: Date.now() + TTL_SECONDS * 1000 });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 核对验证码;
|
|
||||||
* 通过则立即销毁该码 (防止重放), 返回 true;
|
|
||||||
* 失败 (码不存在 / 已过期 / 不匹配) 返回 false。
|
|
||||||
*/
|
|
||||||
export async function consumeOtp(phone: string, code: string): Promise<boolean> {
|
|
||||||
const redis = getRedis();
|
|
||||||
if (redis) {
|
|
||||||
const stored = await redis.get(redisKey(phone));
|
|
||||||
if (!stored || stored !== code) return false;
|
|
||||||
await redis.del(redisKey(phone));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
const entry = mem.get(phone);
|
|
||||||
if (!entry) return false;
|
|
||||||
if (Date.now() > entry.expiresAt) {
|
|
||||||
mem.delete(phone);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
if (entry.code !== code) return false;
|
|
||||||
mem.delete(phone);
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
@ -1,83 +0,0 @@
|
|||||||
/**
|
|
||||||
* 阿里云短信服务客户端
|
|
||||||
*
|
|
||||||
* 接入: dysmsapi.aliyuncs.com (短信服务在国内是全局服务,所有调用走 cn-hangzhou)
|
|
||||||
* 文档: https://help.aliyun.com/document_detail/419298.html
|
|
||||||
*
|
|
||||||
* 环境变量:
|
|
||||||
* SMS_ACCESS_KEY RAM 子账号 AccessKey ID
|
|
||||||
* SMS_SECRET_KEY RAM 子账号 AccessKey Secret
|
|
||||||
* SMS_SIGN_NAME 已审核通过的签名 (例: 广州气元科技)
|
|
||||||
* SMS_TEMPLATE_CODE 已审核通过的模板 Code (例: SMS_506210397)
|
|
||||||
*
|
|
||||||
* 模板内容必须严格使用 ${code} 占位符,例如:
|
|
||||||
* "您的验证码是 ${code},5 分钟内有效,请勿泄露。"
|
|
||||||
*/
|
|
||||||
import Dysmsapi, * as $Dysmsapi from "@alicloud/dysmsapi20170525";
|
|
||||||
import * as $OpenApi from "@alicloud/openapi-client";
|
|
||||||
import * as $Util from "@alicloud/tea-util";
|
|
||||||
|
|
||||||
let client: Dysmsapi | null = null;
|
|
||||||
|
|
||||||
function getClient(): Dysmsapi | null {
|
|
||||||
if (client) return client;
|
|
||||||
const accessKeyId = process.env.SMS_ACCESS_KEY;
|
|
||||||
const accessKeySecret = process.env.SMS_SECRET_KEY;
|
|
||||||
if (!accessKeyId || !accessKeySecret) return null;
|
|
||||||
const config = new $OpenApi.Config({ accessKeyId, accessKeySecret });
|
|
||||||
config.endpoint = "dysmsapi.aliyuncs.com";
|
|
||||||
client = new Dysmsapi(config);
|
|
||||||
return client;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SendOtpResult {
|
|
||||||
ok: boolean;
|
|
||||||
/** 阿里云返回的 BizId,排查问题时给阿里云工单用 */
|
|
||||||
bizId?: string;
|
|
||||||
/** 阿里云错误码 / 失败原因 */
|
|
||||||
errorCode?: string;
|
|
||||||
errorMessage?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 发送 6 位验证码短信。
|
|
||||||
* 返回 ok=true 表示阿里云已受理 (用户收到短信通常在 30 秒内);
|
|
||||||
* ok=false 时 errorCode 给出原因 (如 isv.MOBILE_NUMBER_ILLEGAL / isv.BUSINESS_LIMIT_CONTROL 等)。
|
|
||||||
*
|
|
||||||
* 若 SMS_ACCESS_KEY/SMS_SECRET_KEY 未配置,函数直接返回 ok=false 且 errorCode='SMS_NOT_CONFIGURED',
|
|
||||||
* 调用方可根据该信号 fallback 到 dev console.log 输出验证码。
|
|
||||||
*/
|
|
||||||
export async function sendOtpSms(
|
|
||||||
phone: string,
|
|
||||||
code: string,
|
|
||||||
): Promise<SendOtpResult> {
|
|
||||||
const c = getClient();
|
|
||||||
const signName = process.env.SMS_SIGN_NAME;
|
|
||||||
const templateCode = process.env.SMS_TEMPLATE_CODE;
|
|
||||||
if (!c || !signName || !templateCode) {
|
|
||||||
return { ok: false, errorCode: "SMS_NOT_CONFIGURED" };
|
|
||||||
}
|
|
||||||
|
|
||||||
const req = new $Dysmsapi.SendSmsRequest({
|
|
||||||
phoneNumbers: phone,
|
|
||||||
signName,
|
|
||||||
templateCode,
|
|
||||||
templateParam: JSON.stringify({ code }),
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
|
||||||
const resp = await c.sendSmsWithOptions(req, new $Util.RuntimeOptions({}));
|
|
||||||
const body = resp.body;
|
|
||||||
if (body?.code === "OK") {
|
|
||||||
return { ok: true, bizId: body.bizId };
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
errorCode: body?.code ?? "UNKNOWN",
|
|
||||||
errorMessage: body?.message,
|
|
||||||
};
|
|
||||||
} catch (e) {
|
|
||||||
const msg = e instanceof Error ? e.message : String(e);
|
|
||||||
return { ok: false, errorCode: "EXCEPTION", errorMessage: msg };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
206
src/lib/store.ts
@ -1,175 +1,83 @@
|
|||||||
import { create } from "zustand";
|
import { create } from "zustand";
|
||||||
import { persist } from "zustand/middleware";
|
|
||||||
import { ARTISTS } from "./mock-data";
|
import { ARTISTS } from "./mock-data";
|
||||||
import type { Artist } from "@/types/artist";
|
import type { Artist } from "@/types/artist";
|
||||||
|
|
||||||
/**
|
/** 每日基础投票额度(与后端 ActivityConfig.dailyQuota 对齐) */
|
||||||
* 每用户终身投票总额度 —— 一人 12 票,每位艺人最多 1 票,
|
export const DAILY_VOTE_QUOTA = 10;
|
||||||
* 投完即结束,不限时,不可撤销。
|
|
||||||
*/
|
|
||||||
export const TOTAL_VOTE_QUOTA = 12;
|
|
||||||
|
|
||||||
/** 派生类型:我应援的艺人(新规则下每位仅 1 票,不再带 votedCount) */
|
|
||||||
export interface MySupport {
|
|
||||||
artist: Artist;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface VoteStore {
|
interface VoteStore {
|
||||||
/** 当前所有艺人(含动态票数 / 实时排名) */
|
/** 当前所有艺人(含动态票数 / 实时排名) */
|
||||||
artists: Artist[];
|
artists: Artist[];
|
||||||
|
/** 累计已投票数 */
|
||||||
|
myTotalVotes: number;
|
||||||
|
/** 今日已用票数(跨日自动重置) */
|
||||||
|
usedToday: number;
|
||||||
|
/** 今日额度日期标记(YYYY-M-D,按本地时区) */
|
||||||
|
quotaDate: string;
|
||||||
/**
|
/**
|
||||||
* 我已投票的艺人 ID 列表(顺序 = 投票顺序)。
|
* 给艺人投票(本地模拟,会重新排名)。
|
||||||
* 用数组而不是 Set:zustand persist 默认 storage 是 JSON,Set 无法直接序列化。
|
* 票数不足时返回 false,前端可据此提示。
|
||||||
* 数组按投票顺序保留,既能 .includes() 判重,也能在 /me 页面显示"我的投票"时按时间排序。
|
|
||||||
*/
|
*/
|
||||||
votedArtists: string[];
|
vote: (artistId: string, count: number) => boolean;
|
||||||
/**
|
/** 重置(开发时用) */
|
||||||
* 给艺人投票。
|
|
||||||
* - 已投过该艺人 → 返回 { ok: false, reason: "already" }
|
|
||||||
* - 已用满 12 票 → 返回 { ok: false, reason: "exhausted" }
|
|
||||||
* - 成功 → 返回 { ok: true }
|
|
||||||
*/
|
|
||||||
vote: (artistId: string) => { ok: boolean; reason?: "already" | "exhausted" };
|
|
||||||
/**
|
|
||||||
* 服务端权威态覆盖本地态:登录后从 /api/me 拿到的 votedArtists 直接灌进来,
|
|
||||||
* 跨设备/清缓存的关键 —— 本地 localStorage 不再是唯一真相源。
|
|
||||||
*/
|
|
||||||
hydrateFromServer: (votedArtists: string[]) => void;
|
|
||||||
/** 服务端拒绝时回滚单次投票(乐观更新的兜底) */
|
|
||||||
rollbackVote: (artistId: string) => void;
|
|
||||||
/** 重置(开发时用 / 测试用 / 登出时清理) */
|
|
||||||
reset: () => void;
|
reset: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 票数倒序 + 编号升序兜底,确保 0 票时也有稳定排名 */
|
function todayKey(): string {
|
||||||
|
const d = new Date();
|
||||||
|
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
function rank(list: Artist[]): Artist[] {
|
function rank(list: Artist[]): Artist[] {
|
||||||
return [...list]
|
return [...list]
|
||||||
.sort((a, b) => b.votes - a.votes || a.no.localeCompare(b.no))
|
.sort((a, b) => b.votes - a.votes)
|
||||||
.map((a, i) => ({ ...a, rank: i + 1 }));
|
.map((a, i) => ({ ...a, rank: i + 1 }));
|
||||||
}
|
}
|
||||||
|
|
||||||
const INITIAL_ARTISTS = rank(ARTISTS);
|
const INITIAL = rank(ARTISTS);
|
||||||
|
|
||||||
export const useVoteStore = create<VoteStore>()(
|
export const useVoteStore = create<VoteStore>((set) => ({
|
||||||
persist(
|
artists: INITIAL,
|
||||||
(set, get) => ({
|
myTotalVotes: 0,
|
||||||
artists: INITIAL_ARTISTS,
|
usedToday: 0,
|
||||||
votedArtists: [],
|
quotaDate: todayKey(),
|
||||||
vote: (artistId) => {
|
vote: (artistId, count) => {
|
||||||
const state = get();
|
let success = false;
|
||||||
if (state.votedArtists.includes(artistId)) {
|
set((state) => {
|
||||||
return { ok: false, reason: "already" };
|
const today = todayKey();
|
||||||
}
|
const baseUsed = state.quotaDate === today ? state.usedToday : 0;
|
||||||
if (state.votedArtists.length >= TOTAL_VOTE_QUOTA) {
|
const remaining = DAILY_VOTE_QUOTA - baseUsed;
|
||||||
return { ok: false, reason: "exhausted" };
|
if (count <= 0 || count > remaining) {
|
||||||
}
|
return state;
|
||||||
const updated = state.artists.map((a) =>
|
}
|
||||||
a.id === artistId ? { ...a, votes: a.votes + 1 } : a,
|
success = true;
|
||||||
);
|
const updated = state.artists.map((a) =>
|
||||||
set({
|
a.id === artistId ? { ...a, votes: a.votes + count } : a,
|
||||||
artists: rank(updated),
|
);
|
||||||
votedArtists: [...state.votedArtists, artistId],
|
return {
|
||||||
});
|
artists: rank(updated),
|
||||||
return { ok: true };
|
myTotalVotes: state.myTotalVotes + count,
|
||||||
},
|
usedToday: baseUsed + count,
|
||||||
hydrateFromServer: (votedArtists) => {
|
quotaDate: today,
|
||||||
// 用服务端返回的 votedArtists 重建 artists 票数(回放 mock baseline)
|
};
|
||||||
const counts = new Map<string, number>();
|
});
|
||||||
for (const id of votedArtists) {
|
return success;
|
||||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
},
|
||||||
}
|
reset: () =>
|
||||||
const rebuilt = INITIAL_ARTISTS.map((a) => ({
|
set({
|
||||||
...a,
|
artists: INITIAL,
|
||||||
votes: a.votes + (counts.get(a.id) ?? 0),
|
myTotalVotes: 0,
|
||||||
}));
|
usedToday: 0,
|
||||||
set({
|
quotaDate: todayKey(),
|
||||||
artists: rank(rebuilt),
|
|
||||||
votedArtists,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
rollbackVote: (artistId) => {
|
|
||||||
const state = get();
|
|
||||||
const idx = state.votedArtists.lastIndexOf(artistId);
|
|
||||||
if (idx === -1) return;
|
|
||||||
const nextVoted = [
|
|
||||||
...state.votedArtists.slice(0, idx),
|
|
||||||
...state.votedArtists.slice(idx + 1),
|
|
||||||
];
|
|
||||||
const updated = state.artists.map((a) =>
|
|
||||||
a.id === artistId ? { ...a, votes: Math.max(0, a.votes - 1) } : a,
|
|
||||||
);
|
|
||||||
set({
|
|
||||||
artists: rank(updated),
|
|
||||||
votedArtists: nextVoted,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
reset: () =>
|
|
||||||
set({
|
|
||||||
artists: INITIAL_ARTISTS,
|
|
||||||
votedArtists: [],
|
|
||||||
}),
|
|
||||||
}),
|
}),
|
||||||
{
|
}));
|
||||||
name: "cyber-star-vote",
|
|
||||||
// 仅持久化 votedArtists —— artists 票数/排名是派生数据,
|
|
||||||
// 刷新后重新从初始数据 + votedArtists 重建。
|
|
||||||
// localStorage 仅作本设备缓存,加速首屏渲染;真相源是 /api/me
|
|
||||||
// —— useSyncMe 会在登录/切换用户后用服务端数据覆盖本地。
|
|
||||||
partialize: (state) => ({ votedArtists: state.votedArtists }),
|
|
||||||
// rehydrate 时把 votedArtists 数据"回放"到 artists 票数上,保持视图一致
|
|
||||||
onRehydrateStorage: () => (state) => {
|
|
||||||
if (!state) return;
|
|
||||||
const counts = new Map<string, number>();
|
|
||||||
for (const id of state.votedArtists) {
|
|
||||||
counts.set(id, (counts.get(id) ?? 0) + 1);
|
|
||||||
}
|
|
||||||
const rebuilt = INITIAL_ARTISTS.map((a) => ({
|
|
||||||
...a,
|
|
||||||
votes: a.votes + (counts.get(a.id) ?? 0),
|
|
||||||
}));
|
|
||||||
state.artists = rank(rebuilt);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
/** 选择器:按 ID 获取最新艺人 */
|
/** 便捷选择器:按 ID 获取最新艺人 */
|
||||||
export function selectArtist(id: string) {
|
export function selectArtist(id: string) {
|
||||||
return (s: VoteStore) => s.artists.find((a) => a.id === id);
|
return (s: VoteStore) => s.artists.find((a) => a.id === id);
|
||||||
}
|
}
|
||||||
|
|
||||||
/** 选择器:当前剩余票数 = 12 - 已投艺人数 */
|
/** 选择器:当前剩余票数(基于今日已用) */
|
||||||
export function selectRemaining(s: VoteStore): number {
|
export function selectRemaining(s: VoteStore): number {
|
||||||
return Math.max(0, TOTAL_VOTE_QUOTA - s.votedArtists.length);
|
return Math.max(0, DAILY_VOTE_QUOTA - s.usedToday);
|
||||||
}
|
|
||||||
|
|
||||||
/** 选择器:是否已投过指定艺人(高阶,在组件里用 useVoteStore(selectHasVoted(id))) */
|
|
||||||
export function selectHasVoted(id: string) {
|
|
||||||
return (s: VoteStore) => s.votedArtists.includes(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 选择器:12 票是否已全部投完 */
|
|
||||||
export function selectIsExhausted(s: VoteStore): boolean {
|
|
||||||
return s.votedArtists.length >= TOTAL_VOTE_QUOTA;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 派生函数:"我的应援"列表 —— 按投票顺序(最早投的在前)。
|
|
||||||
*
|
|
||||||
* ⚠️ 不要直接 `useVoteStore(selectMySupports)`:它每次都返回新数组,
|
|
||||||
* 会触发 React 19 的 "getSnapshot should be cached" 报错。
|
|
||||||
* 正确用法:在组件里 useMemo 派生(参考 MeContent.tsx)。
|
|
||||||
*/
|
|
||||||
export function selectMySupports(s: VoteStore): MySupport[] {
|
|
||||||
const list: MySupport[] = [];
|
|
||||||
for (const id of s.votedArtists) {
|
|
||||||
const artist = s.artists.find((a) => a.id === id);
|
|
||||||
if (artist) list.push({ artist });
|
|
||||||
}
|
|
||||||
return list;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** 选择器:我支持的艺人数 = 投票数 */
|
|
||||||
export function selectMySupportingCount(s: VoteStore): number {
|
|
||||||
return s.votedArtists.length;
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,23 +0,0 @@
|
|||||||
/**
|
|
||||||
* TOS 资源 URL 拼接工具
|
|
||||||
*
|
|
||||||
* 用法:
|
|
||||||
* tosUrl("portraits/001.webp")
|
|
||||||
* → https://cyberstar.tos-cn-shanghai.volces.com/cyber-star/portraits/001.webp?v=2
|
|
||||||
*
|
|
||||||
* 环境变量 NEXT_PUBLIC_TOS_DOMAIN 配置:
|
|
||||||
* .env.local / .env.production → 完整的桶 + 路径前缀 (含 scheme, 不含末尾 /)
|
|
||||||
* 未设置时 fallback 到相对路径 (/path/...), 适合本地用 public/ 静态文件托管的场景。
|
|
||||||
*
|
|
||||||
* 缓存版本号 TOS_VERSION:每次有 TOS 文件被原地覆盖更新(图片/视频),
|
|
||||||
* 把这个数字 +1。浏览器和 CDN 会把带新版本号的 URL 当作全新资源,
|
|
||||||
* 立即看到更新后的内容,不必等 TTL 过期或手动 invalidate。
|
|
||||||
*/
|
|
||||||
const TOS_BASE = (process.env.NEXT_PUBLIC_TOS_DOMAIN ?? "").replace(/\/+$/, "");
|
|
||||||
const TOS_VERSION = "8";
|
|
||||||
|
|
||||||
export function tosUrl(path: string): string {
|
|
||||||
const clean = path.replace(/^\/+/, "");
|
|
||||||
const base = TOS_BASE ? `${TOS_BASE}/${clean}` : `/${clean}`;
|
|
||||||
return `${base}?v=${TOS_VERSION}`;
|
|
||||||
}
|
|
||||||
@ -1,18 +0,0 @@
|
|||||||
import { create } from "zustand";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 跨组件 UI 协调状态。
|
|
||||||
* 主要用途:首页筛选条吸顶时通知导航关掉自己的玻璃,
|
|
||||||
* 让筛选条把玻璃从 y=0 一路延伸到自己底部,形成单一连续 backdrop-filter,
|
|
||||||
* 消除两块独立玻璃在 y=80 处的接缝。
|
|
||||||
*/
|
|
||||||
interface UIStore {
|
|
||||||
/** 当前页面有 sticky 筛选条且已经吸顶 */
|
|
||||||
filterStuck: boolean;
|
|
||||||
setFilterStuck: (stuck: boolean) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useUIStore = create<UIStore>((set) => ({
|
|
||||||
filterStuck: false,
|
|
||||||
setFilterStuck: (stuck) => set({ filterStuck: stuck }),
|
|
||||||
}));
|
|
||||||
@ -1,63 +1,57 @@
|
|||||||
export type ArtistTag =
|
export type ArtistTag =
|
||||||
| "rock"
|
| "vocal"
|
||||||
| "pop"
|
| "dance"
|
||||||
| "chinese"
|
| "rap"
|
||||||
| "hiphop"
|
| "all-rounder"
|
||||||
| "folk"
|
| "visual"
|
||||||
| "jazz";
|
| "leader";
|
||||||
|
|
||||||
export interface Artist {
|
export interface Artist {
|
||||||
/** 唯一 ID(如 001 ~ 036) */
|
/** 唯一 ID(如 001 ~ 035) */
|
||||||
id: string;
|
id: string;
|
||||||
/** 编号字符串(如 "001") */
|
/** 编号字符串(如 "001") */
|
||||||
no: string;
|
no: string;
|
||||||
/** 中文名(来自小传) */
|
/** 中文名 */
|
||||||
name: string;
|
name: string;
|
||||||
/** 英文名 / 艺名(来自小传) */
|
/** 英文名 / 艺名 */
|
||||||
enName: string;
|
enName: string;
|
||||||
/** 年龄(来自小传) */
|
/** Slogan 短宣传语 */
|
||||||
age?: number;
|
slogan: string;
|
||||||
/** 性别(来自小传) */
|
/** 长简介 (200-500 字) */
|
||||||
gender?: "M" | "F";
|
|
||||||
/** 长简介 / 人物小传(来自小传) */
|
|
||||||
bio: string;
|
bio: string;
|
||||||
/** 立绘主图 URL */
|
/** 立绘主图 URL */
|
||||||
portrait: string;
|
portrait: string;
|
||||||
/** 圆形头像 URL(暂未使用) */
|
/** 圆形头像 URL */
|
||||||
avatar: string;
|
avatar: string;
|
||||||
/** 表演视频 URL(来自 solo.mp4,缺失则 undefined) */
|
/** 表演视频 URL (15s) */
|
||||||
videoUrl?: string;
|
videoUrl?: string;
|
||||||
/** 视频封面图 */
|
/** 视频封面图 */
|
||||||
videoPoster?: string;
|
videoPoster?: string;
|
||||||
/** 表演图片轮播(三视图 + 氛围图 2/3) */
|
/** 表演图片轮播 */
|
||||||
gallery: string[];
|
gallery: string[];
|
||||||
/** 实力标签(用于筛选) */
|
/** 标签 */
|
||||||
tags: ArtistTag[];
|
tags: ArtistTag[];
|
||||||
/** 身高 cm(来自小传) */
|
/** 生日 MM-DD */
|
||||||
|
birthday: string;
|
||||||
|
/** 身高 cm */
|
||||||
height: number;
|
height: number;
|
||||||
|
/** CV / 声优 */
|
||||||
|
cv?: string;
|
||||||
|
/** 应援色 hex */
|
||||||
|
themeColor: string;
|
||||||
/** 当前票数 */
|
/** 当前票数 */
|
||||||
votes: number;
|
votes: number;
|
||||||
/** 当前排名 (1-36) */
|
/** 当前排名 (1-35) */
|
||||||
rank: number;
|
rank: number;
|
||||||
/** 座右铭(来自小传) */
|
|
||||||
motto?: string;
|
|
||||||
/** 性格描述(来自小传) */
|
|
||||||
personality?: string;
|
|
||||||
/** 口头禅(来自小传) */
|
|
||||||
catchphrase?: string;
|
|
||||||
/** 核心技能(来自小传) */
|
|
||||||
skills?: string;
|
|
||||||
/** 核心赛道(来自小传) */
|
|
||||||
track?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const TAG_LABEL: Record<ArtistTag, string> = {
|
export const TAG_LABEL: Record<ArtistTag, string> = {
|
||||||
rock: "摇滚",
|
vocal: "声乐担当",
|
||||||
pop: "流行",
|
dance: "舞蹈担当",
|
||||||
chinese: "国风",
|
rap: "rap担当",
|
||||||
hiphop: "嘻哈说唱",
|
"all-rounder": "全能型",
|
||||||
folk: "民谣治愈",
|
visual: "颜值担当",
|
||||||
jazz: "爵士",
|
leader: "队长担当",
|
||||||
};
|
};
|
||||||
|
|
||||||
export type RankCategory = "gold" | "silver" | "bronze" | "top12" | "candidate";
|
export type RankCategory = "gold" | "silver" | "bronze" | "top12" | "candidate";
|
||||||
|
|||||||
@ -1,51 +0,0 @@
|
|||||||
# Asset Pipeline · TOS 桶资源压缩 + 打包
|
|
||||||
|
|
||||||
把 `public/portraits/` 和 `public/videos/` 转成桶友好的体积(WebP + H.264),输出到仓库外的 `../assets-compressed/`。
|
|
||||||
|
|
||||||
## 输出结构(与桶目录对齐)
|
|
||||||
|
|
||||||
```
|
|
||||||
assets-compressed/
|
|
||||||
├── portraits/
|
|
||||||
│ ├── 001.webp
|
|
||||||
│ ├── 001-2.webp
|
|
||||||
│ ├── 001-3.webp
|
|
||||||
│ ├── 001-view.webp
|
|
||||||
│ └── ...
|
|
||||||
└── videos/
|
|
||||||
├── hero-pv.mp4
|
|
||||||
└── artists/001.mp4 ...
|
|
||||||
```
|
|
||||||
|
|
||||||
## 压缩参数
|
|
||||||
|
|
||||||
| 类型 | 处理 | 目标体积 |
|
|
||||||
|---|---|---|
|
|
||||||
| 立绘 / 氛围图 PNG | WebP q82, 最大宽 1600 | ≤ 800KB |
|
|
||||||
| 三视图 `-view.png` | WebP q82, 最大宽 2400 | ≤ 1.5MB |
|
|
||||||
| 视频 MP4 | libx264 CRF 28, 最大宽 1920, AAC 96k, faststart | 单条 ≤ 3MB / Hero PV ≤ 15MB |
|
|
||||||
|
|
||||||
## 用法
|
|
||||||
|
|
||||||
```bash
|
|
||||||
cd tools/asset-pipeline
|
|
||||||
npm install # 装 sharp + ffmpeg-installer (各自带二进制, 无需系统装)
|
|
||||||
npm run compress # 压缩, 已存在且新于源的输出会跳过 (可恢复中断)
|
|
||||||
npm run pack # 打包成 cyber-star-assets.tar.gz
|
|
||||||
```
|
|
||||||
|
|
||||||
输出文件:
|
|
||||||
|
|
||||||
```
|
|
||||||
../../assets-compressed/ # 压缩后的源文件 (供回滚 / 抽查)
|
|
||||||
../../cyber-star-assets.tar.gz # 发给运维上传的最终包
|
|
||||||
```
|
|
||||||
|
|
||||||
## 上传到桶
|
|
||||||
|
|
||||||
运维拿到 `cyber-star-assets.tar.gz` 解压后,用 tosutil 整目录推:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
tar -xzf cyber-star-assets.tar.gz
|
|
||||||
tosutil cp -r -f assets-compressed/* tos://cyber-star/
|
|
||||||
```
|
|
||||||