UI-UX/src/app/page.tsx
iye 10878ddb3f
Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
feat(vote): 重构投票模型为终身 12 票 + 每艺人 1 票
前端:
- store 改为 votedArtists[] + zustand persist
- VoteModal 删除 1/3/5/ALL 选择器,改三态(待投/已投/满额)
- 卡片/排行/详情页加 hasVoted 状态 + ✓ 角标
- Hero 右上角 Countdown 替换为 HeroVoteProgress(12 格点亮进度)
- /me 改为终身额度叙事(QuotaCard / StatsGrid / MyFanSupport)

后端:
- votes 表加 @@unique([userId, artistId])(已 apply 到生产 RDS)
- /api/vote 重写:12 票上限 + P2002 ALREADY_VOTED + P2003 NOT_FOUND 兜底
- /api/me 新增 votedArtists[] + voteQuota,移除 dailyQuota
- 新增 ERR.ALREADY_VOTED 错误码

测试:
- DB 层 5/5 + E2E 18/18 通过(scripts/e2e-vote-flow.sh)
- 修复 P2003 FK 违反未识别的 bug

详情见 docs/todo/voting-refactor-完成报告.md 与 voting-refactor-backend-完成报告.md

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-15 20:14:57 +08:00

193 lines
7.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
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 } from "@/components/ArtistFilters";
import VoteModal from "@/components/VoteModal";
import { sortArtists, type SortKey } from "@/lib/mock-data";
import { useVoteStore } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction";
import { useScrollRestore } from "@/hooks/useScrollRestore";
import { useUIStore } from "@/lib/ui-store";
import { cn } from "@/lib/cn";
import { tosUrl } from "@/lib/tos";
export default function Home() {
const artists = useVoteStore((s) => s.artists);
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
useVoteAction();
const [tagFilter, setTagFilter] = useState<TagFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("votes");
const [filterStuck, setFilterStuck] = useState(false);
const filterSentinelRef = useRef<HTMLDivElement>(null);
const setStoreFilterStuck = useUIStore((s) => s.setFilterStuck);
// 首页滚动位置 per-tab 记忆:从艺人详情点 ← 返回时恢复到上次浏览位置
useScrollRestore("home");
const visibleArtists = useMemo(() => {
let list = [...artists];
if (tagFilter !== "all") {
list = list.filter((a) => a.tags.includes(tagFilter));
}
return sortArtists(list, sortKey);
}, [artists, tagFilter, sortKey]);
// 仅在首页启用 scroll-snap用户接近 Hero/Top12/候选区时自然吸附。
// 用 proximity 而不是 mandatory —— mandatory 会把"滚到底部"强制吸回到最后一个
// snap 点的 start候选区顶部表现为回弹proximity 只在靠近时吸,远离不干预。
useEffect(() => {
const root = document.documentElement;
const prev = root.style.scrollSnapType;
root.style.scrollSnapType = "y proximity";
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);
};
}, []);
// 把 filterStuck 同步到全局 UI store —— 让导航栏感知,在吸顶时关掉自己的玻璃,
// 让筛选条延伸出的"共享玻璃带"成为唯一的 backdrop-filter,消除接缝
useEffect(() => {
setStoreFilterStuck(filterStuck);
return () => setStoreFilterStuck(false);
}, [filterStuck, setStoreFilterStuck]);
return (
<>
{/* Hero · 全屏沉浸式视频 · 作为第一个 snap 点
-mt-20 把 Hero 拉到 main 顶部 padding 之上,让视频铺到导航后面(与毛玻璃导航重叠) */}
<div
className="-mt-20"
style={{
scrollSnapAlign: "start",
}}
>
<HeroBanner videoSrc={tosUrl("videos/hero-pv.mp4")} />
</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"
style={{
scrollSnapAlign: "start",
scrollMarginTop: "80px",
}}
className="pb-12"
>
{/* 大标题 · 版心内 */}
<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" />
{artists.length}
</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>
</div>
{/* 哨兵:用于检测筛选条是否吸顶 */}
<div ref={filterSentinelRef} aria-hidden style={{ height: 0 }} />
{/* 筛选条 · 外层铺满,内层版心承载文案。
吸顶时,absolute 子层把玻璃从 -top-20 一路扩到容器底部 ——
这是一个单一元素的 backdrop-filter,横跨 nav 区域 + filter 区域,
消除两块独立玻璃在 y=80 接缝处的视觉割裂。导航栏同步关掉自己的玻璃。 */}
<div
className="sticky z-30 transition-colors duration-200"
style={{ top: "80px" }}
>
{/* 共享玻璃带:absolute,吸顶时延伸到 nav 顶部,opacity 平滑过渡 */}
<div
aria-hidden
className={cn(
"absolute inset-x-0 -top-20 bottom-0 pointer-events-none",
"bg-surface/40 backdrop-blur-xl backdrop-saturate-150 border-b border-white/[0.06]",
"transition-opacity duration-300",
filterStuck ? "opacity-100" : "opacity-0",
)}
/>
<div className="relative max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8">
<ArtistFilters
tagFilter={tagFilter}
onTagChange={setTagFilter}
sort={sortKey}
onSortChange={setSortKey}
/>
</div>
</div>
{/* 候选人网格 · 版心内 */}
<div 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}
remaining={remaining}
totalQuota={totalQuota}
onClose={closeVote}
onConfirm={confirmVote}
/>
</>
);
}
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>
</div>
);
}