diff --git a/docs/design-spec.html b/docs/design-spec.html new file mode 100644 index 0000000..3b90764 --- /dev/null +++ b/docs/design-spec.html @@ -0,0 +1,1841 @@ + + + + + +CYBER STAR · Design Spec v2.1 + + + + + + + + + + + + + + +
+ + + + + +
+ +
+
Top 12 · Virtual Idol Debut Project
+

+ CYBER STAR +

+

虚拟偶像出道企划官方网站视觉设计规范。本文档定义品牌识别、色彩、字体、组件、动效、可访问性等全部前端视觉资产,是设计与开发之间的单一来源(Single Source of Truth)。

+
+ Version 2.1 + 2026-05-12 + Zero Emoji + WCAG 2.1 AA +
+
+ +
+ v2.1 更新(本版): + ① 删除 Hero 内所有按钮(含 Play Debut PV、Debut PV tag); + ② 倒计时加浅紫色边框胶囊; + ③ 卡片投票按钮所有排名统一为紫色实心样式; + ④ 弹窗去除顶部 2px 紫线; + ⑤ 全规范文档与代码 禁用 emoji,所有图标统一 lucide SVG。 +
+ + +
+
01
+

Brand Identity · 品牌识别

+

CYBER STAR 的视觉语言聚焦于「赛博 / 暗黑 / 紫色霓虹 / 太空感」。所有设计决策都应强化这种神秘、未来、高级感的氛围。

+ +

Logo 规范

+ + + + + + + + +
规则
主 Logo金属铬质 CYBER STAR + 星形装饰(PNG 透明背景)
资源路径/public/logo.png
原始比例约 2.7 : 1
最小展示宽度120 px
顶栏使用高度32 px(对应宽度约 86 px)
简化版仅四角星 SVG + 紫色辉光(favicon、极窄场景)
+ +

禁忌项

+
    +
  • 不使用扁平色块大面积铺底
  • +
  • 不使用强烈的彩虹色 / 多色对比
  • +
  • 不使用阳光、暖色调
  • +
  • 不使用粗线条 / 卡通风格的图标
  • +
  • 不使用纯黑 #000 大面积背景
  • +
  • 不使用任何 emoji(包括心形、播放三角、奖杯等 Unicode 装饰字符)— 唯一例外:CYBER ✦ STAR 字标中的 ✦
  • +
+
+ + +
+
02
+

Color System · 色彩系统

+

背景层级越深越亮一点点以营造空间纵深;主调紫色覆盖 8 级亮度阶梯,3-5 档为最常用值。

+ +

背景层级(暗紫调)

+
+
#08051A
Deepest
--color-deepest
页面最底层
+
#0D0A24
Deep
--color-deep
导航 / Footer
+
#13102E
Base
--color-base
主要区块
+
#1A1638
Surface
--color-surface
卡片 / 输入框
+
#221D4A
Elevated
--color-elevated
浮层 / 弹窗
+
#1E1840
Card
--color-card
艺人卡片底
+
+ +

主调紫 · Royal Violet

+
+
#EDE9FE
Purple 100
--color-purple-100
极淡紫
+
#DDD6FE
Purple 200
--color-purple-200
高亮文字
+
#C4B5FD
Purple 300
--color-purple-300
文字高亮 ★
+
#A78BFA
Purple 400
--color-purple-400
边框 / 图标
+
#8B5CF6
Purple 500
--color-purple-500
主品牌色 ★
+
#7C3AED
Purple 600
--color-purple-600
按钮 Hover
+
#6D28D9
Purple 700
--color-purple-700
按钮 Active
+
#5B21B6
Purple 800
--color-purple-800
渐变深端
+
+ +

文字颜色(按透明度)

+
+
主标题 · Heading Sample Text
#FFFFFF · 100%
+
正文阅读 · Body Sample Text
rgba(255,255,255,0.85)
+
次要说明 · Caption Sample
rgba(255,255,255,0.65)
+
弱化元数据 · Meta Sample
rgba(255,255,255,0.45)
+
几近隐藏 · Disabled Sample
rgba(255,255,255,0.25)
+
紫色强调 · 12.6W 票
var(--color-purple-300)
+
错误提示 · Error Sample
var(--color-pink-500)
+
+ +

边框 · 渐变

+
+
Subtle--border-subtle
+
Default--border-default
+
Purple--border-purple
+
Purple Soft倒计时胶囊用
+
Hero--grad-hero
+
Purple--grad-purple
+
Card--grad-card
+
+
+ + +
+
03
+

Typography · 字体系统

+

4 套字体各司其职。中文严格使用 Inter Fallback 链中的中文字体,不尝试用 Display 字体显示中文。

+ +
+
Font Logo · 标题专用
+
CYBER STAR
+
ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789
+
Orbitron · Weight 500 / 700 / 900 · var(--font-logo)
+
+ +
+
Font Display · 按钮 / 副标题
+
VOTE NOW
+
ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789
+
Audiowide · Weight 400 · var(--font-display)
+
+ +
+
Font Label · 英文小写大写标签
+
CANDIDATES
+
ABCDEFGHIJKLMNOPQRSTUVWXYZ 0123456789
+
Cinzel · Weight 400 / 600 · var(--font-label)
+
+ +
+
Font Body · 中英文正文
+
虚拟偶像 Top12 出道企划 Aa 123
+
中文 + Inter 西文。所有中文一律用此族,不强制 uppercase。
+
Inter · Weight 400-700 · var(--font-body)
+
+ +

字号阶梯

+ + + + + + + + +
用途桌面移动Tracking
Hero 主标题 H1clamp(64, 7vw, 96)px48px0.35em
区块标题 H218px16px0.2em
卡片标题14px13pxnormal
正文14px13pxnormal
标签 / Eyebrow (uppercase)11px10px0.3-0.4em
票数 / 排名(数字)tabular-numsnormal
+
+ + +
+
04
+

Spacing & Layout · 间距与布局

+

基础栅格 4px,容器最大宽度 1280px。

+ +

间距阶梯

+
+
space-1
4px
+
space-2
8px
+
space-3
12px
+
space-4
16px
+
space-5
20px
+
space-6
24px
+
space-8
32px
+
space-10
40px
+
space-16
64px
+
+ +

艺人卡片网格

+ + + + + + +
断点列数Gap
< 640px212px
≥ 640px316px
≥ 768px416px
≥ 1024px516px
+
+ + +
+
05
+

Radius · Shadow · Glow

+ +

圆角

+
+
0直角,不用
+
8px输入框 / 按钮
+
12px卡片 / 容器
+
16px大卡片
+
20px弹窗 / Hero
+
9999胶囊 / 徽章
+
+ +

阴影 & 辉光

+
+
Cardshadow-card
+
Purple Glowshadow-purple
+
Soft Glowshadow-glow
+
+ +

文字辉光

+
+
CYBER STAR
+
.glow-text-purple
+
+
+ + +
+
06
+

Motion · 动效规范

+

动效短促克制,强调反馈即时性。Hover < 200ms,模态进入 ~280ms。

+ + + + + + + + + + +
名称时长Easing用法
Hover 微抬升200msease-out卡片 translateY(-2px)
按钮过渡180msease-out颜色 / 阴影变化
弹窗进入280mscubic-bezier(0.22, 1, 0.36, 1)scale 0.94→1 + fade
弹窗退出200msease-inscale 1→0.96 + fade
pulse-glow2.4s infiniteease-in-outVOTE 按钮 / 当前排名
float3s infiniteease-in-out滚动提示
spin-slow20s infinitelinearHero 装饰光环
+ +

动效演示

+
+
+
VOTE
+
pulse-glow
+
+
+
+
float
+
+
+
+
spin-slow
+
+
+
+ + + + + +
+
07.2
+

Hero Section · Hero 区

+

全屏视频背景(自动播放、循环、默认静音)。Hero 内不放置任何按钮,仅倒计时(右上)与声音切换(右下)。

+ +
+
+
TOP 12 · VIRTUAL IDOL DEBUT PROJECT
+
+ 距离投票结束 + 12天 03:24:16 +
+
+
+ CYBER STAR +
+
虚拟偶像出道企划
+
+
+ +
+
+
+ + + + + + + + + + + + + +
属性
高度70vh(min 480px / max 720px)
圆角20px
背景全屏视频 object-cover + 半透明黑色蒙层
视频行为autoPlay loop muted playsInline
禁止在 Hero 内放置按钮删除 Play Debut PV、删除 Debut PV 自动播放 tag
倒计时位置右上角,距边 24px
倒计时容器padding 8×16 + 1px solid rgba(196,181,253,0.4) 浅紫边框 + rounded-full + rgba(13,10,36,0.55) + backdrop-blur
倒计时文字12-13px 白字 + 紫色高亮数字 + tabular-nums
声音按钮右下角,36×36 圆形毛玻璃,lucide VolumeX/Volume2
视频缺失降级--grad-hero 渐变
+
+ + +
+
07.3
+

Top 12 出道位

+

圆形头像横排,独立紫色序号圆 + 中文名 + 票数,统一紫色样式,不分 1-3 / 4-12。

+ +
+
+
+ + 实时 Top12 出道位 +
+ + 查看完整榜单 + + +
+
+
+
A
1
艺人 A
12.6W 票
+
B
2
艺人 B
11.8W 票
+
C
3
艺人 C
10.5W 票
+
D
4
艺人 D
9.2W 票
+
E
5
艺人 E
8.7W 票
+
F
6
艺人 F
7.9W 票
+
+
+
+ +
禁止添加 VOTE NOW 侧栏面板。当前实现中的 VotePanel 须删除。
+
+ + +
+
07.4
+

Filter Bar · 筛选 + 搜索栏

+ +
+
+
+ + + + + +
+
+ + +
+
+
+
+
+
+
+ + + + + + + +
元素图标
排序触发lucide ArrowUpDown
搜索框前缀lucide Search
网格视图lucide LayoutGrid
列表视图lucide List
+
+ + +
+
07.5
+

Artist Card · 艺人卡片

+

所有 35 张卡片的投票按钮完全相同。Top12 与候选区只在「立绘明度 + 边框 + 排名徽章」3 处差异化。

+ +

Top 12 出道位卡片

+
+
+
+
1
+
Portrait Placeholder
+
+
+
No.001
+
艺人 A
+
樱花校园系
+
+ + 12.5W 票 +
+ +
+
+
+
+
2
+
Portrait Placeholder
+
+
+
No.002
+
艺人 B
+
古风国风系
+
+ + 11.8W 票 +
+ +
+
+
+ +

13+ 候选区卡片(仅立绘弱化 + 边框 + 徽章变化)

+
+
+
+
13
+
Portrait Placeholder
+
+
+
No.013
+
艺人 M
+
活力少女
+
+ + 4.8W 票 +
+ +
+
+
+
+
14
+
Portrait Placeholder
+
+
+
No.014
+
艺人 N
+
冷艳风
+
+ + 4.3W 票 +
+ +
+
+
+ +

差异化对比表

+ + + + + + + + + +
元素Top 1-1213+ 候选区
立绘 opacity1(鲜艳)0.78(轻度暗化)
卡片边框--border-purple 紫边14% 白边框
卡片辉光--shadow-purple 紫色辉光无辉光
排名徽章紫色实心圆 + 紫色辉光--elevated 底 + 55% 白字
投票按钮完全相同:紫色实心 + 白字「投票」
票数颜色--color-purple-30055% 白色
组件结构100% 一致,仅 className 差异
+
+ + + + + +
+
07.7
+

Buttons · 按钮

+ +

Variants

+
+ + + + +
+ +

Sizes

+
+ + + +
+ +

带图标按钮(lucide SVG)

+
+ + + +
+ + + + + + +
尺寸高度Padding-X字号用途
sm32px14px10px / 0.15em次要按钮、Tag 操作
md44px24px12px / 0.25em默认尺寸 ★
lg56px40px14px / 0.25emHero CTA、主操作
+
+ + + + + +
+
08
+

Icons · 图标系统

+ +
+ 铁律:禁止使用任何 emoji(奖杯、心形、放大镜、喇叭、播放三角、箭头等所有彩色 / Unicode 装饰字符)。所有图标统一使用 lucide-react SVG,stroke 宽度 1.5-2,无 fill 例外(如 Heart 用 fill="currentColor")。

唯一例外:品牌字标 CYBER ✦ STAR 中间的 (U+2726 BLACK FOUR POINTED STAR)保留原字符,配紫色辉光。 +
+ +

项目使用的 lucide 图标清单

+ + +

标准尺寸

+ + + + + + +
用途尺寸
内联小图标(票数、标签)12-14px
按钮内图标14-16px
独立操作图标16-20px
大尺寸标题图标24-32px
+
+ + +
+
09
+

Responsive · 响应式断点

+ + + + + + + + +
断点设备卡片列数布局变化
< 640px手机2横滚 Top12,隐藏 nav 文字
≥ 640px大手机 / 平板竖3
≥ 768px平板横4显示 nav 文字
≥ 1024px桌面5完整布局
≥ 1280px大桌面5容器 max-w-7xl 居中
+
+ + +
+
10
+

UI/UX 通用规范(必须遵守)

+

10 条网页设计行业铁律,所有页面都必须满足。

+ +

10.1 可访问性(WCAG 2.1 AA)

+
    +
  • 所有交互元素必须有 aria-label 或可见文字
  • +
  • 颜色对比度:正文 ≥ 4.5:1,大字 ≥ 3:1
  • +
  • 不依赖颜色单独传达信息(必须配合图标 / 文字)
  • +
  • 视频自动播放必须 muted,并提供解除静音控件
  • +
  • 弹窗:Escape 关闭、聚焦陷阱、aria-modal="true"
  • +
+ +

10.2 焦点指示(Keyboard Nav)

+
    +
  • 所有可聚焦元素必须有可见 :focus-visible 轮廓
  • +
  • 标准:2px 紫色实线 + 2px 偏移,色值 --color-purple-400
  • +
  • 禁止 outline: none 而不提供替代焦点样式
  • +
+ +

10.3 触控目标

+
    +
  • 移动端最小触控目标 44 × 44 px
  • +
  • 相邻可点击元素至少留 8px 间距
  • +
+ +

10.4 三态必备

+ + + + + +
状态视觉
加载中(Loading)骨架屏 + lucide Loader2,不用 spinner 占整页
空状态(Empty)居中插画 / 图标 + 说明 + 可选 CTA
错误(Error)居中错误图标(pink-500)+ 描述 + 重试按钮
+ +

10.5 表单设计

+
    +
  • Label 始终在输入框上方(不用浮动 label)
  • +
  • 错误提示:输入框下方红字 + 边框转 pink-500
  • +
  • Placeholder 不能代替 Label
  • +
  • 必填字段标 *(紫色)
  • +
+ +

10.6 语义

+
    +
  • <button> 用于触发动作,<a href> 用于跳转
  • +
  • 禁止:用按钮做跳转 / 用链接做提交
  • +
+ +

10.7-10 信息密度 · 性能 · 暗色模式 · 禁用 emoji

+
    +
  • 同区块 ≤ 3 个字号层级,section 间距 ≥ 40 / 64 px
  • +
  • 段落最大宽度 ≤ 75 字符
  • +
  • LCP < 2.5s,CLS < 0.1,图片必须 aspect-ratio 占位
  • +
  • 暗色模式专属:禁纯黑 #000、禁大面积纯白、阴影改辉光
  • +
  • 禁止任何 emoji,所有图标统一 lucide SVG(强制铁律)
  • +
+
+ + +
+
11
+

Content · 文案约定

+ + + + + + + + + + + +
场景文案
投票按钮「投票」(不用 "Vote")
顶部 nav「首页」「排行榜」
排名榜标题<Trophy size={14}/> + 「实时 Top12 出道位」
倒计时前缀「距离投票结束」
投票成功「已为 {名字} 投出 {N} 票」
票数耗尽「今日票数已用完,明天再来吧」
卡片票数<Heart size={12} fill="currentColor"/> + 「12.6W 票」(W 大写)
版权「© {当年} CYBER STAR · All Rights Reserved」
+
+ + +
+
12
+

Token 映射 · 设计 ↔ 代码

+

交付时设计师直接引用 Tailwind 工具类名,开发零成本接入。

+ + + + + + + + + + + + + + +
Figma 颜色名CSS 变量Tailwind 工具类
Background / Deepestvar(--color-deepest)bg-deepest
Background / Cardvar(--color-card)bg-card
Brand / Purple 500var(--color-purple-500)bg-purple-500 / text-purple-500
Brand / Purple 300var(--color-purple-300)text-purple-300
Border / Subtlevar(--border-subtle)border-white/[0.08]
Border / Purple Softrgba(196,181,253,0.4)border-purple-300/40
Shadow / Purple Glowvar(--shadow-purple)shadow-purple-glow
Gradient / Purplevar(--grad-purple)bg-grad-purple
Font / Logovar(--font-logo) = Orbitronfont-logo
Font / Displayvar(--font-display) = Audiowidefont-display
Font / Bodyvar(--font-body) = Interfont-body
+
+ + + +
+
+ + diff --git a/docs/design-spec.md b/docs/design-spec.md new file mode 100644 index 0000000..3b1fbfe --- /dev/null +++ b/docs/design-spec.md @@ -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` 或自定义 `` | 四角星形 | +| 查看更多链接 | `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 链接与按钮的语义区分 + +| 元素 | 用途 | 视觉 | +|---|---|---| +| ` diff --git a/src/app/login/LoginForm.tsx b/src/app/login/LoginForm.tsx index 8e40bd0..0a060ed 100644 --- a/src/app/login/LoginForm.tsx +++ b/src/app/login/LoginForm.tsx @@ -179,7 +179,7 @@ export default function LoginForm() { {process.env.NODE_ENV !== "production" && (
- 💡 开发环境:万能验证码 123456 + 开发环境:万能验证码 123456
)} @@ -216,19 +216,19 @@ export default function LoginForm() {
diff --git a/src/app/me/MeContent.tsx b/src/app/me/MeContent.tsx index 44fad96..966f0d1 100644 --- a/src/app/me/MeContent.tsx +++ b/src/app/me/MeContent.tsx @@ -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 ( -
- +
+ - +
-
- -

- 每日签到 -

-
+
-
- -

- 我的应援 -

-
+
- -
- -
+
+ ); +} + +function SectionTitle({ label }: { label: string }) { + return ( +
+ +

+ {label} +

); } diff --git a/src/app/me/page.tsx b/src/app/me/page.tsx index a35f9f4..de5ed0b 100644 --- a/src/app/me/page.tsx +++ b/src/app/me/page.tsx @@ -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 }; diff --git a/src/app/page.tsx b/src/app/page.tsx index 7de3f81..48b4df1 100644 --- a/src/app/page.tsx +++ b/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("votes"); const [tagFilter, setTagFilter] = useState("all"); - const [search, setSearch] = useState(""); - const [view, setView] = useState("grid"); + const [filterStuck, setFilterStuck] = useState(false); + const filterSentinelRef = useRef(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 */} -
+ {/* Hero · 全屏沉浸式视频 · 作为第一个 snap 点 */} +
-
+
- {/* Top12 实时榜条 */} -
-
-

- 🏆 - Top 12 · 实时出道位 -

- - 查看完整榜单 - - -
+ {/* Top12 出道位 · 作为第二个 snap 点:滚动结束后自然落到这里,标题贴近顶部 */} +
- {/* 候选人阵容 */} + {/* 候选人阵容 · 作为第三个 snap 点 */}
-
-
-

- Candidates · 35 Idols -

-

- ✦ 候选人阵容 + {/* 大标题 · 版心内 */} +
+
+

+ + 35 位候选人

+

+ 当前显示{" "} + + {visibleArtists.length} + {" "} + 位 +

-

- 共 {visibleArtists.length}{" "} - 位艺人 -

- + {/* 哨兵:用于检测筛选条是否吸顶 */} +
- {visibleArtists.length === 0 ? ( - - ) : view === "grid" ? ( -
- {visibleArtists.map((a) => ( - - ))} + {/* 筛选条 · 外层铺满(与导航栏同宽),吸顶后启用毛玻璃;内层版心承载文案 */} +
+
+
- ) : ( -
- {visibleArtists.map((a) => ( - - ))} -
- )} +
+ + {/* 候选人网格 · 版心内 */} +
+ {visibleArtists.length === 0 ? ( + + ) : ( +
+ {visibleArtists.map((a) => ( + + ))} +
+ )} +

- {/* 投票弹窗 */} - + ); } -function ArtistListRow({ - artist, - onVote, -}: { - artist: Artist; - onVote: (a: Artist) => void; -}) { - const inTop12 = artist.rank <= 12; - return ( -
-
- #{artist.rank} -
- -
- -
-
-
- {artist.name}{" "} - - · {artist.enName} - -
-
- No.{artist.no} · {artist.slogan} -
-
- -
-
- {(artist.votes / 10000).toFixed(1)}w -
-
-
- -
- ); -} - function EmptyState() { return (

No Match

-

未找到匹配的艺人,试试换个关键词或筛选标签

+

该标签下暂无艺人

); } diff --git a/src/app/ranking/page.tsx b/src/app/ranking/page.tsx index 4dbad2c..7e70b41 100644 --- a/src/app/ranking/page.tsx +++ b/src/app/ranking/page.tsx @@ -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(() => { @@ -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 ( <> -
- {/* 头部 */} -
-

- Live Ranking · 2026 -

-

- Top - - 35 -

-

35 位候选人 · 实时排名

-
- - +
+ {/* Top 3 领奖台 */} + + + {/* 实时刷新标识 */} +
+ +
+ + {/* 列表头部 */} +
+
+ 排名 + 头像 + 艺人 + 票数 + 距上一名 + 操作 +
+ +
+ {top4to12.map((a, idx) => { + const prev = idx === 0 ? top3[2] : top4to12[idx - 1]; + const gap = prev ? prev.votes - a.votes : undefined; + return ( + + ); + })} +
+ + + +
+ {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 ( + + ); + })}
- {/* Top3 领奖台 */} - - - {/* Top 4-12 标题 */} -
- -

- 出道位 · Top 4 ~ 12 -

-
- -
- 排名 - 头像 - 艺人 - 票数 - 距上一名 - 操作 -
- -
- {top4to12.map((a, idx) => { - const prev = idx === 0 ? top3[2] : top4to12[idx - 1]; - const gap = prev ? prev.votes - a.votes : undefined; - return ( - - ); - })} -
- - - -
- {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 ( - - ); - })} -
- -

- 排名每 30 秒自动刷新 · 投票后立即生效 -

- + ); } diff --git a/src/components/ArtistFilters.tsx b/src/components/ArtistFilters.tsx index e2c68e7..19ba626 100644 --- a/src/components/ArtistFilters.tsx +++ b/src/components/ArtistFilters.tsx @@ -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 ( -
- {/* 排序 */} - - 排序 - -
- {SORT_OPTIONS.map((opt) => ( - onSortChange(opt.key)} - > - {opt.label} - - ))} -
- - - - {/* 标签 */} - - 标签 - -
- {TAG_OPTIONS.map((opt) => ( - onTagChange(opt.key)} - > - {opt.label} - - ))} -
- - {/* 搜索 + 视图切换:靠右 */} -
- - -
- - -
-
+
+ {TAG_OPTIONS.map((opt) => ( + onTagChange(opt.key)} + > + {opt.label} + + ))}
); } -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 && ( + + )} ); } diff --git a/src/components/FloatingVoteButton.tsx b/src/components/FloatingVoteButton.tsx index f18a580..fe342d2 100644 --- a/src/components/FloatingVoteButton.tsx +++ b/src/components/FloatingVoteButton.tsx @@ -40,7 +40,7 @@ export default function FloatingVoteButton({ )} > - VOTE + 投票 ); } diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 3b50eb5..620b481 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,22 +1,14 @@ import Logo from "./Logo"; export default function Footer() { + const year = new Date().getFullYear(); return ( -
-
-
- -

- 虚拟偶像 Top12 出道企划 · Cyber Star · Virtual Idol Debut Project -

-
- -
-

- © 2026 Cyber Star -

-

airlabs.art

-
+
+
+ +

+ © {year} CYBER STAR · All Rights Reserved +

); diff --git a/src/components/HeroBanner.tsx b/src/components/HeroBanner.tsx index ae89e23..1ddc152 100644 --- a/src/components/HeroBanner.tsx +++ b/src/components/HeroBanner.tsx @@ -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(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 (
{/* 背景视频 / 渐变占位 */} {videoSrc ? ( @@ -96,120 +70,72 @@ export default function HeroBanner({ muted loop playsInline + preload="auto" className="absolute inset-0 w-full h-full object-cover" /> ) : (
)} - {/* 装饰:能量光环 */} -
- {/* 蒙层渐变 */}
- {/* 顶部:左侧 PV 标 + 右侧倒计时 */} -
-
- ▶ Debut PV {videoSrc ? "自动播放" : "敬请期待"}(静音) + {/* 版心容器:1500px max-width,所有文案 / 倒计时 / 声音按钮全部在内 */} +
+ {/* Eyebrow 左上 */} +
+

+ Top 12 · Virtual Idol Debut Project +

- -
- {/* 中央内容:Logo + 副标题 + Play 按钮 */} -
-

- {eyebrow} -

-

- {title.split("STAR").map((part, i) => - i === 0 ? ( - {part} - ) : ( - - - ✦ - - STAR - {part.replace("STAR", "")} - - ), - )} -

-

{subtitle}

+ {/* 浅紫边框倒计时 右上 */} +
+ +
- {/* 播放按钮 */} - -
+ {/* 中央 CYBER ✦ STAR */} +
+

+ CYBER + + ✦ + + STAR +

+

+ 虚 拟 偶 像 出 道 企 划 +

+
- {/* 底部右侧:音量 / 全屏 */} -
+ {/* 声音按钮 右下 */} -
- {/* 底部左侧:滚动提示 */} - - - {/* 隐藏的"用户已交互"标志,用于满足可访问性 */} - - {hasInteracted ? "interacted" : "auto"} - + {/* 底部渐隐 */} +
); } diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx index 3e7033f..d91aba2 100644 --- a/src/components/Logo.tsx +++ b/src/components/Logo.tsx @@ -8,11 +8,12 @@ interface LogoProps { className?: string; } -const SIZE_MAP: Record = { - 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 = { + 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]; + // 用原生 绕开 Next/Image 的格式转换 —— 某些环境下 sharp 把透明 PNG + // 转 webp/avif 时会铺白底,导致 logo 在深色 nav 上出现白色矩形。 const inner = ( - - Cyber - - Star - + CYBER STAR ); if (!href) return inner; return ( - + {inner} ); diff --git a/src/components/NavLinks.tsx b/src/components/NavLinks.tsx index 3da9d80..e3be17b 100644 --- a/src/components/NavLinks.tsx +++ b/src/components/NavLinks.tsx @@ -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, + item: (typeof NAV_ITEMS)[number], + ) => { + if (item.requireAuth && status !== "authenticated") { + e.preventDefault(); + // 登录成功后直接跳目标页面(如「我的」→ /me),不回首页 + if (status === "unauthenticated") openLogin(item.href); + } + }; + if (mobile) { return (
    @@ -36,8 +57,9 @@ export default function NavLinks({ className, mobile = false }: NavLinksProps) {
  • 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 (
      @@ -63,8 +85,9 @@ export default function NavLinks({ className, mobile = false }: NavLinksProps) {
    • 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 && ( )} diff --git a/src/components/Navigation.tsx b/src/components/Navigation.tsx index b3f7ec5..675b51a 100644 --- a/src/components/Navigation.tsx +++ b/src/components/Navigation.tsx @@ -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 (
      -
-
+

- ⚠ 视频不会自动播放,避免流量浪费 + 视频不会自动播放,避免流量浪费

-
+
-
+

@@ -212,7 +209,13 @@ export default function ArtistDetailContent({ openVote(artist)} /> - + ); } @@ -247,13 +250,14 @@ function SectionHeading({ subtitle: string; }) { return ( -

-

- {subtitle} -

-

- ✦ {title} +
+ +

+ {title}

+ + {subtitle} +
); } diff --git a/src/components/auth/AuthMenu.tsx b/src/components/auth/AuthMenu.tsx new file mode 100644 index 0000000..b188b5a --- /dev/null +++ b/src/components/auth/AuthMenu.tsx @@ -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(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 ( + + ); + } + + // 已登录态:头像 + 昵称 + 下拉(紫色渐变实心胶囊,高亮用户身份) + return ( +
+ + + {menuOpen && ( +
+
+
登录账号
+
+ {user?.name} +
+
+ +
+ )} +
+ ); +} diff --git a/src/components/auth/GlobalLoginModal.tsx b/src/components/auth/GlobalLoginModal.tsx new file mode 100644 index 0000000..b3b0a69 --- /dev/null +++ b/src/components/auth/GlobalLoginModal.tsx @@ -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 ( + setOpen(false)} + onSuccess={handleSuccess} + /> + ); +} diff --git a/src/components/auth/LoginModal.tsx b/src/components/auth/LoginModal.tsx new file mode 100644 index 0000000..9883797 --- /dev/null +++ b/src/components/auth/LoginModal.tsx @@ -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(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( + + {open && ( + + {/* 遮罩 */} + + +
+
+ +
+

+ Sign in to Vote +

+
+ +
+
+ +
+ + + 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" + /> +
+
+ +
+ +
+
+ + + 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" + /> +
+ +
+
+ + {error && ( +
+ {error} +
+ )} + + {process.env.NODE_ENV !== "production" && ( +
+ 开发环境:万能验证码 123456 +
+ )} + + + +

+ 未注册手机号将自动创建账号 +

+
+
+ + )} +
, + document.body, + ); +} diff --git a/src/components/auth/RemainingVotesBadge.tsx b/src/components/auth/RemainingVotesBadge.tsx new file mode 100644 index 0000000..2d4dd3d --- /dev/null +++ b/src/components/auth/RemainingVotesBadge.tsx @@ -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 ( +
+ + 今日剩余 + + + {remaining} + + + / {DAILY_VOTE_QUOTA} + +
+ ); +} diff --git a/src/components/cards/ArtistCard.tsx b/src/components/cards/ArtistCard.tsx index a21bb72..d494887 100644 --- a/src/components/cards/ArtistCard.tsx +++ b/src/components/cards/ArtistCard.tsx @@ -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 (
- {/* 立绘区 */} -
- + {/* 立绘区(13+ 卡片轻度暗化) */} +
+ - {/* 编号徽章(左上) */} -
- No.{artist.no} -
- - {/* 排名徽章(右上) */} + {/* 排名徽章(左上独立紫色圆) */}
{artist.rank}
- {/* 顶部渐变蒙层(让编号更清晰) */} -
+ {/* 顶部轻微渐变蒙层 */} +
- {/* 信息区 */} -
-
- {artist.name}{" "} - · {artist.enName} + {/* 信息区(黑色背景明显分隔) */} +
+
+ No.{artist.no}
-
+
+ {artist.name} +
+
{artist.slogan}
- ❤ {formatVotes(artist.votes)} 票 + + {formatVotes(artist.votes)} 票
- {/* 投票按钮 */} -
+ {/* 投票按钮(所有排名统一样式 · 紫色实心) */} +
diff --git a/src/components/me/MyFanSupport.tsx b/src/components/me/MyFanSupport.tsx index dd88058..eef23a8 100644 --- a/src/components/me/MyFanSupport.tsx +++ b/src/components/me/MyFanSupport.tsx @@ -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[] }) {
还没有应援的艺人 ·{" "} - 去发现 → + 去发现
); @@ -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]", )} > -
+
{artist.name}
-
+
+ 已投 {votedCount} 票
当前 #{artist.rank} diff --git a/src/components/me/QuotaCard.tsx b/src/components/me/QuotaCard.tsx index a2b88a8..49cd85d 100644 --- a/src/components/me/QuotaCard.tsx +++ b/src/components/me/QuotaCard.tsx @@ -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 ( -
- {/* 装饰星点 */} - - - ✧ - - - {/* 高光 */} +
+ {/* 装饰:右侧紫色光晕 */}
+ {/* 装饰:右侧"水晶"占位(无素材时用 CSS 渲染的辉光六边形) */} + -
+
-
- - 我累计投出 -
-
- {total.toLocaleString()}{" "} - -
-
- 为偶像应援无上限 · 越投越精彩 +

今日剩余票数

+
+ + {remaining} + +
+

+ 明日 00:00 重置为{" "} + + {dailyQuota} + {" "} + 票 +

-
- - 喊好友一起来 +
+ + 获取更多票数
); } + +/** 右侧装饰:用 CSS 渲染的简易"紫色水晶"效果,作为 3D 素材到位前的占位。 */ +function CrystalDecoration() { + return ( +
+
+
+
+
+
+ ); +} diff --git a/src/components/me/SignInCalendar.tsx b/src/components/me/SignInCalendar.tsx index 75346dc..b92d24c 100644 --- a/src/components/me/SignInCalendar.tsx +++ b/src/components/me/SignInCalendar.tsx @@ -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 ( -
-
- {WEEK_LABELS.map((label, i) => { - const signed = weekly[i]; - const isToday = i === computedToday; - return ( - - ); - })} -
- -

- 每日签到 · 解锁专属应援徽章 · 中断后从头计算 -

+
+ {WEEK_LABELS.map((label, i) => { + const signed = weekly[i]; + const isToday = i === computedToday; + const clickable = isToday && !todaySigned; + return ( + + ); + })}
); } diff --git a/src/components/me/StatsGrid.tsx b/src/components/me/StatsGrid.tsx index 1eba8dd..7c17b0e 100644 --- a/src/components/me/StatsGrid.tsx +++ b/src/components/me/StatsGrid.tsx @@ -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: , + votes: , fan: , signin: , invite: , @@ -36,14 +36,19 @@ export default function StatsGrid({ user }: { user: MockUser }) { {stats.map((s) => (
-
- {s.value} -
-
- {s.icon} - {s.label} + + {s.icon} + +
+
+ {s.value} +
+
{s.label}
))} diff --git a/src/components/me/UserHeader.tsx b/src/components/me/UserHeader.tsx index f1a9d5b..f22dfa9 100644 --- a/src/components/me/UserHeader.tsx +++ b/src/components/me/UserHeader.tsx @@ -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 ( -
- {/* 头像 */} -
-
- {initial} +
+ {/* 头像 + 等级角标 */} +
+
+
+ {initial} +
+ + + Lv.{level} +
- @{user.nickname} + {user.nickname}
-
+
ID: {user.id} - | + · 已连续签到{" "} - + {user.signInStreak} {" "} 天
- +
+ + {onLogout && ( + + )} +
); } diff --git a/src/components/ranking/DebutLineDivider.tsx b/src/components/ranking/DebutLineDivider.tsx index 142b49c..3b37c7c 100644 --- a/src/components/ranking/DebutLineDivider.tsx +++ b/src/components/ranking/DebutLineDivider.tsx @@ -1,7 +1,8 @@ +import { AlertTriangle } from "lucide-react"; + export default function DebutLineDivider() { return ( -
- {/* 横线 */} +
- {/* 中央徽章 */} -
- - - Debut Line · 出道线 +
+ + + 出道线 · Debut Line -
); diff --git a/src/components/ranking/RankingRow.tsx b/src/components/ranking/RankingRow.tsx index 9fcb760..705f822 100644 --- a/src/components/ranking/RankingRow.tsx +++ b/src/components/ranking/RankingRow.tsx @@ -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 (
{/* 排名 */}
#{artist.rank} @@ -54,11 +55,7 @@ export default function RankingRow({
- {artist.name}{" "} - - · {artist.enName} + {artist.name} + + · {artist.slogan}
-
- {artist.slogan} -
{/* 票数 */}
-
- {(artist.votes / 10000).toFixed(1)}w -
-
+ + {formatVotes(artist.votes)} +
- {/* 差距 */} + {/* 距上一名 / 差出道线 */}
{isRescue && gapToDebut != null ? ( -
+
- 差 +{gapToDebut.toLocaleString()} 进出道位 + +{gapToDebut.toLocaleString()}
) : gapAbove != null && artist.rank > 1 ? ( -
- − - {gapAbove.toLocaleString()} +
+ + −{gapAbove.toLocaleString()}
) : ( )}
- {/* 投票按钮 */} + {/* 投票按钮(统一紫色实心) */}
); diff --git a/src/components/ranking/Top3Podium.tsx b/src/components/ranking/Top3Podium.tsx index 46e578e..df31ff7 100644 --- a/src/components/ranking/Top3Podium.tsx +++ b/src/components/ranking/Top3Podium.tsx @@ -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 ( -
+
{order.map(({ artist, rank }) => { - const style = STYLES[rank]; + const isFirst = rank === 1; return ( + {/* 顶部小皇冠(仅第一名) */} + {isFirst && ( +
+ +
+ )} + + {/* 头像(圆形 + 紫色环) */}
- {style.label} - {style.rank} -
-
-
+ + {/* 名字 */} +
{artist.name}
-
- {artist.enName} -
+ + {/* 票数 */}
- {(artist.votes / 10000).toFixed(1)}w + {formatVotes(artist.votes)}{" "} + +
+ + {/* 当前排名徽章 */} +
+ 当前 #{artist.rank}
-
); })} diff --git a/src/components/ui/Countdown.tsx b/src/components/ui/Countdown.tsx index 18d8dd4..f974e77 100644 --- a/src/components/ui/Countdown.tsx +++ b/src/components/ui/Countdown.tsx @@ -83,15 +83,15 @@ export default function Countdown({ return (
- - ⏱ - - - {time.days}d {String(time.hours).padStart(2, "0")}: + 距离投票结束 + + {time.days}天 {String(time.hours).padStart(2, "0")}: {String(time.minutes).padStart(2, "0")}: {String(time.seconds).padStart(2, "0")} diff --git a/src/hooks/useVoteAction.ts b/src/hooks/useVoteAction.ts index 9cb1c47..793dbcf 100644 --- a/src/hooks/useVoteAction.ts +++ b/src/hooks/useVoteAction.ts @@ -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(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, + }; } diff --git a/src/lib/api-response.ts b/src/lib/api-response.ts index 10fa759..cea05aa 100644 --- a/src/lib/api-response.ts +++ b/src/lib/api-response.ts @@ -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), }; /** diff --git a/src/lib/login-modal-store.ts b/src/lib/login-modal-store.ts new file mode 100644 index 0000000..c927099 --- /dev/null +++ b/src/lib/login-modal-store.ts @@ -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((set) => ({ + open: false, + redirectTo: undefined, + setOpen: (open) => set({ open }), + show: (redirectTo) => set({ open: true, redirectTo }), +})); diff --git a/src/lib/mock-data.ts b/src/lib/mock-data.ts index c679479..a6bc31f 100644 --- a/src/lib/mock-data.ts +++ b/src/lib/mock-data.ts @@ -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"], ]; diff --git a/src/lib/mock-user.ts b/src/lib/mock-user.ts index ea6c7a0..d81c496 100644 --- a/src/lib/mock-user.ts +++ b/src/lib/mock-user.ts @@ -11,7 +11,7 @@ export interface MockUser { weeklySignIn: boolean[]; /** 今日是否已签到 */ todaySignedIn: boolean; - /** 累计投票数(无上限) */ + /** 累计投票数 */ totalVotes: number; /** 应援的艺人 ID 列表 */ supportingIds: string[]; diff --git a/src/lib/store.ts b/src/lib/store.ts index 583e09b..61fe9ba 100644 --- a/src/lib/store.ts +++ b/src/lib/store.ts @@ -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((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((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); +} diff --git a/src/types/artist.ts b/src/types/artist.ts index 48e11a7..42600c9 100644 --- a/src/types/artist.ts +++ b/src/types/artist.ts @@ -48,7 +48,7 @@ export interface Artist { export const TAG_LABEL: Record = { vocal: "声乐担当", dance: "舞蹈担当", - rap: "Rap 担当", + rap: "rap担当", "all-rounder": "全能型", visual: "颜值担当", leader: "队长担当",