feat(ui): design overhaul, global login modal, design spec

- nav: center links (首页/排行榜/我的), right-side AuthMenu + RemainingVotesBadge; image logo with responsive sizing
- auth: replace /login route with global LoginModal triggered anywhere; "我的" intercepts unauth users with post-login redirect
- home: full-screen Hero, redesigned Top12 (12 pill cards, top-3 glow), scroll-snap mandatory between Hero/Top12/candidates
- home: candidates section with sticky filter that gains frosted-glass bg when stuck (matches nav)
- filter: simplified tags (全部/舞蹈/声乐/rap/全能型); ArtistCard uniform purple vote button
- ranking/me: remove Top12Bar; me header stacks 编辑资料/退出登录 vertically
- typography: font-logo set to Orbitron; ✦ glyph in CYBER ✦ STAR preserved
- layout: max-w-[1500px] unified across pages
- docs: add design-spec.md + design-spec.html with full visual spec (lucide SVG, zero emoji policy)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
iye 2026-05-12 18:59:30 +08:00
parent bd5a361a18
commit d5ed43acbd
45 changed files with 4026 additions and 1117 deletions

1841
docs/design-spec.html Normal file

File diff suppressed because it is too large Load Diff

604
docs/design-spec.md Normal file
View File

@ -0,0 +1,604 @@
# CYBER STAR · 视觉设计规范 v2
> 适用范围CYBER STAR 虚拟偶像 Top12 出道企划官网(首页、排行榜、艺人详情页、个人中心)
> 设计语言:**赛博 / 暗黑 / 紫色霓虹 / 太空感**
> 更新时间2026-05-12
---
## 1. 品牌识别
### 1.1 Logo
- **主 Logo**:金属铬质 `CYBER STAR` 文字 + 右侧星形装饰PNG 透明背景)
- 路径:`/public/logo.png`(待放置)
- 原始比例:约 **2.7 : 1**
- 最小展示宽度:**120px**
- 顶栏使用高度:**32px**,对应宽度约 **86px**
- **简化 Logofavicon / 极窄场景)**:紫色四角星 SVG自定义 path 或 lucide `Sparkles`),单独使用时配紫色辉光
### 1.2 关键词
赛博朋克、未来感、神秘、紫色霓虹、深空、半透明、辉光、玻璃质感glassmorphism
### 1.3 不要做的事
- **禁止**:不使用扁平色块大面积铺底
- **禁止**:不使用强烈的彩虹色 / 多色对比
- **禁止**:不使用阳光、暖色调
- **禁止**:不使用粗线条 / 卡通风格的图标
---
## 2. 色彩系统
### 2.1 背景层级(暗紫调)
| Token | HEX | 用法 |
|---|---|---|
| `--color-deepest` | `#08051A` | 页面最底层背景 |
| `--color-deep` | `#0D0A24` | 导航 / Footer 背景 |
| `--color-base` | `#13102E` | 主要区块背景 |
| `--color-surface` | `#1A1638` | 一级卡片 / 输入框 |
| `--color-elevated` | `#221D4A` | 浮层 / 弹窗 / Hover 态 |
| `--color-card` | `#1E1840` | 艺人卡片底色 |
> 视觉策略:层级越深、颜色越亮一点点,营造空间纵深感。
### 2.2 主调紫 · Royal Violet
| Token | HEX | 用法 |
|---|---|---|
| `--color-purple-100` | `#EDE9FE` | 极淡紫,几乎不用 |
| `--color-purple-200` | `#DDD6FE` | 高亮文字 |
| `--color-purple-300` | `#C4B5FD` | **主要文字高亮 / 标题点缀** |
| `--color-purple-400` | `#A78BFA` | 边框 / 图标 / 次级按钮文字 |
| `--color-purple-500` | `#8B5CF6` | **主品牌色**,按钮、徽章 |
| `--color-purple-600` | `#7C3AED` | 按钮 hover / 强调 |
| `--color-purple-700` | `#6D28D9` | 按钮 active / 渐变深端 |
| `--color-purple-800` | `#5B21B6` | 极深紫,渐变深端 |
### 2.3 文字颜色
| 用途 | 值 | 备注 |
|---|---|---|
| 主标题 / 强调 | `#FFFFFF` (white) | 100% 不透明 |
| 正文 | `rgba(255,255,255,0.85)` | 二级阅读文字 |
| 次要说明 | `rgba(255,255,255,0.65)` | slogan / placeholder |
| 弱化 | `rgba(255,255,255,0.45)` | 元数据 / 提示 |
| 几近隐藏 | `rgba(255,255,255,0.25)` | disabled 文字 |
| 紫色强调 | `var(--color-purple-300)` | 票数 / 标签 / 排名 |
| 错误 | `var(--color-pink-500)` `#EC4899` | 限警告类文案 |
### 2.4 边框
| Token | 值 | 用法 |
|---|---|---|
| `--border-subtle` | `rgba(255,255,255,0.08)` | 默认分隔 |
| `--border-default` | `rgba(255,255,255,0.14)` | 卡片 / 输入框边框 |
| `--border-purple` | `rgba(139,92,246,0.55)` | 强调态边框 |
| `--border-glow` | `rgba(196,181,253,0.65)` | 辉光边框 |
### 2.5 渐变
| Token | 用法 |
|---|---|
| `--grad-hero` | Hero 区放射状暗紫Hero 视频缺失时的降级背景 |
| `--grad-purple` | 主按钮渐变135°深紫 → 中紫 → 浅紫) |
| `--grad-purple-deep` | 深沉版主渐变 |
| `--grad-card` | 艺人卡片背景155°elevated → surface |
| `--grad-violet-glow` | 放射状辉光,装饰用 |
| `--grad-shine` | 按钮微光扫过效果 |
### 2.6 辅助色(仅限点缀)
| 名 | HEX | 限定场景 |
|---|---|---|
| Cyan-400 | `#38D9F5` | 数据点缀、统计强调,不用作大面积 |
| Blue-500 | `#2D7FFF` | 链接 hover不用作背景 |
| Magenta | `#D946EF` | 特殊状态 |
| Pink-500 | `#EC4899` | 错误、警示 |
> Token 中保留的 `--color-gold-400` / `--color-silver` / `--color-bronze` **当前不使用**,预留给后续可能的徽章升级;本规范及当前设计稿只用紫色统一表达排名层级。
---
## 3. 字体系统
### 3.1 字体族
| 用途 | 字体 | Fallback |
|---|---|---|
| **`font-logo`** | **Orbitron**500 / 700 / 900 | Audiowide, sans-serif |
| **`font-display`** | Audiowide | sans-serif |
| **`font-label`**(英文标签全大写) | Cinzel | serif |
| **`font-body`**(中英文正文) | Inter | Source Han Sans SC, PingFang SC, Microsoft YaHei |
> 中文一律使用 `font-body`,不要尝试用 display 字体显示中文(不支持字形)。
### 3.2 字号阶梯
| 用途 | 桌面尺寸 | 移动尺寸 | tracking |
|---|---|---|---|
| Hero 主标题 H1 | `clamp(64px, 7vw, 96px)` | 48px | `0.35em` |
| 区块标题 H2 | 18px | 16px | `0.2em` |
| 卡片标题 | 14px | 13px | normal |
| 正文 | 14px | 13px | normal |
| 标签 / Eyebrowuppercase | 11px | 10px | `0.3em ~ 0.4em` |
| 数字(票数 / 排名) | 用 `tabular-nums` | — | normal |
### 3.3 排版规则
- 英文 uppercase + tracking 是品牌核心调性,**所有英文 label 必须大写 + 字间距 ≥ 0.2em**
- 中文不强制全大写、不强制字间距
- 行高:标题 1.1-1.2,正文 1.6
- 数字必须 `tabular-nums`(票数对齐)
---
## 4. 间距 & 布局
### 4.1 基础栅格
- 间距基数:**4px**
- 常用间距:`4 / 8 / 12 / 16 / 20 / 24 / 32 / 40 / 64`
### 4.2 容器
| 元素 | 值 |
|---|---|
| Page max-width | `1280px``max-w-7xl` |
| 水平 padding移动 | 16px |
| 水平 padding桌面 | 32px |
| Section 之间纵向间距 | 桌面 64px / 移动 40px |
### 4.3 艺人卡片网格
| 断点 | 列数 | gap |
|---|---|---|
| `< 640px` | 2 | 12px |
| `≥ 640px` | 3 | 16px |
| `≥ 768px` | 4 | 16px |
| `≥ 1024px` | **5** | 16px |
### 4.4 Top12 头像横排
- 头像直径:`64-72px`(响应式)
- 头像间距12-16px
- 单行铺满,不允许换行;移动端横向滚动
---
## 5. 圆角 & 阴影 & 辉光
### 5.1 圆角
| 用途 | 值 |
|---|---|
| 小标签 / Pill | `9999px`(完全圆) |
| 小元素(徽章) | `9999px`(圆) |
| 输入框 / 小按钮 | `8px` |
| 卡片 / 容器 | `12px` |
| 大卡片 / 弹窗 | `16-20px` |
| Hero 区 | `20-24px` |
### 5.2 阴影
| Token | 值 | 用法 |
|---|---|---|
| `--shadow-card` | `0 8px 32px rgba(0,0,0,0.65)` | 卡片默认 |
| `--shadow-purple` | `0 0 24px rgba(139,92,246,0.5), 0 0 60px rgba(139,92,246,0.18)` | 紫色辉光(按钮 / 选中态) |
| `--shadow-glow` | `0 0 40px rgba(196,181,253,0.25)` | 高光氛围 |
### 5.3 文字辉光
```css
.glow-text-purple {
text-shadow:
0 0 16px rgba(196, 181, 253, 0.55),
0 0 32px rgba(139, 92, 246, 0.35);
}
```
仅用于Hero 主标题、激活态导航项。
---
## 6. 动效规范
| 名称 | 时长 | Easing | 用法 |
|---|---|---|---|
| Hover 微抬升 | `200ms` | `ease-out` | 卡片 `translateY(-2px)` |
| 按钮渐变 | `180ms` | `ease-out` | 颜色 / 阴影变化 |
| 弹窗进入 | `280ms` | `cubic-bezier(0.22, 1, 0.36, 1)` | scale 0.94→1 + opacity |
| 弹窗退出 | `200ms` | `ease-in` | scale 1→0.96 + opacity |
| `pulse-glow` | `2.4s` infinite | `ease-in-out` | VOTE 按钮 / 当前排名 |
| `float` | `3s` infinite | `ease-in-out` | 滚动提示 / 装饰 |
| `spin-slow` | `20s` infinite | `linear` | Hero 装饰光环 |
---
## 7. 组件规范
### 7.1 导航栏Navigation
```
┌──────────────────────────────────────────────────────────┐
│ [LOGO] 首页 排行榜 [Search] [登录/注册] │
└──────────────────────────────────────────────────────────┘
```
> 示意图中 `[Search]` 表示 lucide `Search` SVG 图标36×36 圆形按钮),代码实现绝不使用搜索 emoji。
| 属性 | 值 |
|---|---|
| 高度 | `64px` |
| 背景 | `rgba(13,10,36,0.85)` + `backdrop-blur(xl)` |
| 底边 | `1px solid rgba(255,255,255,0.08)` |
| 行为 | `sticky top-0`z-index 50 |
| Logo | 高度 32px |
| 导航项间距 | 32px |
| 导航项字体 | `font-display`、12px、`tracking-[0.25em]`、uppercase |
| 默认色 | `rgba(255,255,255,0.65)` |
| 激活色 | `var(--color-purple-300)` + `.glow-text-purple` + 底部 1px 紫线 |
| 搜索图标 | 36×36 圆形按钮,`rgba(255,255,255,0.04)` 背景 |
| 登录按钮 | 36 高度紫色描边胶囊padding-x 20px |
> **注意**:倒计时**不放在导航栏**,仍保留在 Hero 内。
### 7.2 Hero 区(全屏视频背景)
```
┌─────────────────────────────────────────────┐
│ TOP 12 VIRTUAL IDOL DEBUT PROJECT │
│ ┌────────────────┐ │
│ │ 距离投票结束 │ │ ← 浅紫边框胶囊
│ │ 12天 03:24:16 │ │
│ └────────────────┘ │
│ CYBER ✦ STAR │
│ │
│ 虚拟偶像出道企划 │
│ │
│ [VolumeX] │ ← 仅声音按钮
└─────────────────────────────────────────────┘
```
| 属性 | 值 |
|---|---|
| 高度 | `70vh`(最小 480px / 最大 720px |
| 圆角 | `20px` |
| 背景 | 全屏视频 `object-cover` + 半透明黑色蒙层 |
| 视频行为 | `autoPlay` `loop` `muted` `playsInline` |
| 蒙层 | 顶部 45% 黑 → 中部 15% 黑 → 底部 75% 黑(垂直渐变) |
| **禁止****Hero 内不放任何按钮** | 删除 "Play Debut PV"、删除左上 "Debut PV 自动播放" tag |
| 倒计时位置 | **右上角**,距边 24px |
| 倒计时容器 | `padding: 8px 16px` + **`1px solid rgba(196,181,253,0.4)` 浅紫边框** + `rounded-full` 胶囊 + `rgba(13,10,36,0.55)` 半透明深紫底 + `backdrop-blur` |
| 倒计时文字 | 12-13px 白字 + 紫色高亮数字 + `tabular-nums` |
| 声音按钮位置 | **右下角**,距边 20px |
| 声音按钮 | 36×36 圆形 / `rgba(0,0,0,0.55)` + `backdrop-blur` + 白色 `Volume2` / `VolumeX` SVGlucide-react |
| 视频缺失降级 | 用 `--grad-hero` 渐变填充 |
### 7.3 Top 12 出道位
```
┌───────────────────────────────────────────────┐
│ [Trophy] 实时 Top12 出道位 查看完整榜单 [→] │
│ │
│ (o)(o)(o)(o)(o)(o)(o)(o)(o)(o)(o)(o) │ ← 圆形头像
│ 1 2 3 4 5 6 7 8 9 10 11 12 │ ← 紫色序号圆
│ 艺人 A 艺人 B … │
│ 12.6W 票 … │
└───────────────────────────────────────────────┘
```
> 示意图中 `[Trophy]` `[→]` 表示 lucide `Trophy` `ChevronRight` SVG绝不使用奖杯 emoji。
| 元素 | 规范 |
|---|---|
| 容器 | `rgba(13,10,36,0.95)` + `border-subtle` + `rounded-xl` + padding 16px |
| 头像 | **圆形**,直径 64-72px2px 紫色边框(`--color-purple-500`),紫色辉光 |
| 序号徽章 | 独立的、头像下方居中、紫色实心圆、20×20、白字数字 |
| 名字 | 12px、白色、居中、单行省略 |
| 票数 | 11px、`--color-purple-300`、居中、`tabular-nums` |
| 全部 12 位 | 统一紫色边框 + 紫色辉光,**不区分 1-3 / 4-12** |
| 移动端 | 横向滚动 + snap |
| 禁止 | **不要再加 VOTE NOW 侧栏** |
### 7.4 筛选 + 搜索栏
```
┌───────────────────────────────────────────────────┐
│ 全部 舞蹈担当 声乐担当 rap担当 全能型 [Sort] [Search][G/L]│
└───────────────────────────────────────────────────┘
```
> `[Sort]` = lucide `ArrowUpDown``[Search]` = lucide `Search``[G/L]` = lucide `LayoutGrid` + `List` SVG视图切换
| 元素 | 规范 |
|---|---|
| 标签按钮 | 文字 only、无边框、padding 6px 12px、12px |
| 默认色 | `rgba(255,255,255,0.55)` |
| Hover | `rgba(255,255,255,0.85)` |
| 激活 | `--color-purple-300` + 底部 2px 紫线 |
| 排序触发 | 12px、含图标、`rgba(255,255,255,0.04)` 背景 |
| 搜索框 | 高度 36、`--color-surface` 背景、左侧搜索图标、placeholder 45% 白 |
| 视图切换 | 36×36 双按钮组、激活态 `--color-purple-500/12` 紫色背景 |
#### 标签筛选(固定 5 项 · 不可改)
| 标签 key | 中文文案 | 默认筛选行为 |
|---|---|---|
| `all` | 全部 | 显示全部 35 位(默认激活态) |
| `dance` | 舞蹈担当 | 筛选 `tags``dance` 的艺人 |
| `vocal` | 声乐担当 | 筛选 `tags``vocal` 的艺人 |
| `rap` | rap担当 | 筛选 `tags``rap` 的艺人(注意:小写 rap无空格 |
| `all-rounder` | 全能型 | 筛选 `tags``all-rounder` 的艺人 |
> 移除 `visual` `leader` 等其它标签的筛选入口(数据可保留,但不在筛选栏暴露)。
### 7.5 艺人卡片(关键组件)
```
┌─────────────────┐
│ (1) │ ← 紫色圆形排名徽章(左上)
│ │
│ [立绘 4:5] │
│ │
├─────────────────┤ ← 信息区与立绘明确分隔
│ No.001 │
│ 艺人 A │
│ 樱花校园系 │
│ [♥] 12.5w 票 │ ← [♥] = lucide Heart SVG
│ │
│ ┌─────────────┐ │
│ │ 投票 │ │ ← 紫色实心、占满整行
│ └─────────────┘ │
└─────────────────┘
```
#### 通用规范
| 元素 | 规范 |
|---|---|
| 圆角 | `12px` |
| 背景 | `var(--grad-card)` |
| 立绘 | `aspect-[4/5]`object-cover |
| 立绘 / 信息分隔 | 1px 顶部 `border-subtle` + 信息区底色 `rgba(0,0,0,0.4)` 叠加 |
| Padding信息区 | 12px |
| 编号 No.xxx | 11px、`rgba(255,255,255,0.55)``font-display` |
| 艺人名 | 14px、白色、`font-semibold`、truncate |
| Slogan | 11px、`rgba(255,255,255,0.55)`、truncate |
| Heart SVG + 票数 | 12px、`--color-purple-300``font-display``tabular-nums`(图标用 lucide `Heart` size=12**禁用心形 emoji** |
| 排名徽章 | 28×28 圆形、`--color-purple-500` 实心、白字 14px、`shadow-purple-glow`、左上 distance 8px |
#### 投票按钮(所有排名统一样式)
| 属性 | 值 |
|---|---|
| 高度 | 36px |
| 圆角 | 8px |
| 文案 | **「投票」**(中文,不是 "Vote" |
| 样式 | `--grad-purple` 实心 + 白字 + 紫色辉光(`shadow-[0_0_12px_rgba(139,92,246,0.35)]` |
| Hover | `brightness(1.1)` + 更强辉光 |
| Active | `brightness(0.95)` |
> **注意****所有 35 张卡片的投票按钮完全相同**,不因排名 Top12 / 13+ 而变样式。
#### 排名差异化(严格按设计稿:二档制)
仅通过 **2 个**变量差异化,不动按钮:
| 排名段 | 立绘 | 边框 + 辉光 | 排名徽章 | 投票按钮 |
|---|---|---|---|---|
| **Top 1-12出道位** | 鲜艳(`opacity 1` | `--border-purple` 紫边 + `--shadow-purple` 紫辉光 | 紫色实心圆 + 紫色辉光 | **紫色实心**(同下行) |
| **13+(候选区)** | 轻度暗化(`opacity 0.78` | 14% 白边框 + **无**辉光 | 暗色徽章(`--color-elevated` 底 + 55% 白字) | **紫色实心**(与上行**完全相同** |
> 卡片结构 100% 一致,便于复用同一 React 组件,仅通过 `inTop12` 布尔变量控制立绘 opacity + 边框样式 + 徽章样式三处。
### 7.6 Footer
```
© 2026 CYBER STAR · All Rights Reserved
```
| 属性 | 值 |
|---|---|
| 高度 | 64px |
| 背景 | `--color-deep` |
| 文字 | 11px、`rgba(255,255,255,0.35)`、居中 |
| 不放链接 | 极简化 |
### 7.7 按钮(通用)
| 变体 | 用途 | 样式 |
|---|---|---|
| Primary | 主操作(投票、确认) | `--grad-purple` + 白字 + `--shadow-purple` |
| Outline | 次操作(登录、注册) | 透明背景 + `--border-purple` 边框 + 紫色文字 |
| Ghost | 工具栏(搜索、关闭) | 5% 白背景 + 14% 白边框 + 70% 白文字 |
| Danger | 退出登录 | `--color-pink-500` + 白字 |
| 尺寸 | 高度 | padding-x | font-size |
|---|---|---|---|
| sm | 32 | 14 | 10 |
| md | 44 | 24 | 12 |
| lg | 56 | 40 | 14 |
### 7.8 弹窗 / 遮罩层
| 属性 | 值 |
|---|---|
| 遮罩 | `rgba(0,0,0,0.75)` + `backdrop-blur(md)` |
| 弹窗容器 | `--color-elevated` 95% 透明度 + `border-default` + `rounded-2xl` |
| 阴影 | `0 24px 80px rgba(0,0,0,0.7), 0 0 40px rgba(139,92,246,0.12)` |
| **禁止**:顶部光条 | **删除**(不要 2px 紫色横条,保持容器干净) |
| 关闭按钮 | 右上角 24×24lucide `X` SVG55% 白色 |
| 居中 | `position:fixed``inset:0``flex items-center justify-center` |
| z-index | 100 |
---
## 8. 图标 & 装饰
### 8.1 图标库(铁律)
- **禁止****严禁使用任何 emoji**(包括奖杯、心形、放大镜、喇叭、播放三角、上下箭头等所有彩色 / Unicode 装饰字符)
- **唯一例外**`CYBER ✦ STAR` 品牌字标中间的 `✦`U+2726保留原字符使用
- **使用****统一使用 `lucide-react`**线条风、stroke 1.5-2
- 严禁混用 filled / outlined / cartoon icons
- 标准尺寸12 / 14 / 16 / 20 / 24
### 8.2 场景对应的 lucide 图标
| 场景 | lucide 组件名 | 备注 |
|---|---|---|
| 票数前缀 | `Heart` | `fill="currentColor"` 实心;颜色 `--color-purple-300` |
| Top12 榜单标题 | `Trophy` | 紫色 stroke |
| 标题装饰星 | `Sparkles` 或自定义 `<svg>` | 四角星形 |
| 查看更多链接 | `ChevronRight` | 12-14px |
| 搜索 | `Search` | 16-18px |
| 静音 / 取消静音 | `VolumeX` / `Volume2` | 14-16px |
| 视图切换 | `LayoutGrid` / `List` | 16px |
| 排序 | `ArrowUpDown` | 14px |
| 弹窗关闭 | `X` | 18px |
| 加载中 | `Loader2` + `animate-spin` | 14-16px |
| 投票心形(按钮内) | `Heart` | 14px按钮内置 |
| 播放(仅艺人详情视频用) | `Play` `Pause` | Hero 内不使用 |
### 8.3 装饰符号(仅以 SVG 形式出现)
- **CYBER STAR 中间的 ✦**:使用原 `✦` 字符Unicode U+2726 BLACK FOUR POINTED STAR作为装饰配合紫色辉光。这是品牌字标的一部分**是 §8.1 emoji 禁令的唯一例外**
- **页面装饰光斑 / 粒子**:通过 `body::before` `body::after``radial-gradient` 实现,不引入额外图标
### 8.4 环境装饰层
`body::before` 提供低饱和紫色雾光(左上、右下双焦点),`body::after` 提供 8 个静态星点。**不要在内容区单独添加大面积装饰光斑**,避免视觉拥挤。
---
## 9. 响应式断点
| 断点 | 设备 | 行为 |
|---|---|---|
| `< 640px` | 手机 | 单列 / 双列卡片、横滚 Top12、隐藏 nav 文字 |
| `≥ 640px` | 大手机 / 平板竖 | 3 列卡片 |
| `≥ 768px` | 平板横 | 4 列卡片nav 文字显示 |
| `≥ 1024px` | 桌面 | **5 列卡片**,完整布局 |
| `≥ 1280px` | 大桌面 | 容器 `max-w-7xl` 居中 |
---
## 10. 通用 UI/UX 网页设计规范(必须遵守)
### 10.1 可访问性WCAG 2.1 AA
- 所有交互元素必须有 `aria-label` 或可见文字
- **颜色对比度**:正文文字与背景 ≥ 4.5:1大号文字 ≥ 3:1
- 紫色辉光按钮文字必须保持白色不透明,不要降到半透明
- 不依赖颜色单独传达信息(必须配合图标 / 文字)
- 视频自动播放必须 `muted`,并提供解除静音控件
- 弹窗Escape 关闭、聚焦陷阱focus trap`aria-modal="true"`、关闭后焦点归还触发元素
### 10.2 焦点指示Keyboard Navigation
- **所有可聚焦元素**(按钮、链接、输入框、卡片)必须有可见 `:focus-visible` 轮廓
- 标准2px 紫色实线 + 2px 偏移,颜色 `var(--color-purple-400)`
- 不要使用 `outline: none` 而不提供替代焦点样式
- Tab 顺序遵循视觉顺序(左→右、上→下)
### 10.3 触控目标Touch Target
- 移动端最小触控目标 **44 × 44 px**iOS HIG/ 48 × 48 dpMaterial
- 相邻可点击元素之间至少留 **8px** 间距
- 卡片整体可点(跳详情)+ 内部投票按钮独立可点 → 必须用 `event.preventDefault` + `stopPropagation` 隔离
### 10.4 加载与反馈状态Loading / Empty / Error
每个数据展示组件**必须**实现以下三态:
| 状态 | 视觉 |
|---|---|
| **加载中Loading** | 骨架屏skeleton— 紫灰渐变占位,**不**用 spinner 占整页 |
| **空状态Empty** | 居中插画 / 图标 + 一句说明文字 + 可选行动按钮 |
| **错误Error** | 居中错误图标pink-500+ 描述 + 「重试」按钮 |
异步操作(投票、登录):
- 触发后立即在按钮内显示 loading spinner按钮 `disabled` 防重复
- 成功 / 失败用 `react-hot-toast` 全局 toast 反馈,**不**用 alert / confirm
### 10.5 表单设计
- Label 始终在输入框**上方**(不用浮动 label避免遮挡
- 错误提示:输入框下方红色文字 + 边框转 `pink-500`
- Placeholder 不能代替 label仅给示例
- 必填字段标 `*`(紫色)
- 按钮文案说明动作而非"提交"(如「立即登录」「发送验证码」)
### 10.6 链接与按钮的语义区分
| 元素 | 用途 | 视觉 |
|---|---|---|
| `<button>` | 触发动作(投票、打开弹窗、提交) | 实心 / 描边 / Ghost |
| `<a href>` | 跳转页面(详情页、外链) | 文字 + hover 下划线 |
**禁止**:用按钮做跳转 / 用链接做表单提交。
### 10.7 信息密度与留白
- 同一区块内最多 **3 个**字号层级
- 每个 section 之间纵向留白 ≥ 40px移动/ 64px桌面
- 卡片内部 padding 不小于 12px
- 文字段落最大宽度 ≤ 75 字符(避免长行难读)
### 10.8 文案与本地化
- 中文用中文标点(。,!?:"
- 英文 label 全大写 + tracking
- 数字千位分隔12,650 / 12.6W
- 时间用相对时间("3 分钟前"+ tooltip 显示绝对时间
- 长文本截断必须用 `...` 并在 hover 提供 tooltip
### 10.9 性能预期
- LCP < 2.5sHero 视频懒加载poster 先显示
- CLS < 0.1所有图片 / 视频留好 aspect-ratio 占位
- 卡片立绘必须 `width / height` 属性或 `aspect-ratio` 防回流
### 10.10 暗色模式专属规则
- **禁止纯黑 #000**:用 `#08051A` 作为最深色,避免 OLED 屏闪烁感
- **禁止纯白 #FFF 大面积**:白文字用,白背景**不用**
- 阴影改为辉光box-shadow with color传统黑色阴影在暗色背景不可见
- 图片 / 视频缩略图加 4% 白色 overlay避免在暗背景"漂浮"感
---
## 11. 内容文案约定
| 场景 | 文案 |
|---|---|
| 投票按钮 | **「投票」**(不用 "Vote" |
| 顶部 nav | 「首页」「排行榜」 |
| 标签筛选 | 「全部」「舞蹈担当」「声乐担当」「rap担当」「全能型」rap 小写、无空格) |
| 排名榜标题 | `<Trophy size={14}/>` + 「实时 Top12 出道位」 |
| 倒计时前缀 | 「距离投票结束」 |
| 投票成功 | 「已为 {名字} 投出 {N} 票」 |
| 票数耗尽 | 「今日票数已用完,明天再来吧」 |
| 卡片票数 | `<Heart size={12} fill="currentColor"/>` + 「12.6W 票」W 大写)。**禁用心形 emoji** |
| 版权 | 「© {当年} CYBER STAR · All Rights Reserved」 |
---
## 12. 设计稿与代码 token 映射表(交付辅助)
| Figma 颜色名 | CSS 变量 | Tailwind 工具类 |
|---|---|---|
| Background / Deepest | `var(--color-deepest)` | `bg-deepest` |
| Background / Card | `var(--color-card)` | `bg-card` |
| Brand / Purple 500 | `var(--color-purple-500)` | `bg-purple-500` `text-purple-500` |
| Brand / Purple 300 | `var(--color-purple-300)` | `text-purple-300` |
| Border / Subtle | `var(--border-subtle)` | `border-white/[0.08]` |
| Shadow / Purple Glow | `var(--shadow-purple)` | `shadow-purple-glow` |
| Gradient / Purple | `var(--grad-purple)` | `bg-grad-purple` |
> 设计师交付时**直接引用 Tailwind 工具类名**,开发可零成本接入。

View File

@ -73,8 +73,8 @@ async function main() {
startAt: now,
endAt,
voteEnabled: true,
dailyQuota: 12,
perArtistLimit: 3,
dailyQuota: 10,
perArtistLimit: 0,
paidVoteEnabled: false,
},
update: {

BIN
public/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 358 KiB

View File

@ -4,8 +4,7 @@ import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
/**
* GET /api/me
*
* "今日剩余票数"
*
*/
export async function GET() {
try {
@ -29,53 +28,62 @@ export async function GET() {
};
};
const [profile, signIn, supports] = (await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
nickname: true,
avatar: true,
phone: true,
createdAt: true,
},
}),
prisma.signIn.findFirst({
where: { userId: user.id },
orderBy: { date: "desc" },
}),
prisma.fanSupport.findMany({
where: { userId: user.id },
include: {
artist: {
select: {
id: true,
no: true,
name: true,
enName: true,
slogan: true,
themeColor: true,
voteCount: true,
currentRank: true,
const [profile, signIn, supports, dailyQuotaRow, config] =
(await Promise.all([
prisma.user.findUnique({
where: { id: user.id },
select: {
id: true,
nickname: true,
avatar: true,
phone: true,
createdAt: true,
},
}),
prisma.signIn.findFirst({
where: { userId: user.id },
orderBy: { date: "desc" },
}),
prisma.fanSupport.findMany({
where: { userId: user.id },
include: {
artist: {
select: {
id: true,
no: true,
name: true,
enName: true,
slogan: true,
themeColor: true,
voteCount: true,
currentRank: true,
},
},
},
},
orderBy: { votedTotal: "desc" },
}),
])) as [
Awaited<ReturnType<typeof prisma.user.findUnique>>,
Awaited<ReturnType<typeof prisma.signIn.findFirst>>,
SupportRow[],
];
orderBy: { votedTotal: "desc" },
}),
prisma.dailyQuota.findUnique({
where: { userId_date: { userId: user.id, date: today } },
}),
prisma.activityConfig.findUnique({ where: { id: 1 } }),
])) as [
Awaited<ReturnType<typeof prisma.user.findUnique>>,
Awaited<ReturnType<typeof prisma.signIn.findFirst>>,
SupportRow[],
Awaited<ReturnType<typeof prisma.dailyQuota.findUnique>>,
Awaited<ReturnType<typeof prisma.activityConfig.findUnique>>,
];
if (!profile) return ERR.NOT_FOUND("用户不存在");
// 累计投票数
const totalVotes = await prisma.vote.aggregate({
where: { userId: user.id },
_sum: { count: true },
});
const dailyQuota = dailyQuotaRow?.totalQuota ?? config?.dailyQuota ?? 10;
const usedToday = dailyQuotaRow?.usedQuota ?? 0;
return ok(
sanitizeBigInt({
profile,
@ -85,6 +93,11 @@ export async function GET() {
todaySignedIn: signIn ? sameDay(signIn.date, today) : false,
},
totalVotes: totalVotes._sum.count ?? 0,
dailyQuota: {
total: dailyQuota,
used: usedToday,
remaining: Math.max(0, dailyQuota - usedToday),
},
supports: supports.map((s: SupportRow) => ({
artist: s.artist,
votedTotal: s.votedTotal,

View File

@ -17,26 +17,36 @@ const VoteBody = z.object({
count: z.number().int().min(1).max(99_999),
});
/** 内部抛错用,事务捕获后转为业务错误响应 */
class QuotaExhaustedError extends Error {
constructor(public remaining: number) {
super("QUOTA_EXHAUSTED");
}
}
/**
* POST /api/vote
* · /
*
*
*
* - ActivityConfig.dailyQuota 10
* - perArtistLimit
* - 1 5 IP 60 60
*
*
* 1. + IP /
* 2.
* 3. + +
* 4.
* 1. +
* 2. +
* 3.
* 4. +
*/
export async function POST(req: NextRequest) {
try {
const user = await getCurrentUser();
if (!user) return ERR.UNAUTHORIZED();
// 限流:单用户 1 秒最多 5 次(仅做防刷,非数量限制)
const userRl = await rateLimit(`vote:user:${user.id}`, 1, 5);
if (!userRl.allowed) return ERR.RATE_LIMITED();
// 限流:单 IP 60 秒最多 60 次
const ip = await getClientIp();
if (ip) {
const ipRl = await rateLimit(`vote:ip:${ip}`, 60, 60);
@ -50,53 +60,97 @@ export async function POST(req: NextRequest) {
}
const { artistId, count } = parsed.data;
// 活动状态
const config = await prisma.activityConfig.findUnique({ where: { id: 1 } });
if (!config?.voteEnabled) return ERR.ACTIVITY_OFF();
const now = new Date();
if (now < config.startAt || now > config.endAt) return ERR.ACTIVITY_OFF();
const ua = await getUserAgent();
const today = startOfDay();
const dailyQuota = config.dailyQuota;
const result = await prisma.$transaction(async (tx: TxClient) => {
// 1. 写入投票记录
const vote = await tx.vote.create({
data: {
userId: user.id,
artistId,
count,
source: "QUOTA",
ip: ip ?? undefined,
ua: ua ?? undefined,
},
try {
const result = await prisma.$transaction(async (tx: TxClient) => {
// 1. 当日额度(不存在则按 config 创建)
const dq = await tx.dailyQuota.upsert({
where: { userId_date: { userId: user.id, date: today } },
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. 写入投票
const vote = await tx.vote.create({
data: {
userId: user.id,
artistId,
count,
source: "QUOTA",
ip: ip ?? undefined,
ua: ua ?? undefined,
},
});
// 3. 累加艺人票数
const artist = await tx.artist.update({
where: { id: artistId },
data: { voteCount: { increment: count } },
select: { id: true, voteCount: true, name: true },
});
// 4. 应援关系
await tx.fanSupport.upsert({
where: { userId_artistId: { userId: user.id, artistId } },
create: { userId: user.id, artistId, votedTotal: count },
update: { votedTotal: { increment: count } },
});
// 5. 扣减当日额度
const updatedDq = await tx.dailyQuota.update({
where: { userId_date: { userId: user.id, date: today } },
data: { usedQuota: { increment: count } },
});
return {
vote,
artist,
remaining: updatedDq.totalQuota - updatedDq.usedQuota,
};
});
// 2. 累加艺人票数
const artist = await tx.artist.update({
where: { id: artistId },
data: { voteCount: { increment: count } },
select: { id: true, voteCount: true, name: true },
});
// 3. 更新 / 创建应援关系
await tx.fanSupport.upsert({
where: { userId_artistId: { userId: user.id, artistId } },
create: { userId: user.id, artistId, votedTotal: count },
update: { votedTotal: { increment: count } },
});
return { vote, artist };
});
return ok(
sanitizeBigInt({
artistId: result.artist.id,
artistVotes: result.artist.voteCount,
voteId: result.vote.id,
}),
);
return ok(
sanitizeBigInt({
artistId: result.artist.id,
artistVotes: result.artist.voteCount,
voteId: result.vote.id,
remaining: result.remaining,
dailyQuota,
}),
);
} catch (e) {
if (e instanceof QuotaExhaustedError) {
return ERR.QUOTA_EXHAUSTED(
`今日票数仅剩 ${e.remaining} 票,无法一次投出 ${count}`,
);
}
throw e;
}
} catch (e) {
console.error("[POST /api/vote]", e);
return ERR.INTERNAL();
}
}
function startOfDay(d = new Date()): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}

View File

@ -37,7 +37,7 @@
--color-bronze: #cd7f32;
/* ── 字体(变量注入自 next/font ── */
--font-logo: var(--font-megrim), "Cinzel", serif;
--font-logo: var(--font-orbitron), "Audiowide", sans-serif;
--font-display: var(--font-audiowide), "Audiowide", sans-serif;
--font-label: var(--font-cinzel), "Cinzel", serif;
--font-body: var(--font-inter), -apple-system, "Source Han Sans SC",

View File

@ -1,14 +1,14 @@
import type { Metadata } from "next";
import { Megrim, Audiowide, Cinzel, Inter } from "next/font/google";
import { Orbitron, Audiowide, Cinzel, Inter } from "next/font/google";
import Navigation from "@/components/Navigation";
import Footer from "@/components/Footer";
import Providers from "@/components/Providers";
import "./globals.css";
const megrim = Megrim({
weight: "400",
const orbitron = Orbitron({
weight: ["500", "700", "900"],
subsets: ["latin"],
variable: "--font-megrim",
variable: "--font-orbitron",
display: "swap",
});
@ -52,7 +52,7 @@ export default function RootLayout({
return (
<html
lang="zh-CN"
className={`${megrim.variable} ${audiowide.variable} ${cinzel.variable} ${inter.variable} h-full antialiased`}
className={`${orbitron.variable} ${audiowide.variable} ${cinzel.variable} ${inter.variable} h-full antialiased`}
>
<body className="min-h-full flex flex-col">
<Providers>

View File

@ -179,7 +179,7 @@ export default function LoginForm() {
{process.env.NODE_ENV !== "production" && (
<div className="px-3 py-2 rounded-lg bg-purple-500/8 border border-purple-500/25 text-[11px] text-purple-300/80">
💡 <b className="text-white">123456</b>
<b className="text-white">123456</b>
</div>
)}
@ -216,19 +216,19 @@ export default function LoginForm() {
<div className="flex justify-center gap-3">
<button
disabled
className="w-11 h-11 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-white/30 cursor-not-allowed"
className="w-11 h-11 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-white/30 cursor-not-allowed text-[10px] font-display tracking-wider"
aria-label="微信登录(即将上线)"
title="微信登录(即将上线)"
>
<span className="text-base">🔵</span>
WX
</button>
<button
disabled
className="w-11 h-11 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-white/30 cursor-not-allowed"
className="w-11 h-11 rounded-full bg-white/5 border border-white/10 flex items-center justify-center text-white/30 cursor-not-allowed text-[10px] font-display tracking-wider"
aria-label="QQ 登录(即将上线)"
title="QQ 登录(即将上线)"
>
<span className="text-base">🐧</span>
QQ
</button>
</div>
</div>

View File

@ -1,7 +1,6 @@
"use client";
import { useState } from "react";
import { Gift, Star, LogOut } from "lucide-react";
import { signOut } from "next-auth/react";
import toast from "react-hot-toast";
import UserHeader from "@/components/me/UserHeader";
@ -10,7 +9,11 @@ import StatsGrid from "@/components/me/StatsGrid";
import SignInCalendar from "@/components/me/SignInCalendar";
import MyFanSupport from "@/components/me/MyFanSupport";
import { MOCK_USER, getFanSupports, type MockUser } from "@/lib/mock-user";
import { useVoteStore } from "@/lib/store";
import {
useVoteStore,
selectRemaining,
DAILY_VOTE_QUOTA,
} from "@/lib/store";
interface MeContentProps {
session: {
@ -22,8 +25,8 @@ interface MeContentProps {
export default function MeContent({ session }: MeContentProps) {
const myTotalVotes = useVoteStore((s) => s.myTotalVotes);
const storeArtists = useVoteStore((s) => s.artists);
const remaining = useVoteStore(selectRemaining);
// 本地签到状态(数据库就绪后由 /api/me/signin 提供)
const [signedInToday, setSignedInToday] = useState(MOCK_USER.todaySignedIn);
const [weeklySignIn, setWeeklySignIn] = useState(MOCK_USER.weeklySignIn);
@ -36,7 +39,7 @@ export default function MeContent({ session }: MeContentProps) {
totalVotes: MOCK_USER.totalVotes + myTotalVotes,
};
// 用 store 里最新的艺人排名重算 "我的应援" 当前排名
// 用 store 里最新的艺人排名重算"我的应援"当前排名
const supports = getFanSupports().map((s) => {
const fresh = storeArtists.find((a) => a.id === s.artist.id);
return fresh ? { ...s, artist: fresh } : s;
@ -85,52 +88,51 @@ export default function MeContent({ session }: MeContentProps) {
};
const handleLogout = () => {
toast("正在退出登录…", { icon: "👋" });
toast("正在退出登录…");
signOut({ callbackUrl: "/" });
};
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10 space-y-8">
<UserHeader user={user} />
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10 space-y-8">
<UserHeader user={user} onLogout={handleLogout} />
<QuotaCard total={user.totalVotes} onInvite={handleInvite} />
<QuotaCard
remaining={remaining}
dailyQuota={DAILY_VOTE_QUOTA}
cumulative={user.totalVotes}
onInvite={handleInvite}
/>
<StatsGrid user={user} />
<section>
<div className="flex items-center gap-2 mb-3">
<Gift size={14} className="text-purple-300" />
<h2 className="font-display text-sm tracking-[0.25em] text-white uppercase">
</h2>
</div>
<SectionTitle label="每日签到" />
<SignInCalendar
weekly={user.weeklySignIn}
todaySigned={user.todaySignedIn}
streak={user.signInStreak}
onSignIn={handleSignIn}
/>
</section>
<section>
<div className="flex items-center gap-2 mb-3">
<Star size={14} className="text-purple-300" />
<h2 className="font-display text-sm tracking-[0.25em] text-white uppercase">
</h2>
</div>
<SectionTitle label="我的应援" />
<MyFanSupport supports={supports} />
</section>
<div className="pt-4 border-t border-white/[0.06] flex justify-center">
<button
type="button"
onClick={handleLogout}
className="inline-flex items-center gap-2 px-4 h-10 rounded-full bg-white/[0.04] border border-white/10 text-white/55 hover:text-pink-400 hover:border-pink-500/40 transition-colors font-display text-xs tracking-widest uppercase"
>
<LogOut size={13} />
退
</button>
</div>
</div>
);
}
function SectionTitle({ label }: { label: string }) {
return (
<div className="mb-4 flex items-center gap-2.5">
<span
aria-hidden
className="w-1 h-4 rounded-full bg-purple-400 shadow-[0_0_8px_rgba(167,139,250,0.7)]"
/>
<h2 className="text-sm font-semibold text-white tracking-wider">
{label}
</h2>
</div>
);
}

View File

@ -10,9 +10,9 @@ export const metadata: Metadata = {
export default async function MePage() {
const session = await auth();
// 未登录 → 跳登录页(登录后回到 /me
// 未登录 → 回到首页(登录弹窗由导航 / vote 交互触发,不再使用独立 /login 页
if (!session?.user) {
redirect("/login?callbackUrl=/me");
redirect("/");
}
const sessionUser = session.user as { id?: string; name?: string | null };

View File

@ -1,32 +1,25 @@
"use client";
import { useMemo, useState } from "react";
import Link from "next/link";
import { ChevronRight } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { Users } from "lucide-react";
import HeroBanner from "@/components/HeroBanner";
import Top12Bar from "@/components/Top12Bar";
import ArtistCard from "@/components/cards/ArtistCard";
import ArtistFilters, {
type TagFilter,
type ViewMode,
} from "@/components/ArtistFilters";
import ArtistFilters, { type TagFilter } from "@/components/ArtistFilters";
import VoteModal from "@/components/VoteModal";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { getActivityEndTime, sortArtists } from "@/lib/mock-data";
import type { SortKey } from "@/lib/mock-data";
import type { Artist } from "@/types/artist";
import { useVoteStore } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction";
import { cn } from "@/lib/cn";
export default function Home() {
const artists = useVoteStore((s) => s.artists);
const { target, openVote, closeVote, confirmVote } = useVoteAction();
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
useVoteAction();
const [sortKey, setSortKey] = useState<SortKey>("votes");
const [tagFilter, setTagFilter] = useState<TagFilter>("all");
const [search, setSearch] = useState("");
const [view, setView] = useState<ViewMode>("grid");
const [filterStuck, setFilterStuck] = useState(false);
const filterSentinelRef = useRef<HTMLDivElement>(null);
const endTime = useMemo(() => getActivityEndTime(), []);
@ -35,169 +28,136 @@ export default function Home() {
if (tagFilter !== "all") {
list = list.filter((a) => a.tags.includes(tagFilter));
}
if (search.trim()) {
const q = search.trim().toLowerCase();
list = list.filter(
(a) =>
a.name.toLowerCase().includes(q) ||
a.enName.toLowerCase().includes(q) ||
a.no.includes(q),
);
}
return sortArtists(list, sortKey);
}, [artists, sortKey, tagFilter, search]);
return sortArtists(list, "votes");
}, [artists, tagFilter]);
// 仅在首页启用 scroll-snap mandatory用户下滑就立即切换到下一个 snap 点
// (Hero → Top12 → 候选区)。卸载时还原。
useEffect(() => {
const root = document.documentElement;
const prev = root.style.scrollSnapType;
root.style.scrollSnapType = "y mandatory";
return () => {
root.style.scrollSnapType = prev;
};
}, []);
// 检测筛选条是否吸顶:直接读哨兵相对视口的 top越过 nav 下沿80px即 stuck。
// 不用 IntersectionObserver — 它对零面积/scroll-snap 场景不稳定scroll 监听更直接。
useEffect(() => {
const sentinel = filterSentinelRef.current;
if (!sentinel) return;
const update = () => {
const top = sentinel.getBoundingClientRect().top;
setFilterStuck(top <= 80);
};
update();
window.addEventListener("scroll", update, { passive: true });
window.addEventListener("resize", update);
return () => {
window.removeEventListener("scroll", update);
window.removeEventListener("resize", update);
};
}, []);
return (
<>
{/* Hero Banner */}
<section className="px-4 sm:px-6 lg:px-8 pt-4 max-w-7xl mx-auto">
{/* Hero · 全屏沉浸式视频 · 作为第一个 snap 点 */}
<div
style={{
scrollSnapAlign: "start",
scrollMarginTop: "80px",
}}
>
<HeroBanner endTime={endTime} />
</section>
</div>
{/* Top12 实时榜条 */}
<section className="px-4 sm:px-6 lg:px-8 pt-10 max-w-7xl mx-auto">
<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 uppercase flex items-center gap-2">
<span className="text-purple-300">🏆</span>
Top 12 ·
</h2>
<Link
href="/ranking"
className="font-label text-[11px] tracking-widest text-purple-300 hover:text-purple-200 uppercase inline-flex items-center gap-0.5"
>
<ChevronRight size={12} />
</Link>
</div>
{/* Top12 出道位 · 作为第二个 snap 点:滚动结束后自然落到这里,标题贴近顶部 */}
<section
style={{
scrollSnapAlign: "start",
scrollMarginTop: "80px",
}}
className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pt-8 sm:pt-10"
>
<Top12Bar artists={artists} />
</section>
{/* 候选人阵容 */}
{/* 候选人阵容 · 作为第三个 snap 点 */}
<section
id="artists"
className="px-4 sm:px-6 lg:px-8 pt-12 pb-16 max-w-7xl mx-auto"
style={{
scrollSnapAlign: "start",
scrollMarginTop: "80px",
}}
className="pb-12"
>
<div className="flex items-end justify-between mb-4 px-1">
<div>
<p className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/80 mb-1">
Candidates · 35 Idols
</p>
<h2 className="font-display text-base sm:text-lg tracking-[0.2em] text-white uppercase">
{/* 大标题 · 版心内 */}
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pt-10">
<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">
<Users size={16} className="text-purple-300" />
35
</h2>
<p className="font-label text-[11px] tracking-widest text-white/45 uppercase">
{" "}
<span className="text-purple-300 tabular-nums">
{visibleArtists.length}
</span>{" "}
</p>
</div>
<p className="text-xs text-white/45 hidden sm:block">
<span className="text-purple-300">{visibleArtists.length}</span>{" "}
</p>
</div>
<ArtistFilters
sortKey={sortKey}
onSortChange={setSortKey}
tagFilter={tagFilter}
onTagChange={setTagFilter}
search={search}
onSearchChange={setSearch}
view={view}
onViewChange={setView}
/>
{/* 哨兵:用于检测筛选条是否吸顶 */}
<div ref={filterSentinelRef} aria-hidden style={{ height: 0 }} />
{visibleArtists.length === 0 ? (
<EmptyState />
) : view === "grid" ? (
<div className="mt-5 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 sm:gap-4">
{visibleArtists.map((a) => (
<ArtistCard key={a.id} artist={a} onVote={openVote} />
))}
{/* 筛选条 · 外层铺满(与导航栏同宽),吸顶后启用毛玻璃;内层版心承载文案 */}
<div
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" }}
>
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
<ArtistFilters tagFilter={tagFilter} onTagChange={setTagFilter} />
</div>
) : (
<div className="mt-5 space-y-2">
{visibleArtists.map((a) => (
<ArtistListRow key={a.id} artist={a} onVote={openVote} />
))}
</div>
)}
</div>
{/* 候选人网格 · 版心内 */}
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
{visibleArtists.length === 0 ? (
<EmptyState />
) : (
<div className="mt-6 grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-3 sm:gap-4">
{visibleArtists.map((a) => (
<ArtistCard key={a.id} artist={a} onVote={openVote} />
))}
</div>
)}
</div>
</section>
{/* 投票弹窗 */}
<VoteModal artist={target} onClose={closeVote} onConfirm={confirmVote} />
<VoteModal
artist={target}
remaining={remaining}
dailyQuota={dailyQuota}
onClose={closeVote}
onConfirm={confirmVote}
/>
</>
);
}
function ArtistListRow({
artist,
onVote,
}: {
artist: Artist;
onVote: (a: Artist) => void;
}) {
const inTop12 = artist.rank <= 12;
return (
<div
className={cn(
"flex items-center gap-3 sm:gap-4 p-3 rounded-xl border transition-all",
inTop12
? "border-purple-500/40 bg-purple-500/[0.04] hover:bg-purple-500/[0.08]"
: "border-white/[0.06] bg-surface/40 hover:bg-surface/60",
)}
>
<div className="w-12 text-center font-display text-lg text-purple-300 tabular-nums">
#{artist.rank}
</div>
<Link
href={`/artist/${artist.id}`}
className="flex items-center gap-3 flex-1 min-w-0"
>
<div className="w-12 h-12 rounded-full overflow-hidden border border-white/15 flex-shrink-0">
<ArtistPortrait
artist={artist}
rounded="rounded-full"
className="w-full h-full"
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm text-white font-semibold truncate">
{artist.name}{" "}
<span className="text-white/55 font-normal text-xs">
· {artist.enName}
</span>
</div>
<div className="text-[11px] text-white/40 truncate">
No.{artist.no} · {artist.slogan}
</div>
</div>
</Link>
<div className="hidden sm:block w-24 text-right">
<div className="font-display text-sm text-purple-300 tabular-nums">
{(artist.votes / 10000).toFixed(1)}w
</div>
<div className="text-[10px] text-white/40"></div>
</div>
<button
type="button"
onClick={() => onVote(artist)}
className={cn(
"px-4 py-1.5 rounded-full font-display text-[10px] tracking-widest uppercase transition-all flex-shrink-0",
inTop12
? "bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.4)] hover:brightness-110"
: "bg-elevated border border-white/15 text-white/70 hover:text-white",
)}
>
Vote
</button>
</div>
);
}
function EmptyState() {
return (
<div className="mt-8 py-16 text-center text-white/45 border border-dashed border-white/10 rounded-xl">
<p className="font-label text-xs tracking-widest uppercase text-purple-300 mb-2">
No Match
</p>
<p className="text-sm"></p>
<p className="text-sm"></p>
</div>
);
}

View File

@ -1,14 +1,12 @@
"use client";
import { useMemo } from "react";
import { Sparkles } from "lucide-react";
import Top3Podium from "@/components/ranking/Top3Podium";
import RankingRow from "@/components/ranking/RankingRow";
import DebutLineDivider from "@/components/ranking/DebutLineDivider";
import VoteModal from "@/components/VoteModal";
import Countdown from "@/components/ui/Countdown";
import LiveBadge from "@/components/LiveBadge";
import { getActivityEndTime, sortArtists } from "@/lib/mock-data";
import { sortArtists } from "@/lib/mock-data";
import { useVoteStore } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction";
import { useRanking } from "@/hooks/useRanking";
@ -16,9 +14,9 @@ import type { Artist } from "@/types/artist";
export default function RankingPage() {
const storeArtists = useVoteStore((s) => s.artists);
const { target, openVote, closeVote, confirmVote } = useVoteAction();
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
useVoteAction();
// 实时排名API 可用时生效;失败则继续用 store 本地数据)
const live = useRanking({ pollInterval: 30_000 });
const sorted = useMemo<Artist[]>(() => {
@ -32,8 +30,6 @@ export default function RankingPage() {
return sortArtists(storeArtists, "votes");
}, [storeArtists, live.data]);
const endTime = useMemo(() => getActivityEndTime(), []);
const top3 = sorted.slice(0, 3);
const top4to12 = sorted.slice(3, 12);
const candidates = sorted.slice(12);
@ -42,86 +38,73 @@ export default function RankingPage() {
return (
<>
<div className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-12">
{/* 头部 */}
<div className="text-center mb-10">
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-300 mb-2">
Live Ranking · 2026
</p>
<h1 className="font-logo text-4xl sm:text-5xl tracking-[0.3em] uppercase glow-text-purple mb-3 inline-flex items-baseline">
Top
<span className="text-purple-300 mx-2 text-2xl sm:text-3xl"></span>
35
</h1>
<p className="text-white/55 text-sm mb-5">35 · </p>
<div className="flex flex-wrap justify-center items-center gap-3">
<Countdown endTime={endTime} compact />
<LiveBadge updatedAt={live.lastUpdated} paused={!!live.error} />
<div className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10">
{/* Top 3 领奖台 */}
<Top3Podium top3={top3} />
{/* 实时刷新标识 */}
<div className="flex justify-end mt-4 mb-2">
<LiveBadge updatedAt={live.lastUpdated} paused={!!live.error} />
</div>
{/* 列表头部 */}
<div className="mt-6 rounded-2xl border border-white/[0.06] bg-base/60 backdrop-blur-sm overflow-hidden">
<div className="hidden sm:grid grid-cols-[72px_56px_1fr_96px_120px_88px] gap-4 px-4 py-3 text-[10px] tracking-widest uppercase text-white/45 font-label border-b border-white/[0.06]">
<span className="text-center"></span>
<span></span>
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-center"></span>
</div>
<div>
{top4to12.map((a, idx) => {
const prev = idx === 0 ? top3[2] : top4to12[idx - 1];
const gap = prev ? prev.votes - a.votes : undefined;
return (
<RankingRow
key={a.id}
artist={a}
gapAbove={gap}
onVote={openVote}
/>
);
})}
</div>
<DebutLineDivider />
<div>
{candidates.map((a, idx) => {
const prev =
idx === 0 ? top4to12[top4to12.length - 1] : candidates[idx - 1];
const gap = prev ? prev.votes - a.votes : undefined;
const isRescue = idx === 0;
const gapToDebut = isRescue ? debutCutoff - a.votes + 1 : undefined;
return (
<RankingRow
key={a.id}
artist={a}
gapAbove={gap}
gapToDebut={gapToDebut}
isRescue={isRescue}
onVote={openVote}
/>
);
})}
</div>
</div>
{/* Top3 领奖台 */}
<Top3Podium top3={top3} />
{/* Top 4-12 标题 */}
<div className="flex items-center gap-2 mt-10 mb-4">
<Sparkles size={14} className="text-purple-300" />
<h2 className="font-display text-sm tracking-[0.25em] text-white uppercase">
· Top 4 ~ 12
</h2>
</div>
<div className="hidden sm:grid grid-cols-[64px_64px_1fr_100px_140px_110px] gap-4 px-3 py-2 text-[10px] tracking-widest uppercase text-white/40 font-label">
<span className="text-center"></span>
<span></span>
<span></span>
<span className="text-right"></span>
<span className="text-right"></span>
<span className="text-center"></span>
</div>
<div className="space-y-2">
{top4to12.map((a, idx) => {
const prev = idx === 0 ? top3[2] : top4to12[idx - 1];
const gap = prev ? prev.votes - a.votes : undefined;
return (
<RankingRow
key={a.id}
artist={a}
gapAbove={gap}
onVote={openVote}
/>
);
})}
</div>
<DebutLineDivider />
<div className="space-y-2">
{candidates.map((a, idx) => {
const prev = idx === 0 ? top4to12[top4to12.length - 1] : candidates[idx - 1];
const gap = prev ? prev.votes - a.votes : undefined;
const isRescue = idx === 0;
const gapToDebut = isRescue ? debutCutoff - a.votes + 1 : undefined;
return (
<RankingRow
key={a.id}
artist={a}
gapAbove={gap}
gapToDebut={gapToDebut}
isRescue={isRescue}
onVote={openVote}
/>
);
})}
</div>
<p className="text-xs text-white/35 text-center mt-10">
30 ·
</p>
</div>
<VoteModal artist={target} onClose={closeVote} onConfirm={confirmVote} />
<VoteModal
artist={target}
remaining={remaining}
dailyQuota={dailyQuota}
onClose={closeVote}
onConfirm={confirmVote}
/>
</>
);
}

View File

@ -1,134 +1,44 @@
"use client";
import { Search, LayoutGrid, List } from "lucide-react";
import { cn } from "@/lib/cn";
import type { ArtistTag } from "@/types/artist";
import type { SortKey } from "@/lib/mock-data";
export type ViewMode = "grid" | "list";
export type TagFilter = ArtistTag | "all";
interface ArtistFiltersProps {
sortKey: SortKey;
onSortChange: (key: SortKey) => void;
tagFilter: TagFilter;
onTagChange: (tag: TagFilter) => void;
search: string;
onSearchChange: (q: string) => void;
view: ViewMode;
onViewChange: (v: ViewMode) => void;
}
const SORT_OPTIONS: { key: SortKey; label: string }[] = [
{ key: "votes", label: "票数" },
{ key: "no", label: "编号" },
{ key: "recent", label: "最近活跃" },
];
const TAG_OPTIONS: { key: TagFilter; label: string }[] = [
{ key: "all", label: "全部" },
{ key: "dance", label: "舞蹈担当" },
{ key: "vocal", label: "声乐担当" },
{ key: "rap", label: "Rap 担当" },
{ key: "rap", label: "rap担当" },
{ key: "all-rounder", label: "全能型" },
];
export default function ArtistFilters({
sortKey,
onSortChange,
tagFilter,
onTagChange,
search,
onSearchChange,
view,
onViewChange,
}: ArtistFiltersProps) {
return (
<div className="bg-surface/60 backdrop-blur-md border border-white/[0.06] rounded-xl p-3 sm:p-4 flex flex-wrap items-center gap-2 sm:gap-3">
{/* 排序 */}
<span className="font-label text-[10px] tracking-widest text-white/45 uppercase">
</span>
<div className="flex gap-1.5">
{SORT_OPTIONS.map((opt) => (
<FilterTag
key={opt.key}
active={sortKey === opt.key}
onClick={() => onSortChange(opt.key)}
>
{opt.label}
</FilterTag>
))}
</div>
<span className="hidden sm:inline-block w-px h-5 bg-white/10 mx-1" />
{/* 标签 */}
<span className="font-label text-[10px] tracking-widest text-white/45 uppercase">
</span>
<div className="flex gap-1.5 flex-wrap">
{TAG_OPTIONS.map((opt) => (
<FilterTag
key={opt.key}
active={tagFilter === opt.key}
onClick={() => onTagChange(opt.key)}
>
{opt.label}
</FilterTag>
))}
</div>
{/* 搜索 + 视图切换:靠右 */}
<div className="ml-auto flex items-center gap-2 w-full sm:w-auto">
<label className="relative flex-1 sm:flex-initial">
<Search
size={14}
className="absolute left-3 top-1/2 -translate-y-1/2 text-white/35"
/>
<input
type="search"
placeholder="搜索艺人名称..."
value={search}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 pr-3 h-9 w-full sm:w-56 rounded-full bg-deep/60 border border-white/10 text-sm text-white placeholder-white/35 focus:outline-none focus:border-purple-500/60 focus:shadow-[0_0_12px_rgba(139,92,246,0.2)] transition-all"
/>
</label>
<div className="flex items-center bg-deep/60 border border-white/10 rounded-full p-0.5">
<button
type="button"
onClick={() => onViewChange("grid")}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center transition-colors",
view === "grid"
? "bg-purple-500/25 text-purple-200"
: "text-white/45 hover:text-white/75",
)}
aria-label="网格视图"
>
<LayoutGrid size={13} />
</button>
<button
type="button"
onClick={() => onViewChange("list")}
className={cn(
"w-8 h-8 rounded-full flex items-center justify-center transition-colors",
view === "list"
? "bg-purple-500/25 text-purple-200"
: "text-white/45 hover:text-white/75",
)}
aria-label="列表视图"
>
<List size={13} />
</button>
</div>
</div>
<div className="flex items-center gap-1 py-3 border-b border-white/[0.06] overflow-x-auto">
{TAG_OPTIONS.map((opt) => (
<TagPill
key={opt.key}
active={tagFilter === opt.key}
onClick={() => onTagChange(opt.key)}
>
{opt.label}
</TagPill>
))}
</div>
);
}
function FilterTag({
function TagPill({
active,
onClick,
children,
@ -142,13 +52,17 @@ function FilterTag({
type="button"
onClick={onClick}
className={cn(
"px-3 py-1 rounded-full text-xs transition-all",
active
? "bg-purple-500/20 border border-purple-400 text-purple-200 shadow-[0_0_10px_rgba(139,92,246,0.25)]"
: "bg-white/[0.04] border border-white/10 text-white/60 hover:text-white hover:border-white/25",
"relative px-3.5 py-1.5 text-xs whitespace-nowrap transition-colors",
active ? "text-purple-300" : "text-white/55 hover:text-white/85",
)}
>
{children}
{active && (
<span
className="absolute -bottom-0.5 left-3 right-3 h-0.5 bg-purple-400 rounded-full"
style={{ boxShadow: "0 0 6px #a78bfa" }}
/>
)}
</button>
);
}

View File

@ -40,7 +40,7 @@ export default function FloatingVoteButton({
)}
>
<Heart size={16} fill="white" className="mb-0.5" />
<span>VOTE</span>
<span></span>
</button>
);
}

View File

@ -1,22 +1,14 @@
import Logo from "./Logo";
export default function Footer() {
const year = new Date().getFullYear();
return (
<footer className="border-t border-white/[0.06] bg-[rgba(8,5,26,0.6)] backdrop-blur-sm mt-20">
<div className="max-w-7xl mx-auto px-6 sm:px-8 py-8 flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<Logo size="sm" href={null} />
<p className="mt-2 text-xs text-white/45 leading-relaxed">
Top12 · Cyber Star · Virtual Idol Debut Project
</p>
</div>
<div className="text-xs text-white/40 sm:text-right">
<p className="font-label tracking-[0.25em] uppercase text-purple-300/70 mb-1">
© 2026 Cyber Star
</p>
<p className="font-mono text-white/30">airlabs.art</p>
</div>
<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 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]">
© {year} CYBER STAR · All Rights Reserved
</p>
</div>
</footer>
);

View File

@ -1,14 +1,7 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {
Play,
Pause,
Volume2,
VolumeX,
Maximize2,
ChevronDown,
} from "lucide-react";
import { Volume2, VolumeX } from "lucide-react";
import Countdown from "./ui/Countdown";
import { cn } from "@/lib/cn";
@ -19,72 +12,53 @@ interface HeroBannerProps {
poster?: string;
/** 活动结束时间 */
endTime: Date | string | number;
/** 标题主行(默认 CYBER STAR */
title?: string;
/** 副标题 */
subtitle?: string;
/** 上标小字 */
eyebrow?: string;
className?: string;
}
/**
* Hero
* - 1500
* - = 100svh - 80px
* - 使
*/
export default function HeroBanner({
videoSrc,
poster,
endTime,
title = "CYBER STAR",
subtitle = "虚拟偶像出道企划",
eyebrow = "Top 12 · Virtual Idol Debut Project",
className,
}: HeroBannerProps) {
const videoRef = useRef<HTMLVideoElement>(null);
const [isPlaying, setIsPlaying] = useState(true);
const [isMuted, setIsMuted] = useState(true);
const [hasInteracted, setHasInteracted] = useState(false);
// 自动播放(静音)
useEffect(() => {
const v = videoRef.current;
if (!v || !videoSrc) return;
v.play().catch(() => setIsPlaying(false));
v.muted = isMuted;
v.play().catch(() => {});
// 仅在 videoSrc 变化时执行 · 不依赖 isMutedmute 切换由按钮处理)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [videoSrc]);
const togglePlay = () => {
const v = videoRef.current;
if (!v) return;
if (v.paused) {
v.play();
setIsPlaying(true);
} else {
v.pause();
setIsPlaying(false);
}
setHasInteracted(true);
};
const toggleMute = () => {
const v = videoRef.current;
if (!v) return;
v.muted = !v.muted;
setIsMuted(v.muted);
setHasInteracted(true);
};
const goFullscreen = () => {
videoRef.current?.requestFullscreen?.();
};
const scrollToContent = () => {
window.scrollBy({ top: window.innerHeight * 0.85, behavior: "smooth" });
setIsMuted((prev) => {
const next = !prev;
const v = videoRef.current;
if (v) v.muted = next;
return next;
});
};
return (
<section
className={cn(
"relative w-full overflow-hidden rounded-2xl border border-white/[0.06]",
"h-[70vh] min-h-[480px] max-h-[720px]",
"relative w-full overflow-hidden bg-deepest",
"min-h-[560px]",
className,
)}
style={{
height: "calc(100svh - 80px)",
minHeight: "560px",
}}
>
{/* 背景视频 / 渐变占位 */}
{videoSrc ? (
@ -96,120 +70,72 @@ export default function HeroBanner({
muted
loop
playsInline
preload="auto"
className="absolute inset-0 w-full h-full object-cover"
/>
) : (
<div className="absolute inset-0 bg-grad-hero" />
)}
{/* 装饰:能量光环 */}
<div
aria-hidden
className="absolute -top-32 -right-32 w-[480px] h-[480px] rounded-full pointer-events-none animate-spin-slow"
style={{
background:
"conic-gradient(from 0deg, transparent, rgba(196,181,253,0.18), transparent 60%)",
maskImage:
"radial-gradient(circle, transparent 50%, black 51%, black 70%, transparent 71%)",
WebkitMaskImage:
"radial-gradient(circle, transparent 50%, black 51%, black 70%, transparent 71%)",
}}
/>
{/* 蒙层渐变 */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none"
style={{
background:
"linear-gradient(180deg, rgba(8,5,26,0.45) 0%, rgba(8,5,26,0.15) 35%, rgba(8,5,26,0.75) 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%)",
}}
/>
{/* 顶部:左侧 PV 标 + 右侧倒计时 */}
<div className="absolute inset-x-0 top-0 p-5 sm:p-6 flex items-start justify-between z-10">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-black/55 backdrop-blur-md border border-white/10 text-[11px] text-white/85 tracking-wider font-label">
Debut PV {videoSrc ? "自动播放" : "敬请期待"}
{/* 版心容器1500px max-width所有文案 / 倒计时 / 声音按钮全部在内 */}
<div className="absolute inset-0 max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
{/* Eyebrow 左上 */}
<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.4em] uppercase text-purple-200/90">
Top 12 · Virtual Idol Debut Project
</p>
</div>
<Countdown endTime={endTime} compact />
</div>
{/* 中央内容Logo + 副标题 + Play 按钮 */}
<div className="absolute inset-0 flex flex-col items-center justify-center z-10 px-6 text-center">
<p className="font-label text-[10px] sm:text-xs tracking-[0.4em] uppercase text-purple-200/90 mb-4">
{eyebrow}
</p>
<h1
className="font-logo text-5xl sm:text-7xl lg:text-8xl tracking-[0.35em] uppercase glow-text-purple inline-flex items-baseline"
style={{ paddingLeft: "0.35em" }}
>
{title.split("STAR").map((part, i) =>
i === 0 ? (
<span key={i}>{part}</span>
) : (
<span key={i} className="inline-flex items-baseline">
<span className="text-purple-300 mx-2 sm:mx-3 text-3xl sm:text-5xl lg:text-6xl">
</span>
<span>STAR</span>
{part.replace("STAR", "")}
</span>
),
)}
</h1>
<p className="mt-4 text-white/65 text-sm sm:text-base">{subtitle}</p>
{/* 浅紫边框倒计时 右上 */}
<div className="absolute top-5 sm:top-8 right-4 sm:right-6 lg:right-8 z-10">
<Countdown endTime={endTime} compact />
</div>
{/* 播放按钮 */}
<button
type="button"
onClick={togglePlay}
className="mt-8 inline-flex items-center gap-3 px-5 py-3 rounded-full bg-white/10 hover:bg-white/15 backdrop-blur-md border border-white/20 transition-all group"
>
<span className="w-11 h-11 rounded-full bg-purple-500 flex items-center justify-center text-white shadow-purple-glow group-hover:scale-105 transition-transform">
{isPlaying ? <Pause size={18} /> : <Play size={18} fill="white" />}
</span>
<span className="font-display text-xs tracking-[0.25em] uppercase text-white pr-2">
Play Debut PV
</span>
</button>
</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 className="absolute bottom-5 right-5 flex items-center gap-2 z-10">
{/* 声音按钮 右下 */}
<button
type="button"
onClick={toggleMute}
aria-label={isMuted ? "取消静音" : "静音"}
className="w-9 h-9 rounded-full bg-black/55 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/85 hover:text-white hover:bg-black/70 transition-colors"
aria-pressed={!isMuted}
className="absolute bottom-6 sm:bottom-8 right-4 sm:right-6 lg:right-8 z-10 w-10 h-10 rounded-full bg-black/55 backdrop-blur-md border border-white/15 flex items-center justify-center text-white/85 hover:text-white hover:bg-black/75 active:scale-95 transition-all cursor-pointer"
>
{isMuted ? <VolumeX size={14} /> : <Volume2 size={14} />}
</button>
<button
type="button"
onClick={goFullscreen}
aria-label="全屏"
className="w-9 h-9 rounded-full bg-black/55 backdrop-blur-md border border-white/10 flex items-center justify-center text-white/85 hover:text-white hover:bg-black/70 transition-colors"
>
<Maximize2 size={14} />
{isMuted ? <VolumeX size={16} /> : <Volume2 size={16} />}
</button>
</div>
{/* 底部左侧:滚动提示 */}
<button
type="button"
onClick={scrollToContent}
className="absolute bottom-5 left-1/2 -translate-x-1/2 z-10 inline-flex items-center gap-2 text-white/55 hover:text-white/90 transition-colors animate-float"
>
<span className="font-label text-[10px] tracking-[0.3em] uppercase">
Scroll to Explore
</span>
<ChevronDown size={14} />
</button>
{/* 隐藏的"用户已交互"标志,用于满足可访问性 */}
<span aria-hidden className="sr-only">
{hasInteracted ? "interacted" : "auto"}
</span>
{/* 底部渐隐 */}
<div
aria-hidden
className="absolute inset-x-0 bottom-0 h-24 pointer-events-none"
style={{
background:
"linear-gradient(180deg, transparent 0%, var(--color-deepest) 100%)",
}}
/>
</section>
);
}

View File

@ -8,11 +8,12 @@ interface LogoProps {
className?: string;
}
const SIZE_MAP: Record<LogoSize, { text: string; star: string; gap: string }> = {
sm: { text: "text-base", star: "text-xs", gap: "mx-1" },
md: { text: "text-xl sm:text-2xl", star: "text-sm", gap: "mx-1.5" },
lg: { text: "text-4xl sm:text-5xl", star: "text-2xl", gap: "mx-2" },
xl: { text: "text-6xl sm:text-7xl", star: "text-4xl", gap: "mx-3" },
// 高度由 size 控制,宽度按 logo.png 实际比例(约 5.41:1单行 CYBER STAR + 星环)自适应
const HEIGHT_PX: Record<LogoSize, number> = {
sm: 24,
md: 44,
lg: 64,
xl: 88,
};
export default function Logo({
@ -20,21 +21,35 @@ export default function Logo({
href = "/",
className = "",
}: LogoProps) {
const { text, star, gap } = SIZE_MAP[size];
const h = HEIGHT_PX[size];
// 用原生 <img> 绕开 Next/Image 的格式转换 —— 某些环境下 sharp 把透明 PNG
// 转 webp/avif 时会铺白底,导致 logo 在深色 nav 上出现白色矩形。
const inner = (
<span
className={`font-logo tracking-[0.25em] uppercase glow-text-purple inline-flex items-baseline ${text} ${className}`}
>
Cyber
<span className={`text-purple-300 ${gap} ${star}`}></span>
Star
</span>
<img
src="/logo.png"
alt="CYBER STAR"
decoding="async"
draggable={false}
style={{
height: `${h}px`,
width: "auto",
background: "transparent",
// 保留紫色辉光,但 drop-shadow 不会引入白底
filter: "drop-shadow(0 0 14px rgba(139,92,246,0.4))",
}}
className={`block select-none ${className}`}
/>
);
if (!href) return inner;
return (
<Link href={href} className="inline-block hover:opacity-90 transition-opacity">
<Link
href={href}
className="inline-flex items-center hover:opacity-90 transition-opacity"
aria-label="CYBER STAR · 首页"
style={{ background: "transparent" }}
>
{inner}
</Link>
);

View File

@ -2,12 +2,20 @@
import Link from "next/link";
import { usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import { cn } from "@/lib/cn";
import { useLoginModalStore } from "@/lib/login-modal-store";
const NAV_ITEMS = [
{ label: "HOME", href: "/" },
{ label: "RANKING", href: "/ranking" },
] as const;
const NAV_ITEMS: Array<{
label: string;
href: string;
/** 若 true未登录时点击会拦截并弹出登录弹窗 */
requireAuth?: boolean;
}> = [
{ label: "首页", href: "/" },
{ label: "排行榜", href: "/ranking" },
{ label: "我的", href: "/me", requireAuth: true },
];
interface NavLinksProps {
className?: string;
@ -16,17 +24,30 @@ interface NavLinksProps {
export default function NavLinks({ className, mobile = false }: NavLinksProps) {
const pathname = usePathname();
const { status } = useSession();
const openLogin = useLoginModalStore((s) => s.show);
const isActive = (href: string) => {
if (href === "/") return pathname === "/";
return pathname.startsWith(href);
};
const handleClick = (
e: React.MouseEvent<HTMLAnchorElement>,
item: (typeof NAV_ITEMS)[number],
) => {
if (item.requireAuth && status !== "authenticated") {
e.preventDefault();
// 登录成功后直接跳目标页面(如「我的」→ /me不回首页
if (status === "unauthenticated") openLogin(item.href);
}
};
if (mobile) {
return (
<ul
className={cn(
"flex items-center gap-6 px-6 py-2.5 font-display text-[11px] tracking-[0.25em] whitespace-nowrap",
"flex items-center gap-6 px-6 py-2.5 text-[13px] tracking-[0.1em] whitespace-nowrap",
className,
)}
>
@ -36,8 +57,9 @@ export default function NavLinks({ className, mobile = false }: NavLinksProps) {
<li key={item.href}>
<Link
href={item.href}
onClick={(e) => handleClick(e, item)}
className={cn(
"uppercase transition-colors",
"transition-colors",
active ? "text-purple-300" : "text-white/55",
)}
>
@ -53,7 +75,7 @@ export default function NavLinks({ className, mobile = false }: NavLinksProps) {
return (
<ul
className={cn(
"items-center gap-8 flex-1 font-display text-xs tracking-[0.25em]",
"items-center gap-8 text-sm tracking-[0.1em]",
className,
)}
>
@ -63,8 +85,9 @@ export default function NavLinks({ className, mobile = false }: NavLinksProps) {
<li key={item.href}>
<Link
href={item.href}
onClick={(e) => handleClick(e, item)}
className={cn(
"relative py-1 transition-colors uppercase",
"relative py-1 transition-colors",
active
? "text-purple-300 glow-text-purple"
: "text-white/65 hover:text-white",
@ -73,7 +96,7 @@ export default function NavLinks({ className, mobile = false }: NavLinksProps) {
{item.label}
{active && (
<span
className="absolute -bottom-0.5 left-0 right-0 h-px bg-purple-400"
className="absolute -bottom-1 left-0 right-0 h-px bg-purple-400"
style={{ boxShadow: "0 0 8px #a78bfa" }}
/>
)}

View File

@ -1,50 +1,27 @@
import Link from "next/link";
import { auth } from "@/lib/auth";
import Logo from "./Logo";
import NavLinks from "./NavLinks";
import SearchTrigger from "./SearchTrigger";
import AuthMenu from "./auth/AuthMenu";
import RemainingVotesBadge from "./auth/RemainingVotesBadge";
export default async function Navigation() {
const session = await auth();
const user = session?.user;
const initial = user?.name?.charAt(0).toUpperCase() ?? "?";
export default function Navigation() {
return (
<header className="sticky top-0 z-50 backdrop-blur-xl bg-[rgba(13,10,36,0.85)] border-b border-white/[0.08]">
<nav className="max-w-7xl mx-auto h-16 px-6 sm:px-8 flex items-center gap-8">
<nav className="max-w-[1500px] mx-auto h-20 px-4 sm:px-6 lg:px-8 flex items-center gap-8">
<Logo size="md" />
{/* 中部:首页 / 排行榜 / 我的 */}
<NavLinks className="hidden md:flex" />
{/* 右侧 */}
{/* 右侧:搜索 + 今日余票 + 登录/注册 (或 头像+下拉) */}
<div className="ml-auto flex items-center gap-3">
<SearchTrigger />
{user ? (
<Link
href="/me"
className="flex items-center gap-2 px-2.5 h-9 rounded-full bg-white/[0.04] border border-white/10 hover:bg-white/[0.08] transition-colors"
aria-label="个人中心"
>
<span className="w-7 h-7 rounded-full flex items-center justify-center text-white font-display text-xs bg-grad-purple shadow-[0_0_10px_rgba(139,92,246,0.4)]">
{initial}
</span>
<span className="text-xs text-white/85 hidden sm:inline max-w-[80px] truncate">
{user.name}
</span>
</Link>
) : (
<Link
href="/login"
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 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"
>
Login / Sign Up
</Link>
)}
<RemainingVotesBadge />
<AuthMenu />
</div>
</nav>
{/* 移动端 nav links */}
{/* 移动端:单独一行 nav links */}
<NavLinks
className="md:hidden border-t border-white/[0.05] overflow-x-auto"
mobile

View File

@ -2,6 +2,7 @@
import { SessionProvider } from "next-auth/react";
import { Toaster } from "react-hot-toast";
import GlobalLoginModal from "@/components/auth/GlobalLoginModal";
/**
* Provider
@ -12,6 +13,7 @@ export default function Providers({ children }: { children: React.ReactNode }) {
return (
<SessionProvider>
{children}
<GlobalLoginModal />
<Toaster
position="top-center"
toastOptions={{

View File

@ -1,135 +1,108 @@
"use client";
import Link from "next/link";
import { ArrowRight } from "lucide-react";
import { ChevronRight, Trophy } from "lucide-react";
import type { Artist } from "@/types/artist";
import { getRankCategory } from "@/types/artist";
import { cn } from "@/lib/cn";
import ArtistPortrait from "./cards/ArtistPortrait";
interface Top12BarProps {
artists: Artist[];
/** 点击 VOTE NOW 触发的回调(不传则跳转 /vote */
onVoteNow?: () => void;
/** 是否显示头部标题 */
showHeader?: boolean;
}
const RANK_BORDER = {
gold: "border-[#fcd34d] shadow-[0_0_16px_rgba(252,211,77,0.5)]",
silver: "border-[#c4ccd8] shadow-[0_0_12px_rgba(196,204,216,0.4)]",
bronze: "border-[#cd7f32] shadow-[0_0_12px_rgba(205,127,50,0.4)]",
top12: "border-purple-500 shadow-[0_0_10px_rgba(139,92,246,0.3)]",
candidate: "border-white/15",
} as const;
function formatVotes(v: number): string {
if (v >= 10_000) return `${(v / 10_000).toFixed(1)}W 票`;
return `${v.toLocaleString()}`;
}
const BADGE_BG = {
gold: "bg-[#fcd34d] text-black",
silver: "bg-[#c4ccd8] text-black",
bronze: "bg-[#cd7f32] text-white",
top12: "bg-purple-600 text-white",
candidate: "bg-white/10 text-white/70",
} as const;
export default function Top12Bar({ artists, onVoteNow }: Top12BarProps) {
export default function Top12Bar({ artists, showHeader = true }: Top12BarProps) {
const top12 = artists.slice(0, 12);
return (
<div className="bg-[rgba(13,10,36,0.95)] border border-white/[0.06] rounded-xl p-4 grid gap-3 grid-cols-1 lg:grid-cols-[1fr_180px]">
{/* 头像横滚 */}
<div className="flex gap-2.5 sm:gap-3 overflow-x-auto pb-1 -mx-1 px-1 scroll-px-1 snap-x snap-mandatory">
{artists.slice(0, 12).map((artist) => {
const cat = getRankCategory(artist.rank);
return (
<Link
key={artist.id}
href={`/artist/${artist.id}`}
className="flex-shrink-0 w-[68px] sm:w-[76px] snap-start group"
>
<div
className={cn(
"relative w-full aspect-[4/5] rounded-lg overflow-hidden border-[1.5px] transition-transform group-hover:-translate-y-0.5",
RANK_BORDER[cat],
)}
>
<ArtistPortrait
artist={artist}
rounded="rounded-none"
className="absolute inset-0"
/>
{/* 编号 */}
<div className="absolute bottom-1 left-1 font-display text-[10px] text-white bg-black/65 px-1.5 py-px rounded tracking-wider tabular-nums">
{artist.no.slice(-2)}
</div>
{/* 排名小角标 */}
<div
className={cn(
"absolute top-1 right-1 w-4 h-4 rounded-full font-display text-[9px] flex items-center justify-center",
BADGE_BG[cat],
)}
>
{artist.rank}
</div>
</div>
<p className="mt-1.5 font-label text-[10px] tracking-widest text-white/65 truncate uppercase text-center">
{artist.enName}
</p>
</Link>
);
})}
</div>
<div className="w-full">
{showHeader && (
<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">
<Trophy size={16} className="text-purple-300" />
Top12
</h2>
<Link
href="/ranking"
className="font-label text-[11px] tracking-widest text-purple-300 hover:text-purple-200 uppercase inline-flex items-center gap-0.5"
>
<ChevronRight size={12} />
</Link>
</div>
)}
{/* VOTE NOW 侧栏面板 */}
<VotePanel onClick={onVoteNow} />
{/* 12 张胶囊卡片 · grid 等分铺满,无滚动 · 无外边框无背景 */}
<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} />
))}
</div>
</div>
);
}
function VotePanel({ onClick }: { onClick?: () => void }) {
const content = (
<div className="relative h-full bg-grad-purple rounded-xl p-4 sm:p-5 flex flex-col justify-between shadow-purple-glow overflow-hidden cursor-pointer transition-all hover:brightness-110 hover:shadow-[0_0_36px_rgba(139,92,246,0.7)] animate-pulse-glow min-h-[140px]">
{/* 高光装饰 */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none"
style={{
background:
"radial-gradient(circle at 80% 20%, rgba(255,255,255,0.2) 0%, transparent 50%)",
}}
/>
{/* 装饰星点 */}
<span className="absolute top-3 left-3 text-white/30 text-xs"></span>
function Top12Card({ artist }: { artist: Artist }) {
const inTop3 = artist.rank <= 3;
return (
<Link
href={`/artist/${artist.id}`}
className="group block text-center"
aria-label={`${artist.name} · 当前第 ${artist.rank}`}
>
{/* 外层 relative徽章可以完整显示在外面不会被卡片圆角裁切 */}
<div className="relative">
<div className="font-display text-2xl tracking-[0.2em] text-white leading-none">
VOTE
<br />
NOW
{/* 卡片本体2:3 矩形,上下两端 = 半圆rounded-full 在 2:3 矩形上 = 胶囊形) */}
<div
className={cn(
"relative w-full aspect-[2/3] rounded-full overflow-hidden border",
"border-white/15",
"transition-transform group-hover:-translate-y-1",
// 仅前 3 名保留紫色辉光
inTop3 && "border-purple-500 shadow-[0_0_16px_rgba(139,92,246,0.55)]",
)}
>
<ArtistPortrait
artist={artist}
rounded="rounded-none"
className="absolute inset-0 w-full h-full"
/>
</div>
<div className="mt-2 text-[11px] text-white/85 tracking-wider">
</div>
</div>
<div className="relative flex justify-end">
<span className="w-9 h-9 rounded-full bg-white/20 border border-white/35 backdrop-blur-md flex items-center justify-center text-white">
<ArrowRight size={16} />
{/* 排名徽章 · 完整悬浮在卡片左上角(外层定位,不被 overflow 裁切) */}
<span
className={cn(
"absolute -top-1 -left-1 z-10",
"w-6 h-6 sm:w-7 sm:h-7 rounded-full",
"bg-purple-500 text-white font-display text-[11px] sm:text-xs",
"flex items-center justify-center tabular-nums",
"border-2 border-deepest",
inTop3 && "shadow-[0_0_10px_rgba(139,92,246,0.8)]",
)}
>
{artist.rank}
</span>
</div>
</div>
);
if (onClick) {
return (
<button
type="button"
onClick={onClick}
aria-label="Vote now"
className="text-left p-0 border-0 bg-transparent"
>
{content}
</button>
);
}
return (
<Link href="/vote" aria-label="Vote now">
{content}
{/* 卡片下方 · 名字 + 票数 */}
<div className="mt-2 px-0.5">
<div className="text-[11px] sm:text-xs text-white truncate font-medium">
{artist.name}
</div>
<div className="text-[10px] sm:text-[11px] text-purple-300 tabular-nums mt-0.5">
{formatVotes(artist.votes)}
</div>
</div>
</Link>
);
}

View File

@ -9,25 +9,42 @@ import { cn } from "@/lib/cn";
import Button from "./ui/Button";
import ArtistPortrait from "./cards/ArtistPortrait";
type VoteOption = number | "ALL";
interface VoteModalProps {
/** 当前要投票的艺人,传 null 关闭弹窗 */
artist: Artist | null;
/** 今日剩余票数ALL 即投出该数值) */
remaining: number;
/** 每日总额度(用于副文案展示) */
dailyQuota: number;
/** 关闭弹窗 */
onClose: () => void;
/** 确认投票 */
/** 确认投票count 为最终实际投票数ALL 会被解析为 remaining */
onConfirm: (artist: Artist, count: number) => void | Promise<void>;
}
const VOTE_OPTIONS: number[] = [1, 5, 10, 50];
const DEFAULT_COUNT = 5;
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({
artist,
remaining,
dailyQuota,
onClose,
onConfirm,
}: VoteModalProps) {
const open = artist != null;
const [selected, setSelected] = useState<number>(DEFAULT_COUNT);
const [selected, setSelected] = useState<VoteOption>(defaultOption(remaining));
const [loading, setLoading] = useState(false);
const [mounted, setMounted] = useState(false);
@ -36,10 +53,10 @@ export default function VoteModal({
// 打开时重置默认选择
useEffect(() => {
if (open) {
setSelected(DEFAULT_COUNT);
setSelected(defaultOption(remaining));
setLoading(false);
}
}, [open]);
}, [open, remaining]);
// ESC 关闭 + body 锁滚
useEffect(() => {
@ -56,15 +73,18 @@ export default function VoteModal({
};
}, [open, onClose]);
const actualCount = resolveCount(selected, remaining);
const canSubmit = remaining > 0 && actualCount > 0 && actualCount <= remaining;
const handleConfirm = useCallback(async () => {
if (!artist || loading) return;
if (!artist || loading || !canSubmit) return;
setLoading(true);
try {
await onConfirm(artist, selected);
await onConfirm(artist, actualCount);
} finally {
setLoading(false);
}
}, [artist, selected, loading, onConfirm]);
}, [artist, actualCount, canSubmit, loading, onConfirm]);
if (!mounted) return null;
@ -87,7 +107,7 @@ export default function VoteModal({
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-default"
/>
{/* 弹窗主体 */}
{/* 弹窗主体(已去除顶部紫色横条) */}
<motion.div
role="dialog"
aria-modal="true"
@ -98,9 +118,6 @@ export default function VoteModal({
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
{/* 顶部紫色光条 */}
<div className="absolute top-0 left-0 right-0 h-0.5 bg-grad-purple rounded-t-2xl" />
{/* 关闭按钮 */}
<button
type="button"
@ -121,7 +138,7 @@ export default function VoteModal({
</div>
{/* 标题 */}
<div className="text-center mb-5">
<div className="text-center mb-4">
<div
id="vote-modal-title"
className="text-lg font-bold text-white mb-1"
@ -133,21 +150,39 @@ export default function VoteModal({
</div>
</div>
{/* 剩余票数提示 */}
<div className="flex items-center justify-between text-xs mb-2.5">
<span className="text-white/55"></span>
<span className="text-purple-300 tabular-nums">
{remaining} / {dailyQuota}
</span>
</div>
{/* 票数选择 */}
<div className="text-xs text-white/55 mb-2.5"></div>
<div className="flex gap-2.5 justify-center mb-5">
{VOTE_OPTIONS.map((opt) => {
const active = selected === opt;
const optValue = resolveCount(opt, remaining);
const disabled =
remaining === 0 ||
optValue === 0 ||
optValue > remaining ||
(opt === "ALL" && remaining === 0);
return (
<button
type="button"
key={opt}
onClick={() => setSelected(opt)}
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",
!active &&
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",
active &&
!disabled &&
active &&
"bg-purple-500/12 border border-purple-500 text-purple-300 shadow-[0_0_16px_rgba(139,92,246,0.35)]",
)}
>
@ -163,9 +198,12 @@ export default function VoteModal({
className="w-full h-12 text-sm"
onClick={handleConfirm}
loading={loading}
disabled={!canSubmit}
leftIcon={<Heart size={14} />}
>
{`确认投出 ${selected}`}
{remaining === 0
? "今日票数已用完"
: `确认投出 ${actualCount}`}
</Button>
</motion.div>
</motion.div>

View File

@ -31,7 +31,8 @@ export default function ArtistDetailContent({
const artist = storeArtist ?? initialArtist;
const allArtists = storeAll.length ? storeAll : initialAll;
const { target, openVote, closeVote, confirmVote } = useVoteAction();
const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } =
useVoteAction();
const handleShare = async () => {
const url =
@ -66,21 +67,17 @@ export default function ArtistDetailContent({
return (
<>
<div className="max-w-7xl 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">
<Link
href="/"
className="inline-flex items-center gap-1 text-white/65 hover:text-purple-300 transition-colors"
>
<ChevronLeft size={14} />
</Link>
<span className="text-white/30">/</span>
<span className="text-white/45"></span>
<span className="text-white/30">/</span>
<span className="text-white/45"></span>
<span className="text-white/30">/</span>
<span className="text-white/85">{artist.name}</span>
<span className="text-white/85"></span>
<button
type="button"
onClick={handleShare}
@ -92,7 +89,7 @@ export default function ArtistDetailContent({
</div>
</div>
<section className="max-w-7xl 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-12">
<div
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"
style={{
@ -161,7 +158,7 @@ export default function ArtistDetailContent({
leftIcon={<Heart size={16} fill="currentColor" />}
onClick={() => openVote(artist)}
>
TA
</Button>
<Button
variant="outline"
@ -170,22 +167,22 @@ export default function ArtistDetailContent({
aria-label="分享"
onClick={handleShare}
>
<span className="hidden sm:inline"></span>
<span className="hidden sm:inline"></span>
</Button>
</div>
</div>
</div>
</section>
<section className="max-w-5xl 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-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-5xl 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-12">
<SectionHeading title="表演图片" subtitle="Performance Gallery" />
<PerformanceGallery
images={artist.gallery}
@ -193,7 +190,7 @@ export default function ArtistDetailContent({
/>
</section>
<section className="max-w-3xl mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<SectionHeading title="艺人简介" subtitle="Biography" />
<div className="bg-surface/50 backdrop-blur-md border border-white/[0.08] rounded-xl p-5 sm:p-7">
<p className="text-sm sm:text-base text-white/75 leading-[1.85]">
@ -212,7 +209,13 @@ export default function ArtistDetailContent({
<FloatingVoteButton onClick={() => openVote(artist)} />
<VoteModal artist={target} onClose={closeVote} onConfirm={confirmVote} />
<VoteModal
artist={target}
remaining={remaining}
dailyQuota={dailyQuota}
onClose={closeVote}
onConfirm={confirmVote}
/>
</>
);
}
@ -247,13 +250,14 @@ function SectionHeading({
subtitle: string;
}) {
return (
<div className="mb-4">
<p className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/80 mb-1">
{subtitle}
</p>
<h2 className="font-display text-base sm:text-lg text-white tracking-[0.2em] uppercase">
{title}
<div className="mb-4 flex items-baseline gap-3">
<span 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]">
{title}
</h2>
<span className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/70">
{subtitle}
</span>
</div>
);
}

View File

@ -0,0 +1,101 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useSession, signOut } from "next-auth/react";
import { LogOut } from "lucide-react";
import { useLoginModalStore } from "@/lib/login-modal-store";
/**
* ****
* - / LoginModal
* - + 退
*
* NavLinks
*/
export default function AuthMenu() {
const { data: session, status } = useSession();
const openLogin = useLoginModalStore((s) => s.show);
const [menuOpen, setMenuOpen] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!menuOpen) return;
const handler = (e: MouseEvent) => {
if (!rootRef.current?.contains(e.target as Node)) {
setMenuOpen(false);
}
};
const esc = (e: KeyboardEvent) => {
if (e.key === "Escape") setMenuOpen(false);
};
document.addEventListener("mousedown", handler);
document.addEventListener("keydown", esc);
return () => {
document.removeEventListener("mousedown", handler);
document.removeEventListener("keydown", esc);
};
}, [menuOpen]);
const user = session?.user;
const initial = user?.name?.charAt(0).toUpperCase() ?? "?";
// 未登录态:紫色描边胶囊按钮
if (status !== "authenticated") {
return (
<button
type="button"
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 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>
);
}
// 已登录态:头像 + 昵称 + 下拉(紫色渐变实心胶囊,高亮用户身份)
return (
<div ref={rootRef} className="relative">
<button
type="button"
onClick={() => setMenuOpen((v) => !v)}
className="flex items-center gap-2 h-9 px-2.5 rounded-full bg-grad-purple 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"
aria-haspopup="menu"
aria-expanded={menuOpen}
aria-label={`${user?.name ?? "用户"} · 账号菜单`}
>
<span className="w-7 h-7 rounded-full flex items-center justify-center text-white font-display text-xs bg-white/15 border border-white/25">
{initial}
</span>
<span className="text-xs text-white hidden sm:inline max-w-[80px] truncate">
{user?.name}
</span>
</button>
{menuOpen && (
<div
role="menu"
className="absolute right-0 top-full mt-2 w-44 rounded-xl bg-elevated/95 backdrop-blur-xl border border-white/10 shadow-[0_12px_36px_rgba(0,0,0,0.55),0_0_24px_rgba(139,92,246,0.12)] overflow-hidden z-50"
>
<div className="px-4 py-3 border-b border-white/[0.06]">
<div className="text-xs text-white/55"></div>
<div className="text-sm text-white truncate mt-0.5">
{user?.name}
</div>
</div>
<button
type="button"
role="menuitem"
onClick={() => {
setMenuOpen(false);
signOut({ callbackUrl: "/" });
}}
className="w-full px-4 py-3 flex items-center gap-2 text-sm text-white/80 hover:bg-pink-500/[0.08] hover:text-pink-400 transition-colors text-left"
>
<LogOut size={14} />
退
</button>
</div>
)}
</div>
);
}

View File

@ -0,0 +1,35 @@
"use client";
import { useCallback } from "react";
import { useRouter } from "next/navigation";
import { useLoginModalStore } from "@/lib/login-modal-store";
import LoginModal from "./LoginModal";
/**
* · Providers
* `useLoginModalStore.show(redirectTo?)`
* redirectTo push
*/
export default function GlobalLoginModal() {
const router = useRouter();
const open = useLoginModalStore((s) => s.open);
const redirectTo = useLoginModalStore((s) => s.redirectTo);
const setOpen = useLoginModalStore((s) => s.setOpen);
const handleSuccess = useCallback(() => {
if (redirectTo) {
router.push(redirectTo);
router.refresh();
} else if (typeof window !== "undefined") {
window.location.reload();
}
}, [redirectTo, router]);
return (
<LoginModal
open={open}
onClose={() => setOpen(false)}
onSuccess={handleSuccess}
/>
);
}

View File

@ -0,0 +1,287 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion";
import { signIn } from "next-auth/react";
import { X, Phone, KeyRound, Loader2 } from "lucide-react";
import Button from "@/components/ui/Button";
import Logo from "@/components/Logo";
import { cn } from "@/lib/cn";
interface LoginModalProps {
open: boolean;
onClose: () => void;
/** 登录成功回调(默认刷新页面) */
onSuccess?: () => void;
}
/**
* /
* /login "需要登录"
*/
export default function LoginModal({ open, onClose, onSuccess }: LoginModalProps) {
const [phone, setPhone] = useState("");
const [code, setCode] = useState("");
const [countdown, setCountdown] = useState(0);
const [error, setError] = useState<string | null>(null);
const [sending, setSending] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [mounted, setMounted] = useState(false);
useEffect(() => setMounted(true), []);
const phoneValid = /^1[3-9]\d{9}$/.test(phone);
const codeValid = /^\d{6}$/.test(code);
// 打开时重置
useEffect(() => {
if (open) {
setPhone("");
setCode("");
setError(null);
setCountdown(0);
setSubmitting(false);
setSending(false);
}
}, [open]);
// ESC + body scroll lock
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
window.addEventListener("keydown", handler);
const prev = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
window.removeEventListener("keydown", handler);
document.body.style.overflow = prev;
};
}, [open, onClose]);
const sendOtp = useCallback(async () => {
if (!phoneValid) {
setError("请输入有效的中国大陆手机号");
return;
}
setError(null);
setSending(true);
try {
const res = await fetch("/api/auth/send-otp", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ phone }),
});
const data = await res.json();
if (!data.ok) throw new Error(data.error?.message || "发送失败");
setCountdown(60);
const timer = setInterval(() => {
setCountdown((c) => {
if (c <= 1) {
clearInterval(timer);
return 0;
}
return c - 1;
});
}, 1000);
} catch (e) {
setError(e instanceof Error ? e.message : "发送失败");
} finally {
setSending(false);
}
}, [phone, phoneValid]);
const handleLogin = useCallback(
async (e: React.FormEvent) => {
e.preventDefault();
if (!phoneValid || !codeValid) {
setError("请检查手机号和验证码");
return;
}
setError(null);
setSubmitting(true);
try {
const result = await signIn("phone-otp", {
phone,
code,
redirect: false,
});
if (result?.error) {
setError("验证码错误或已失效");
} else {
onClose();
if (onSuccess) onSuccess();
else if (typeof window !== "undefined") window.location.reload();
}
} catch {
setError("登录失败,请重试");
} finally {
setSubmitting(false);
}
},
[phone, code, phoneValid, codeValid, onClose, onSuccess],
);
if (!mounted) return null;
return createPortal(
<AnimatePresence>
{open && (
<motion.div
key="login-modal-root"
className="fixed inset-0 z-[100] flex items-center justify-center px-4"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.2 }}
>
{/* 遮罩 */}
<button
type="button"
aria-label="关闭弹窗"
onClick={onClose}
className="absolute inset-0 bg-black/75 backdrop-blur-md cursor-default"
/>
{/* 弹窗主体 */}
<motion.div
role="dialog"
aria-modal="true"
aria-labelledby="login-modal-title"
className="relative w-full max-w-md bg-elevated border border-white/14 rounded-2xl p-8 shadow-[0_24px_80px_rgba(0,0,0,0.7),0_0_40px_rgba(139,92,246,0.12)]"
initial={{ opacity: 0, scale: 0.94, y: 16 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.96, y: 8 }}
transition={{ duration: 0.28, ease: [0.22, 1, 0.36, 1] }}
>
<button
type="button"
onClick={onClose}
className="absolute top-3.5 right-4 w-7 h-7 flex items-center justify-center text-white/55 hover:text-white transition-colors"
aria-label="关闭"
>
<X size={18} />
</button>
<div className="text-center mb-6">
<div className="inline-block">
<Logo size="md" href={null} />
</div>
<p
id="login-modal-title"
className="font-label text-[10px] tracking-[0.4em] uppercase text-purple-300/80 mt-3"
>
Sign in to Vote
</p>
</div>
<form onSubmit={handleLogin} className="space-y-4">
<div>
<label className="block font-label text-[10px] tracking-widest uppercase text-white/55 mb-2">
</label>
<div className="relative">
<Phone
size={14}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-white/35"
/>
<input
type="tel"
inputMode="numeric"
autoComplete="tel"
placeholder="138 0000 0000"
value={phone}
maxLength={11}
onChange={(e) =>
setPhone(
e.target.value.replace(/\D/g, "").slice(0, 11),
)
}
className="w-full h-11 pl-10 pr-3 rounded-lg bg-surface border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-purple-500 focus:shadow-[0_0_16px_rgba(139,92,246,0.25)] transition-all"
/>
</div>
</div>
<div>
<label className="block font-label text-[10px] tracking-widest uppercase text-white/55 mb-2">
</label>
<div className="flex gap-2">
<div className="relative flex-1">
<KeyRound
size={14}
className="absolute left-3.5 top-1/2 -translate-y-1/2 text-white/35"
/>
<input
type="text"
inputMode="numeric"
autoComplete="one-time-code"
placeholder="6 位验证码"
value={code}
maxLength={6}
onChange={(e) =>
setCode(
e.target.value.replace(/\D/g, "").slice(0, 6),
)
}
className="w-full h-11 pl-10 pr-3 rounded-lg bg-surface border border-white/10 text-white placeholder-white/30 focus:outline-none focus:border-purple-500 focus:shadow-[0_0_16px_rgba(139,92,246,0.25)] transition-all tracking-widest"
/>
</div>
<button
type="button"
disabled={!phoneValid || countdown > 0 || sending}
onClick={sendOtp}
className={cn(
"h-11 px-4 rounded-lg font-display text-xs tracking-widest uppercase border transition-all whitespace-nowrap",
countdown > 0
? "bg-white/5 border-white/10 text-white/30"
: "bg-purple-500/10 border-purple-500/40 text-purple-300 hover:bg-purple-500/15 disabled:opacity-50",
)}
>
{sending ? (
<Loader2 size={14} className="animate-spin" />
) : countdown > 0 ? (
`${countdown}s`
) : (
"发送"
)}
</button>
</div>
</div>
{error && (
<div className="px-3 py-2 rounded-lg bg-pink-500/10 border border-pink-500/30 text-pink-300 text-xs">
{error}
</div>
)}
{process.env.NODE_ENV !== "production" && (
<div className="px-3 py-2 rounded-lg bg-purple-500/[0.08] border border-purple-500/25 text-[11px] text-purple-300/80">
<b className="text-white">123456</b>
</div>
)}
<Button
type="submit"
variant="primary"
size="lg"
className="w-full"
disabled={!phoneValid || !codeValid}
loading={submitting}
>
/
</Button>
<p className="text-[11px] text-white/40 text-center leading-relaxed">
</p>
</form>
</motion.div>
</motion.div>
)}
</AnimatePresence>,
document.body,
);
}

View File

@ -0,0 +1,34 @@
"use client";
import { useSession } from "next-auth/react";
import { useVoteStore, selectRemaining, DAILY_VOTE_QUOTA } from "@/lib/store";
/**
* "今日剩余票数"
* -
* - vote store
* - **** AuthMenu / vs
*/
export default function RemainingVotesBadge() {
const { status } = useSession();
const remaining = useVoteStore(selectRemaining);
if (status !== "authenticated") return null;
return (
<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"
aria-label={`今日剩余 ${remaining}`}
>
<span className="text-[11px] text-white/75 leading-none tracking-wide">
</span>
<span className="font-display text-sm text-purple-300 tabular-nums leading-none">
{remaining}
</span>
<span className="text-[11px] text-white/45 leading-none">
/ {DAILY_VOTE_QUOTA}
</span>
</div>
);
}

View File

@ -1,4 +1,5 @@
import Link from "next/link";
import { Heart } from "lucide-react";
import type { Artist } from "@/types/artist";
import { cn } from "@/lib/cn";
import ArtistPortrait from "./ArtistPortrait";
@ -25,10 +26,10 @@ export default function ArtistCard({
return (
<div
className={cn(
"group relative rounded-xl overflow-hidden bg-grad-card border transition-all shadow-card",
"group relative rounded-xl overflow-hidden bg-grad-card border transition-all",
inTop12
? "border-purple-500 shadow-[0_8px_32px_rgba(0,0,0,0.65),0_0_18px_rgba(139,92,246,0.35)]"
: "border-white/[0.06]",
? "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)]",
"hover:-translate-y-1 hover:shadow-[0_12px_36px_rgba(0,0,0,0.7),0_0_24px_rgba(139,92,246,0.25)]",
className,
)}
@ -38,53 +39,55 @@ export default function ArtistCard({
className="block"
aria-label={`查看 ${artist.name} 详情`}
>
{/* 立绘区 */}
<div className="relative aspect-[4/5]">
<ArtistPortrait artist={artist} rounded="rounded-none" className="absolute inset-0" />
{/* 立绘区13+ 卡片轻度暗化) */}
<div className={cn("relative aspect-[4/5]", !inTop12 && "opacity-[0.78]")}>
<ArtistPortrait
artist={artist}
rounded="rounded-none"
className="absolute inset-0"
/>
{/* 编号徽章(左上) */}
<div className="absolute top-2 left-2 font-display text-[10px] tracking-widest text-white bg-black/65 backdrop-blur-md px-2 py-0.5 rounded-full">
No.{artist.no}
</div>
{/* 排名徽章(右上) */}
{/* 排名徽章(左上独立紫色圆) */}
<div
className={cn(
"absolute top-2 right-2 w-7 h-7 rounded-full font-display text-xs flex items-center justify-center text-white",
"absolute top-2 left-2 w-7 h-7 rounded-full font-display text-xs flex items-center justify-center text-white tabular-nums",
inTop12
? "bg-purple-600 shadow-[0_0_12px_rgba(139,92,246,0.7)]"
? "bg-purple-500 shadow-[0_0_12px_rgba(139,92,246,0.7)]"
: "bg-elevated border border-white/15 text-white/55",
)}
>
{artist.rank}
</div>
{/* 顶部渐变蒙层(让编号更清晰) */}
<div className="absolute inset-x-0 top-0 h-12 bg-gradient-to-b from-black/45 to-transparent pointer-events-none" />
{/* 顶部轻微渐变蒙层 */}
<div className="absolute inset-x-0 top-0 h-12 bg-gradient-to-b from-black/40 to-transparent pointer-events-none" />
</div>
{/* 信息区 */}
<div className="p-3">
<div className="font-semibold text-sm text-white truncate">
{artist.name}{" "}
<span className="text-white/60 font-normal">· {artist.enName}</span>
{/* 信息区(黑色背景明显分隔) */}
<div className="p-3 bg-black/40 border-t border-white/[0.06]">
<div className="font-display text-[11px] text-white/55 tracking-wider">
No.{artist.no}
</div>
<div className="text-[10px] text-white/45 truncate mt-0.5">
<div className="mt-0.5 text-sm font-semibold text-white truncate">
{artist.name}
</div>
<div className="text-[11px] text-white/55 truncate mt-0.5">
{artist.slogan}
</div>
<div
className={cn(
"mt-2 font-display text-xs tracking-wider tabular-nums",
inTop12 ? "text-purple-300" : "text-white/45",
"mt-1.5 inline-flex items-center gap-1 font-display text-xs tabular-nums",
inTop12 ? "text-purple-300" : "text-white/55",
)}
>
{formatVotes(artist.votes)}
<Heart size={12} fill="currentColor" />
{formatVotes(artist.votes)}
</div>
</div>
</Link>
{/* 投票按钮 */}
<div className="px-3 pb-3">
{/* 投票按钮(所有排名统一样式 · 紫色实心) */}
<div className="px-3 pb-3 bg-black/40">
<button
type="button"
onClick={(e) => {
@ -92,13 +95,12 @@ export default function ArtistCard({
onVote?.(artist);
}}
className={cn(
"w-full h-8 rounded font-display text-[10px] tracking-[2px] uppercase transition-all",
inTop12
? "bg-grad-purple text-white hover:brightness-110 shadow-[0_0_12px_rgba(139,92,246,0.35)]"
: "bg-elevated border border-white/15 text-white/70 hover:bg-white/10 hover:text-white",
"w-full h-9 rounded-lg font-body font-semibold text-sm transition-all",
"bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)]",
"hover:brightness-110 active:brightness-95",
)}
>
Vote
</button>
</div>
</div>

View File

@ -1,5 +1,5 @@
import Link from "next/link";
import { AlertTriangle } from "lucide-react";
import { Heart, AlertTriangle } from "lucide-react";
import type { FanSupport } from "@/lib/mock-user";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { cn } from "@/lib/cn";
@ -10,7 +10,7 @@ export default function MyFanSupport({ supports }: { supports: FanSupport[] }) {
<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>
</div>
);
@ -26,12 +26,16 @@ export default function MyFanSupport({ supports }: { supports: FanSupport[] }) {
href={`/artist/${artist.id}`}
className={cn(
"flex items-center gap-3 p-3 rounded-xl border transition-all",
inTop12
? "bg-purple-500/[0.05] border-purple-500/30 hover:bg-purple-500/[0.08]"
: "bg-pink-500/[0.04] border-pink-500/25 hover:bg-pink-500/[0.06]",
"bg-surface/60 hover:bg-surface/80",
inTop12 ? "border-purple-500/35" : "border-white/[0.08]",
)}
>
<div className="w-12 h-12 rounded-full overflow-hidden border-2 border-white/15 flex-shrink-0">
<div
className={cn(
"w-12 h-12 rounded-full overflow-hidden border-2 flex-shrink-0",
inTop12 ? "border-purple-500/70" : "border-white/15",
)}
>
<ArtistPortrait
artist={artist}
rounded="rounded-full"
@ -42,13 +46,14 @@ export default function MyFanSupport({ supports }: { supports: FanSupport[] }) {
<div className="text-sm font-semibold text-white truncate">
{artist.name}
</div>
<div className="text-[11px] text-purple-300 mt-0.5 font-display tracking-wider tabular-nums">
<div className="inline-flex items-center gap-1 text-[11px] text-purple-300 mt-0.5 font-display tabular-nums">
<Heart size={10} fill="currentColor" />
{votedCount}
</div>
<div
className={cn(
"text-[11px] mt-0.5 inline-flex items-center gap-1",
inTop12 ? "text-white/50" : "text-pink-400",
inTop12 ? "text-white/55" : "text-pink-400",
)}
>
#{artist.rank}

View File

@ -1,61 +1,100 @@
"use client";
import { Heart, Users } from "lucide-react";
import { UserPlus } from "lucide-react";
interface QuotaCardProps {
/** 我累计投出的票数 */
total: number;
/** 今日剩余票数 */
remaining: number;
/** 每日总额度(用于「重置为 X 票」展示) */
dailyQuota: number;
/** 我累计投出的票数(保留 prop 以兼容现有调用方,组件内不直接展示) */
cumulative?: number;
onInvite?: () => void;
}
export default function QuotaCard({ total, onInvite }: QuotaCardProps) {
export default function QuotaCard({
remaining,
dailyQuota,
onInvite,
}: QuotaCardProps) {
return (
<div className="relative overflow-hidden rounded-2xl p-5 sm:p-6 bg-grad-purple shadow-purple-glow">
{/* 装饰星点 */}
<span className="absolute top-3 right-3 text-white/30 text-base"></span>
<span className="absolute bottom-4 right-12 text-white/15 text-xs">
</span>
{/* 高光 */}
<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
aria-hidden
className="absolute inset-0 pointer-events-none"
className="absolute right-0 top-0 bottom-0 w-1/2 pointer-events-none"
style={{
background:
"radial-gradient(circle at 80% 10%, rgba(255,255,255,0.2) 0%, transparent 60%)",
"radial-gradient(circle at 70% 50%, rgba(139,92,246,0.45) 0%, transparent 60%)",
}}
/>
{/* 装饰:右侧"水晶"占位(无素材时用 CSS 渲染的辉光六边形) */}
<CrystalDecoration />
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="relative p-6 sm:p-8 grid grid-cols-[1fr_auto] gap-4 items-center">
<div>
<div className="flex items-center gap-1.5 text-white/85 text-xs font-label tracking-widest uppercase">
<Heart size={12} fill="currentColor" />
</div>
<div className="font-display text-5xl sm:text-6xl text-white tabular-nums leading-none mt-2 tracking-wider">
{total.toLocaleString()}{" "}
<span className="text-2xl font-body opacity-85"></span>
</div>
<div className="text-[11px] text-white/75 mt-2 tracking-wide">
·
<p className="text-xs text-white/70 tracking-wider"></p>
<div className="mt-2 flex items-baseline gap-1">
<span className="font-display text-5xl sm:text-6xl text-white tabular-nums leading-none">
{remaining}
</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-start sm:items-end gap-2">
<span className="font-label text-[10px] tracking-widest uppercase text-white/80">
<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-white text-purple-700 font-display text-xs tracking-widest uppercase hover:bg-white/95 transition-colors"
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"
>
<Users size={14} />
<UserPlus size={14} />
</button>
</div>
</div>
</div>
);
}
/** 右侧装饰:用 CSS 渲染的简易"紫色水晶"效果,作为 3D 素材到位前的占位。 */
function CrystalDecoration() {
return (
<div
aria-hidden
className="absolute right-4 sm:right-8 top-1/2 -translate-y-1/2 w-32 h-32 sm:w-40 sm:h-40 pointer-events-none"
style={{ perspective: "600px" }}
>
<div
className="absolute inset-0 rounded-2xl border border-purple-300/40"
style={{
background:
"linear-gradient(135deg, rgba(196,181,253,0.4) 0%, rgba(139,92,246,0.25) 60%, rgba(91,33,182,0.4) 100%)",
transform: "rotate(45deg) skewY(8deg)",
boxShadow:
"0 0 24px rgba(196,181,253,0.5), inset 0 0 24px rgba(255,255,255,0.15)",
}}
/>
<div
className="absolute inset-3 rounded-xl"
style={{
background:
"linear-gradient(135deg, rgba(255,255,255,0.18) 0%, transparent 60%)",
transform: "rotate(45deg) skewY(8deg)",
}}
/>
<div className="absolute top-1/4 left-1/4 w-2 h-2 rounded-full bg-white/85 shadow-[0_0_8px_rgba(255,255,255,0.9)]" />
<div className="absolute bottom-1/4 right-1/4 w-1 h-1 rounded-full bg-purple-200 shadow-[0_0_6px_rgba(196,181,253,0.9)]" />
</div>
);
}

View File

@ -1,13 +1,17 @@
"use client";
import { Check, Gift } from "lucide-react";
import { Check } from "lucide-react";
import { cn } from "@/lib/cn";
interface SignInCalendarProps {
/** 本周 7 天签到状态(周一→周日) */
weekly: boolean[];
/** 今日是否已签到 */
todaySigned: boolean;
/** 今天是周几0 周日 ~ 6 周六)用 1~7 对应周一~周日 */
/** 今天是周几0~6对应周一~周日 */
todayIndex?: number;
/** 连续签到天数(用于今日格子显示「第 N 天」) */
streak?: number;
onSignIn?: () => void;
}
@ -17,58 +21,59 @@ export default function SignInCalendar({
weekly,
todaySigned,
todayIndex,
streak = 0,
onSignIn,
}: SignInCalendarProps) {
// 默认今天 = 数组中第一个未签到(或最后一个 true 之后)
const computedToday =
todayIndex ??
(weekly.indexOf(false) === -1 ? weekly.length - 1 : weekly.indexOf(false));
(weekly.indexOf(false) === -1
? weekly.length - 1
: weekly.indexOf(false));
return (
<div>
<div className="grid grid-cols-7 gap-2">
{WEEK_LABELS.map((label, i) => {
const signed = weekly[i];
const isToday = i === computedToday;
return (
<button
type="button"
key={label}
disabled={signed && !isToday}
onClick={isToday && !todaySigned ? onSignIn : undefined}
className={cn(
"rounded-lg p-2.5 text-center transition-all border",
signed && !isToday
? "bg-purple-500/8 border-purple-500/20 text-purple-300"
: isToday && !todaySigned
? "bg-grad-purple text-white border-transparent shadow-purple-glow animate-pulse-glow cursor-pointer hover:brightness-110"
: isToday && todaySigned
? "bg-purple-500/15 border-purple-500/50 text-purple-200"
: "bg-surface/40 border-white/10 text-white/35",
)}
>
<div className="font-label text-[10px] tracking-wider text-current opacity-80">
{label}
</div>
<div className="mt-1.5 flex items-center justify-center h-4">
{signed ? (
<Check size={14} strokeWidth={3} />
) : isToday ? (
<span className="inline-flex items-center gap-0.5 text-[10px] font-display">
<Gift size={11} />
</span>
) : (
<span className="w-1 h-1 rounded-full bg-current opacity-30" />
)}
</div>
</button>
);
})}
</div>
<p className="mt-3 text-[11px] text-white/40">
· ·
</p>
<div className="grid grid-cols-7 gap-2 sm:gap-3">
{WEEK_LABELS.map((label, i) => {
const signed = weekly[i];
const isToday = i === computedToday;
const clickable = isToday && !todaySigned;
return (
<button
type="button"
key={label}
disabled={!clickable}
onClick={clickable ? onSignIn : undefined}
className={cn(
"rounded-xl py-3 px-2 text-center transition-all border flex flex-col items-center justify-center gap-1.5 min-h-[72px]",
isToday
? clickable
? "bg-grad-purple text-white border-transparent shadow-[0_0_18px_rgba(139,92,246,0.55)] animate-pulse-glow cursor-pointer hover:brightness-110"
: "bg-purple-500/15 border-purple-400/45 text-purple-200"
: signed
? "bg-purple-500/[0.08] border-purple-500/20 text-purple-300"
: "bg-surface/40 border-white/10 text-white/35",
)}
>
<div className="font-label text-[10px] tracking-wider opacity-90">
{label}
</div>
{isToday ? (
<>
<div className="text-[10px] font-display font-semibold opacity-90 leading-tight">
</div>
<div className="text-[10px] text-current opacity-75 leading-tight">
{Math.max(streak, todaySigned ? streak : streak + 1)}
</div>
</>
) : signed ? (
<Check size={14} strokeWidth={3} />
) : (
<span className="w-1 h-1 rounded-full bg-current opacity-30" />
)}
</button>
);
})}
</div>
);
}

View File

@ -1,8 +1,8 @@
import { Heart, Star, Calendar, UserPlus } from "lucide-react";
import { Sparkles, Star, Calendar, UserPlus } from "lucide-react";
import type { MockUser } from "@/lib/mock-user";
const ICON_MAP = {
votes: <Heart size={14} />,
votes: <Sparkles size={14} />,
fan: <Star size={14} />,
signin: <Calendar size={14} />,
invite: <UserPlus size={14} />,
@ -36,14 +36,19 @@ export default function StatsGrid({ user }: { user: MockUser }) {
{stats.map((s) => (
<div
key={s.key}
className="bg-surface/60 border border-white/[0.08] rounded-xl px-3 py-3 sm:py-4 text-center"
className="relative overflow-hidden bg-surface/60 border border-white/[0.08] rounded-xl px-4 py-4 flex items-center gap-3 hover:border-purple-500/30 transition-colors"
>
<div className="font-display text-2xl sm:text-3xl text-purple-300 tabular-nums leading-none">
{s.value}
</div>
<div className="mt-1.5 text-[11px] text-white/55 inline-flex items-center gap-1">
<span className="text-purple-300/70">{s.icon}</span>
{s.label}
<span
aria-hidden
className="w-10 h-10 rounded-full bg-purple-500/15 border border-purple-500/30 flex items-center justify-center text-purple-300 flex-shrink-0"
>
{s.icon}
</span>
<div className="min-w-0">
<div className="font-display text-2xl text-white tabular-nums leading-none">
{s.value}
</div>
<div className="mt-1 text-[11px] text-white/55">{s.label}</div>
</div>
</div>
))}

View File

@ -1,45 +1,71 @@
import { Pencil } from "lucide-react";
import { Pencil, Star, LogOut } from "lucide-react";
import type { MockUser } from "@/lib/mock-user";
export default function UserHeader({ user }: { user: MockUser }) {
interface UserHeaderProps {
user: MockUser;
onLogout?: () => void;
}
export default function UserHeader({ user, onLogout }: UserHeaderProps) {
const initial = user.nickname.charAt(0).toUpperCase();
// 简单的等级算法:每 50 票升 1 级,从 1 起步
const level = Math.max(1, Math.floor(user.totalVotes / 50) + 1);
return (
<div className="flex items-center gap-4 pb-6 border-b border-white/[0.08]">
{/* 头像 */}
<div className="relative w-16 h-16 sm:w-20 sm:h-20 rounded-full overflow-hidden flex-shrink-0 border-2 border-purple-500/50 shadow-[0_0_16px_rgba(139,92,246,0.4)]">
<div
className="absolute inset-0 flex items-center justify-center font-logo text-2xl text-white"
style={{
background:
"linear-gradient(135deg, #6d28d9 0%, #8b5cf6 60%, #a78bfa 100%)",
}}
>
{initial}
<div className="flex items-center gap-4">
{/* 头像 + 等级角标 */}
<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-full h-full flex items-center justify-center font-logo text-2xl text-white"
style={{
background:
"linear-gradient(135deg, #6d28d9 0%, #8b5cf6 60%, #a78bfa 100%)",
}}
>
{initial}
</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 className="flex-1 min-w-0">
<div className="text-lg sm:text-xl font-bold text-white truncate">
@{user.nickname}
{user.nickname}
</div>
<div className="text-xs text-white/45 mt-1">
<div className="text-xs text-white/55 mt-1.5">
ID: {user.id}
<span className="mx-2 text-white/20">|</span>
<span className="mx-2 text-white/25">·</span>
{" "}
<span className="text-purple-300 font-display">
<span className="text-purple-300 font-display tabular-nums">
{user.signInStreak}
</span>{" "}
</div>
</div>
<button
type="button"
className="hidden sm:inline-flex items-center gap-1.5 px-3 h-9 rounded-full bg-white/[0.05] border border-white/15 text-white/70 hover:bg-white/10 text-xs"
>
<Pencil size={12} />
</button>
<div className="hidden sm:flex flex-col items-stretch gap-2">
<button
type="button"
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"
>
<Pencil size={12} />
</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>
);
}

View File

@ -1,7 +1,8 @@
import { AlertTriangle } from "lucide-react";
export default function DebutLineDivider() {
return (
<div className="my-6 relative flex items-center justify-center" role="separator">
{/* 横线 */}
<div className="my-4 relative flex items-center justify-center" role="separator">
<div
aria-hidden
className="absolute inset-x-0 h-px"
@ -12,13 +13,11 @@ export default function DebutLineDivider() {
}}
/>
{/* 中央徽章 */}
<div className="relative bg-[var(--color-deepest)] px-5 py-2 rounded-full border border-pink-500/45 inline-flex items-center gap-2 backdrop-blur-md">
<span className="text-pink-400 text-xs"></span>
<span className="font-label text-[10px] sm:text-[11px] tracking-[0.3em] uppercase text-pink-400">
Debut Line · 线
<div className="relative bg-deep px-4 py-1.5 rounded-full border border-pink-500/45 inline-flex items-center gap-2 backdrop-blur-md">
<AlertTriangle size={11} className="text-pink-400" />
<span className="font-label text-[10px] tracking-[0.3em] uppercase text-pink-400">
线 · Debut Line
</span>
<span className="text-pink-400 text-xs"></span>
</div>
</div>
);

View File

@ -3,7 +3,6 @@
import Link from "next/link";
import { TrendingUp, AlertTriangle } from "lucide-react";
import type { Artist } from "@/types/artist";
import { getRankCategory } from "@/types/artist";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { cn } from "@/lib/cn";
@ -13,11 +12,16 @@ interface RankingRowProps {
gapAbove?: number;
/** 与出道线差距(票数):候补区第一位用于"差 X 票进出道位" */
gapToDebut?: number;
/** 是否为出道线下第一位(用于"救援投票" */
/** 是否为出道线下第一位 */
isRescue?: boolean;
onVote: (a: Artist) => void;
}
function formatVotes(v: number): string {
if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`;
return v.toLocaleString();
}
export default function RankingRow({
artist,
gapAbove,
@ -25,25 +29,22 @@ export default function RankingRow({
isRescue = false,
onVote,
}: RankingRowProps) {
const cat = getRankCategory(artist.rank);
const inTop12 = artist.rank <= 12;
return (
<div
className={cn(
"grid grid-cols-[44px_56px_1fr_84px_120px_90px] sm:grid-cols-[64px_64px_1fr_100px_140px_110px] items-center gap-2 sm:gap-4 px-3 py-2.5 rounded-xl border 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
? "bg-purple-500/[0.05] border-purple-500/30 hover:bg-purple-500/[0.08]"
: isRescue
? "bg-pink-500/[0.04] border-pink-500/30 hover:bg-pink-500/[0.06]"
: "bg-surface/40 border-white/[0.06] hover:bg-surface/60",
? "bg-white/[0.02] hover:bg-purple-500/[0.06]"
: "opacity-[0.78] hover:opacity-100 hover:bg-white/[0.03]",
)}
>
{/* 排名 */}
<div
className={cn(
"font-display text-base sm:text-lg tabular-nums text-center",
inTop12 ? "text-purple-300 glow-text-purple" : "text-white/70",
inTop12 ? "text-purple-300" : "text-white/55",
)}
>
#{artist.rank}
@ -54,11 +55,7 @@ export default function RankingRow({
<div
className={cn(
"w-10 h-10 sm:w-12 sm:h-12 rounded-full overflow-hidden border-2",
cat === "gold" && "border-[#fcd34d] shadow-[0_0_12px_rgba(252,211,77,0.45)]",
cat === "silver" && "border-[#c4ccd8]",
cat === "bronze" && "border-[#cd7f32]",
cat === "top12" && "border-purple-500/70",
cat === "candidate" && "border-white/15",
inTop12 ? "border-purple-500/70" : "border-white/15",
)}
>
<ArtistPortrait
@ -75,55 +72,49 @@ export default function RankingRow({
className="min-w-0 hover:text-purple-300 transition-colors"
>
<div className="text-sm text-white font-semibold truncate">
{artist.name}{" "}
<span className="text-white/55 font-normal text-xs">
· {artist.enName}
{artist.name}
<span className="ml-2 text-white/45 font-normal text-[11px]">
· {artist.slogan}
</span>
</div>
<div className="text-[10px] sm:text-[11px] text-white/40 truncate">
{artist.slogan}
</div>
</Link>
{/* 票数 */}
<div className="text-right">
<div className="font-display text-sm text-purple-300 tabular-nums">
{(artist.votes / 10000).toFixed(1)}w
</div>
<div className="text-[10px] text-white/40"></div>
<span
className={cn(
"font-display text-sm tabular-nums",
inTop12 ? "text-purple-300" : "text-white/65",
)}
>
{formatVotes(artist.votes)} <span className="text-[10px] opacity-70"></span>
</span>
</div>
{/* 差距 */}
{/* 距上一名 / 差出道线 */}
<div className="text-right hidden sm:block">
{isRescue && gapToDebut != null ? (
<div className="inline-flex items-center gap-1 text-pink-400 text-xs font-display">
<div className="inline-flex items-center gap-1 text-pink-400 text-xs">
<AlertTriangle size={11} />
+{gapToDebut.toLocaleString()}
<span className="tabular-nums">+{gapToDebut.toLocaleString()}</span>
</div>
) : gapAbove != null && artist.rank > 1 ? (
<div className="inline-flex items-center gap-1 text-white/45 text-xs font-display tabular-nums">
<TrendingUp size={11} className="text-orange-400" />
{gapAbove.toLocaleString()}
<div className="inline-flex items-center gap-1 text-white/45 text-xs tabular-nums">
<TrendingUp size={11} className="text-orange-400" />
{gapAbove.toLocaleString()}
</div>
) : (
<span className="text-white/30 text-xs"></span>
)}
</div>
{/* 投票按钮 */}
{/* 投票按钮(统一紫色实心) */}
<button
type="button"
onClick={() => onVote(artist)}
className={cn(
"h-8 rounded-full font-display text-[10px] tracking-widest uppercase transition-all px-3",
isRescue
? "bg-pink-500 text-white shadow-[0_0_14px_rgba(236,72,153,0.45)] hover:brightness-110"
: inTop12
? "bg-grad-purple text-white shadow-[0_0_12px_rgba(139,92,246,0.35)] hover:brightness-110"
: "bg-elevated border border-white/15 text-white/70 hover:text-white",
)}
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"
>
{isRescue ? "救援投票" : "Vote"}
</button>
</div>
);

View File

@ -1,4 +1,5 @@
import Link from "next/link";
import { Crown } from "lucide-react";
import type { Artist } from "@/types/artist";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { cn } from "@/lib/cn";
@ -7,44 +8,16 @@ interface Top3PodiumProps {
top3: Artist[];
}
const STYLES = {
1: {
label: "🥇",
rank: "#1",
color: "text-[#fcd34d]",
border: "border-[#fcd34d]",
glow: "shadow-[0_0_28px_rgba(252,211,77,0.45)]",
bg: "bg-gradient-to-b from-[#fcd34d22] to-transparent",
scale: "lg:scale-110",
size: "w-28 h-28 sm:w-32 sm:h-32",
},
2: {
label: "🥈",
rank: "#2",
color: "text-[#c4ccd8]",
border: "border-[#c4ccd8]",
glow: "shadow-[0_0_20px_rgba(196,204,216,0.3)]",
bg: "bg-gradient-to-b from-[#c4ccd822] to-transparent",
scale: "",
size: "w-24 h-24 sm:w-28 sm:h-28",
},
3: {
label: "🥉",
rank: "#3",
color: "text-[#cd7f32]",
border: "border-[#cd7f32]",
glow: "shadow-[0_0_20px_rgba(205,127,50,0.3)]",
bg: "bg-gradient-to-b from-[#cd7f3222] to-transparent",
scale: "",
size: "w-24 h-24 sm:w-28 sm:h-28",
},
} as const;
function formatVotes(v: number): string {
if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`;
return v.toLocaleString();
}
export default function Top3Podium({ top3 }: Top3PodiumProps) {
const [first, second, third] = top3;
if (!first || !second || !third) return null;
// 视觉上:第二名 · 第一名 · 第三名 的顺序排列
// 视觉排序:第二 / 第一(中央) / 第三
const order: Array<{ artist: Artist; rank: 1 | 2 | 3 }> = [
{ artist: second, rank: 2 },
{ artist: first, rank: 1 },
@ -52,34 +25,35 @@ export default function Top3Podium({ top3 }: Top3PodiumProps) {
];
return (
<div className="grid grid-cols-3 gap-3 sm:gap-6 items-end py-6 sm:py-10 px-2 sm:px-4 rounded-2xl border border-white/[0.06] bg-gradient-to-b from-purple-500/[0.05] to-transparent">
<div className="grid grid-cols-3 gap-3 sm:gap-5 items-end">
{order.map(({ artist, rank }) => {
const style = STYLES[rank];
const isFirst = rank === 1;
return (
<Link
key={artist.id}
href={`/artist/${artist.id}`}
className={cn(
"group flex flex-col items-center text-center transition-transform",
style.scale,
"hover:-translate-y-1",
"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",
)}
>
{/* 顶部小皇冠(仅第一名) */}
{isFirst && (
<div className="absolute -top-2 left-1/2 -translate-x-1/2 text-purple-300">
<Crown size={20} fill="currentColor" />
</div>
)}
{/* 头像(圆形 + 紫色环) */}
<div
className={cn(
"font-display text-xl sm:text-2xl mb-2",
style.color,
)}
>
<span className="mr-1">{style.label}</span>
{style.rank}
</div>
<div
className={cn(
"rounded-full overflow-hidden border-[3px] mb-3",
style.size,
style.border,
style.glow,
"rounded-full overflow-hidden border-2 mb-3",
"border-purple-500 shadow-[0_0_18px_rgba(139,92,246,0.55)]",
isFirst ? "w-24 h-24 sm:w-28 sm:h-28" : "w-20 h-20 sm:w-24 sm:h-24",
)}
>
<ArtistPortrait
@ -88,21 +62,27 @@ export default function Top3Podium({ top3 }: Top3PodiumProps) {
className="w-full h-full"
/>
</div>
<div className="text-sm sm:text-base font-semibold text-white truncate max-w-full">
{/* 名字 */}
<div className={cn("font-semibold text-white truncate max-w-full", isFirst ? "text-base sm:text-lg" : "text-sm")}>
{artist.name}
</div>
<div className="font-display text-[10px] sm:text-xs tracking-widest text-purple-300/70 uppercase mt-0.5">
{artist.enName}
</div>
{/* 票数 */}
<div
className={cn(
"font-display text-sm sm:text-lg tabular-nums mt-2",
style.color,
"mt-1 font-display tabular-nums",
isFirst ? "text-lg sm:text-xl text-purple-200" : "text-base text-purple-300",
)}
>
{(artist.votes / 10000).toFixed(1)}w
{formatVotes(artist.votes)}{" "}
<span className="text-xs opacity-70"></span>
</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">
#{artist.rank}
</div>
<div className="text-[10px] text-white/40"></div>
</Link>
);
})}

View File

@ -83,15 +83,15 @@ export default function Countdown({
return (
<div
className={cn(
"inline-flex items-center gap-1.5 px-3 py-1 rounded-full bg-purple-500/15 border border-purple-500/30 backdrop-blur-md",
"inline-flex items-center gap-2 px-4 py-1.5 rounded-full",
"bg-[rgba(13,10,36,0.55)] backdrop-blur-md",
"border border-purple-300/40",
className,
)}
>
<span className="text-purple-200 text-[10px] font-label tracking-widest uppercase">
</span>
<span className="font-display text-xs text-purple-200 tracking-wider tabular-nums">
{time.days}d {String(time.hours).padStart(2, "0")}:
<span className="text-white/85 text-xs"></span>
<span className="font-display text-xs text-purple-300 tabular-nums tracking-wider">
{time.days} {String(time.hours).padStart(2, "0")}:
{String(time.minutes).padStart(2, "0")}:
{String(time.seconds).padStart(2, "0")}
</span>

View File

@ -1,16 +1,24 @@
"use client";
import { useState, useCallback } from "react";
import { useRouter, usePathname } from "next/navigation";
import { useSession } from "next-auth/react";
import toast from "react-hot-toast";
import { useVoteStore } from "@/lib/store";
import {
useVoteStore,
selectRemaining,
DAILY_VOTE_QUOTA,
} from "@/lib/store";
import { useLoginModalStore } from "@/lib/login-modal-store";
import type { Artist } from "@/types/artist";
interface UseVoteActionResult {
/** 当前投票目标艺人null 时弹窗关闭) */
target: Artist | null;
/** 触发投票(自动检查登录态) */
/** 今日剩余票数 */
remaining: number;
/** 每日总额度(常量,供 UI 文案展示) */
dailyQuota: number;
/** 触发投票(自动检查登录态 + 额度) */
openVote: (artist: Artist) => void;
/** 关闭投票弹窗 */
closeVote: () => void;
@ -21,39 +29,47 @@ interface UseVoteActionResult {
/**
*
*
* -
* - store + API
* - toast
*
* /
*
* - = 10
* - toast
* - toast
* - store + APIfire-and-forget
*/
export function useVoteAction(): UseVoteActionResult {
const router = useRouter();
const pathname = usePathname();
const { status } = useSession();
const recordVote = useVoteStore((s) => s.vote);
const remaining = useVoteStore(selectRemaining);
const openLogin = useLoginModalStore((s) => s.show);
const [target, setTarget] = useState<Artist | null>(null);
const openVote = useCallback(
(artist: Artist) => {
if (status === "loading") return; // 会话还在加载,等一下
if (status === "loading") return;
if (status === "unauthenticated") {
toast("请先登录后再为偶像投票", { icon: "🔐" });
const back = encodeURIComponent(pathname || "/");
setTimeout(() => router.push(`/login?callbackUrl=${back}`), 350);
toast("请先登录后再为偶像投票");
setTimeout(openLogin, 350);
return;
}
if (remaining <= 0) {
toast("今日票数已用完,明天再来吧");
return;
}
setTarget(artist);
},
[status, pathname, router],
[status, openLogin, remaining],
);
const closeVote = useCallback(() => setTarget(null), []);
const confirmVote = useCallback(
async (artist: Artist, count: number) => {
// 1. 立即更新本地 store + 反馈UI 0 延迟)
recordVote(artist.id, count);
// 1. 本地 store 立即扣减(包含额度校验)
const success = recordVote(artist.id, count);
if (!success) {
toast.error("今日票数不足");
setTarget(null);
return;
}
toast.success(`已为 ${artist.name} 投出 ${count}`);
setTarget(null);
@ -72,5 +88,12 @@ export function useVoteAction(): UseVoteActionResult {
[recordVote],
);
return { target, openVote, closeVote, confirmVote };
return {
target,
remaining,
dailyQuota: DAILY_VOTE_QUOTA,
openVote,
closeVote,
confirmVote,
};
}

View File

@ -30,6 +30,8 @@ export const ERR = {
VALIDATION: (msg: string) => err("VALIDATION", msg, 422),
INTERNAL: (msg = "服务器错误") => err("INTERNAL", msg, 500),
ACTIVITY_OFF: () => err("ACTIVITY_OFF", "投票活动暂未开放", 409),
QUOTA_EXHAUSTED: (msg = "今日票数已用完") =>
err("QUOTA_EXHAUSTED", msg, 409),
};
/**

View File

@ -0,0 +1,17 @@
import { create } from "zustand";
interface LoginModalStore {
open: boolean;
/** 登录成功后要跳转的路径。空值表示就地刷新当前页。 */
redirectTo?: string;
setOpen: (open: boolean) => void;
/** 唤起登录弹窗。可选传 redirectTo —— 登录成功后会 router.push 过去。 */
show: (redirectTo?: string) => void;
}
export const useLoginModalStore = create<LoginModalStore>((set) => ({
open: false,
redirectTo: undefined,
setOpen: (open) => set({ open }),
show: (redirectTo) => set({ open: true, redirectTo }),
}));

View File

@ -38,17 +38,18 @@ const STAGE_NAMES: Array<[string, string, string]> = [
["梓", "AZUR", "蓝调诗人"],
];
// 4 个新主标签均匀分布。每位艺人 1-2 个标签,便于筛选器命中。
const TAG_POOL: ArtistTag[][] = [
["vocal", "visual"],
["dance", "all-rounder"],
["rap", "leader"],
["all-rounder"],
["vocal", "leader"],
["dance", "visual"],
["vocal"],
["rap", "all-rounder"],
["dance"],
["visual", "all-rounder"],
["all-rounder"],
["rap"],
["vocal", "all-rounder"],
["dance", "all-rounder"],
["rap", "all-rounder"],
["vocal"],
["dance"],
["all-rounder"],
["rap"],
["vocal", "dance"],
];

View File

@ -11,7 +11,7 @@ export interface MockUser {
weeklySignIn: boolean[];
/** 今日是否已签到 */
todaySignedIn: boolean;
/** 累计投票数(无上限) */
/** 累计投票数 */
totalVotes: number;
/** 应援的艺人 ID 列表 */
supportingIds: string[];

View File

@ -2,17 +2,32 @@ import { create } from "zustand";
import { ARTISTS } from "./mock-data";
import type { Artist } from "@/types/artist";
/** 每日基础投票额度(与后端 ActivityConfig.dailyQuota 对齐) */
export const DAILY_VOTE_QUOTA = 10;
interface VoteStore {
/** 当前所有艺人(含动态票数 / 实时排名) */
artists: Artist[];
/** 用户累计投票数(无上限) */
/** 累计投票数 */
myTotalVotes: number;
/** 给艺人投票(本地模拟,会重新排名) */
vote: (artistId: string, count: number) => void;
/** 今日已用票数(跨日自动重置) */
usedToday: number;
/** 今日额度日期标记YYYY-M-D按本地时区 */
quotaDate: string;
/**
*
* false
*/
vote: (artistId: string, count: number) => boolean;
/** 重置(开发时用) */
reset: () => void;
}
function todayKey(): string {
const d = new Date();
return `${d.getFullYear()}-${d.getMonth() + 1}-${d.getDate()}`;
}
function rank(list: Artist[]): Artist[] {
return [...list]
.sort((a, b) => b.votes - a.votes)
@ -24,20 +39,36 @@ const INITIAL = rank(ARTISTS);
export const useVoteStore = create<VoteStore>((set) => ({
artists: INITIAL,
myTotalVotes: 0,
vote: (artistId, count) =>
usedToday: 0,
quotaDate: todayKey(),
vote: (artistId, count) => {
let success = false;
set((state) => {
const today = todayKey();
const baseUsed = state.quotaDate === today ? state.usedToday : 0;
const remaining = DAILY_VOTE_QUOTA - baseUsed;
if (count <= 0 || count > remaining) {
return state;
}
success = true;
const updated = state.artists.map((a) =>
a.id === artistId ? { ...a, votes: a.votes + count } : a,
);
return {
artists: rank(updated),
myTotalVotes: state.myTotalVotes + count,
usedToday: baseUsed + count,
quotaDate: today,
};
}),
});
return success;
},
reset: () =>
set({
artists: INITIAL,
myTotalVotes: 0,
usedToday: 0,
quotaDate: todayKey(),
}),
}));
@ -45,3 +76,8 @@ export const useVoteStore = create<VoteStore>((set) => ({
export function selectArtist(id: string) {
return (s: VoteStore) => s.artists.find((a) => a.id === id);
}
/** 选择器:当前剩余票数(基于今日已用) */
export function selectRemaining(s: VoteStore): number {
return Math.max(0, DAILY_VOTE_QUOTA - s.usedToday);
}

View File

@ -48,7 +48,7 @@ export interface Artist {
export const TAG_LABEL: Record<ArtistTag, string> = {
vocal: "声乐担当",
dance: "舞蹈担当",
rap: "Rap 担当",
rap: "rap担当",
"all-rounder": "全能型",
visual: "颜值担当",
leader: "队长担当",