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:
parent
bd5a361a18
commit
d5ed43acbd
1841
docs/design-spec.html
Normal file
1841
docs/design-spec.html
Normal file
File diff suppressed because it is too large
Load Diff
604
docs/design-spec.md
Normal file
604
docs/design-spec.md
Normal 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**
|
||||
- **简化 Logo(favicon / 极窄场景)**:紫色四角星 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 |
|
||||
| 标签 / Eyebrow(uppercase) | 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` SVG(lucide-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-72px,2px 紫色边框(`--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×24,lucide `X` SVG,55% 白色 |
|
||||
| 居中 | `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 dp(Material)
|
||||
- 相邻可点击元素之间至少留 **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.5s(Hero 视频懒加载、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 工具类名**,开发可零成本接入。
|
||||
@ -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
BIN
public/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 358 KiB |
@ -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,
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 };
|
||||
|
||||
256
src/app/page.tsx
256
src/app/page.tsx
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -40,7 +40,7 @@ export default function FloatingVoteButton({
|
||||
)}
|
||||
>
|
||||
<Heart size={16} fill="white" className="mb-0.5" />
|
||||
<span>VOTE</span>
|
||||
<span>投票</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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 变化时执行 · 不依赖 isMuted(mute 切换由按钮处理)
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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" }}
|
||||
/>
|
||||
)}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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={{
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
101
src/components/auth/AuthMenu.tsx
Normal file
101
src/components/auth/AuthMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
src/components/auth/GlobalLoginModal.tsx
Normal file
35
src/components/auth/GlobalLoginModal.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
287
src/components/auth/LoginModal.tsx
Normal file
287
src/components/auth/LoginModal.tsx
Normal 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,
|
||||
);
|
||||
}
|
||||
34
src/components/auth/RemainingVotesBadge.tsx
Normal file
34
src/components/auth/RemainingVotesBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
))}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
|
||||
@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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 立即扣减 + 调用后端 API(fire-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,
|
||||
};
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
17
src/lib/login-modal-store.ts
Normal file
17
src/lib/login-modal-store.ts
Normal 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 }),
|
||||
}));
|
||||
@ -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"],
|
||||
];
|
||||
|
||||
@ -11,7 +11,7 @@ export interface MockUser {
|
||||
weeklySignIn: boolean[];
|
||||
/** 今日是否已签到 */
|
||||
todaySignedIn: boolean;
|
||||
/** 累计投票数(无上限) */
|
||||
/** 累计投票数 */
|
||||
totalVotes: number;
|
||||
/** 应援的艺人 ID 列表 */
|
||||
supportingIds: string[];
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -48,7 +48,7 @@ export interface Artist {
|
||||
export const TAG_LABEL: Record<ArtistTag, string> = {
|
||||
vocal: "声乐担当",
|
||||
dance: "舞蹈担当",
|
||||
rap: "Rap 担当",
|
||||
rap: "rap担当",
|
||||
"all-rounder": "全能型",
|
||||
visual: "颜值担当",
|
||||
leader: "队长担当",
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user