Some checks failed
Build and Deploy / build-and-deploy (push) Has been cancelled
前端: - 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>
193 lines
7.2 KiB
TypeScript
193 lines
7.2 KiB
TypeScript
"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>
|
||
);
|
||
}
|