UI-UX/src/components/artist/ArtistDetailContent.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

455 lines
14 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 Link from "next/link";
import {
ChevronLeft,
Heart,
Check,
Quote as QuoteIcon,
Sparkles,
Compass,
MessageCircle,
User,
Ruler,
Calendar,
BookOpen,
} from "lucide-react";
import type { Artist } from "@/types/artist";
import { TAG_LABEL } from "@/types/artist";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import VoteModal from "@/components/VoteModal";
import Button from "@/components/ui/Button";
import RankCard from "./RankCard";
import PerformanceVideo from "./PerformanceVideo";
import PerformanceGallery from "./PerformanceGallery";
import FloatingVoteButton from "@/components/FloatingVoteButton";
import FloatingBackButton from "@/components/FloatingBackButton";
import { useVoteStore, selectArtist, selectHasVoted } from "@/lib/store";
import { useVoteAction } from "@/hooks/useVoteAction";
import { cn } from "@/lib/cn";
interface ArtistDetailContentProps {
artist: Artist;
allArtists: Artist[];
}
/** 把 "、 / , " 分隔的串切成 chip 数组 */
function parseChips(text?: string): string[] {
if (!text) return [];
return text
.split(/[、,,/]/)
.map((s) => s.trim())
.filter(Boolean);
}
export default function ArtistDetailContent({
artist: initialArtist,
allArtists: initialAll,
}: ArtistDetailContentProps) {
// 用 store 数据覆盖(投票后票数能马上变)
const storeArtist = useVoteStore(selectArtist(initialArtist.id));
const storeAll = useVoteStore((s) => s.artists);
const hasVoted = useVoteStore(selectHasVoted(initialArtist.id));
const artist = storeArtist ?? initialArtist;
const allArtists = storeAll.length ? storeAll : initialAll;
const { target, remaining, totalQuota, openVote, closeVote, confirmVote } =
useVoteAction();
return (
<>
{/* 面包屑 */}
<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/85 truncate">{artist.name}</span>
</div>
</div>
{/* HERO · 立绘 + 身份信息 */}
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-10">
<HeroPanel
artist={artist}
allArtists={allArtists}
onVote={() => openVote(artist)}
hasVoted={hasVoted}
/>
</section>
{/* 性格 · 口头禅 */}
{(artist.personality || artist.catchphrase) && (
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-10">
<div className="grid lg:grid-cols-[1.5fr_1fr] gap-4">
{artist.personality && <PersonalityCard text={artist.personality} />}
{artist.catchphrase && <CatchphraseCard text={artist.catchphrase} />}
</div>
</section>
)}
{/* 核心技能 · 核心赛道 */}
{(artist.skills || artist.track) && (
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-10">
<div className="grid md:grid-cols-2 gap-4">
{artist.skills && (
<ChipCard
label="核心技能"
subtitle="Core Skills"
icon={<Sparkles size={14} />}
chips={parseChips(artist.skills)}
/>
)}
{artist.track && (
<ChipCard
label="核心赛道"
subtitle="Career Track"
icon={<Compass size={14} />}
chips={parseChips(artist.track)}
/>
)}
</div>
</section>
)}
{/* 人物小传 · 长简介 */}
{artist.bio && (
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-12">
<BiographyCard bio={artist.bio} />
</section>
)}
{/* 表演视频 · 与版心同宽,首帧自动作为封面,整个视频区域可点击播放/暂停 */}
{artist.videoUrl && (
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-12">
<SectionHeading title="表演视频" subtitle="Solo Performance" />
<div className="mt-4">
<PerformanceVideo src={artist.videoUrl} poster={artist.videoPoster} />
</div>
<p className="text-xs text-white/40 mt-3">
</p>
</section>
)}
{/* 表演图片 · 三张氛围图,左对齐,竖向 3:4 */}
{artist.gallery && artist.gallery.filter(Boolean).length > 0 && (
<section className="max-w-[1500px] mx-auto px-4 sm:px-6 lg:px-8 pb-20">
<SectionHeading title="表演图片" subtitle="Performance Gallery" />
<div className="mt-4">
<PerformanceGallery images={artist.gallery} />
</div>
</section>
)}
<FloatingBackButton fallbackHref="/" />
<FloatingVoteButton onClick={() => openVote(artist)} hasVoted={hasVoted} />
<VoteModal
artist={target}
remaining={remaining}
totalQuota={totalQuota}
onClose={closeVote}
onConfirm={confirmVote}
/>
</>
);
}
/* ============================================================
* 子组件 · 统一品牌紫色,无 per-artist themeColor
* ============================================================ */
interface HeroPanelProps {
artist: Artist;
allArtists: Artist[];
onVote: () => void;
hasVoted: boolean;
}
function HeroPanel({ artist, allArtists, onVote, hasVoted }: HeroPanelProps) {
return (
<div className="relative rounded-2xl border border-purple-500/20 overflow-hidden grid gap-6 lg:grid-cols-[420px_1fr] lg:gap-8 p-5 sm:p-8 bg-[linear-gradient(135deg,rgba(139,92,246,0.10)_0%,rgba(13,10,36,0.6)_100%)]">
{/* 装饰光晕 */}
<div
aria-hidden
className="absolute -top-24 -right-16 w-[420px] h-[420px] pointer-events-none bg-[radial-gradient(circle,rgba(139,92,246,0.22)_0%,transparent_60%)]"
/>
{/* 立绘 */}
<div className="relative">
<ArtistPortrait
artist={artist}
rounded="rounded-xl"
className="w-full aspect-[4/5]"
/>
{hasVoted && (
<div className="absolute top-3 right-3 w-9 h-9 rounded-full bg-purple-500 shadow-[0_0_16px_rgba(139,92,246,0.8)] flex items-center justify-center border-2 border-white/30">
<Check size={20} strokeWidth={3} className="text-white" />
</div>
)}
</div>
{/* 身份信息 */}
<div className="relative flex flex-col gap-4">
{/* 编号 */}
<div className="font-label text-[10px] tracking-[0.3em] uppercase text-purple-300/80">
No.{artist.no}
</div>
{/* 中文名 / 英文名 */}
<div>
<h1 className="text-4xl sm:text-5xl font-bold text-white tracking-tight mb-1">
{artist.name}
</h1>
<p className="font-display text-xl sm:text-2xl tracking-[0.22em] uppercase text-purple-300 glow-text-purple">
{artist.enName}
</p>
</div>
{/* 实力标签 */}
<div className="flex gap-2 flex-wrap">
{artist.tags.map((t) => (
<span
key={t}
className="px-2.5 py-1 rounded-full bg-purple-500/12 border border-purple-500/30 text-purple-300 text-[11px]"
>
{TAG_LABEL[t]}
</span>
))}
</div>
{/* 年龄 / 身高 / 性别 */}
<div className="grid grid-cols-3 gap-2">
<MetaCell
icon={<Calendar size={13} />}
label="年龄"
value={artist.age != null ? `${artist.age}` : "未公开"}
/>
<MetaCell
icon={<Ruler size={13} />}
label="身高"
value={`${artist.height} cm`}
/>
<MetaCell
icon={<User size={13} />}
label="性别"
value={
artist.gender === "M"
? "男生"
: artist.gender === "F"
? "女生"
: "未公开"
}
/>
</div>
{/* 座右铭 · 品牌紫引文,保持与全站视觉一致 */}
{artist.motto && (
<div className="relative pl-6 pr-4 py-3.5 rounded-r-lg bg-[linear-gradient(90deg,rgba(139,92,246,0.10)_0%,transparent_100%)] border-l-[3px] border-purple-400">
<QuoteIcon size={14} className="absolute top-3 left-2 text-purple-400/50" />
<p className="text-base sm:text-lg italic font-medium text-white/95 leading-snug">
{artist.motto}
</p>
<p className="mt-1.5 font-label text-[10px] tracking-[0.25em] uppercase text-purple-300/70">
Motto ·
</p>
</div>
)}
{/* 排名卡片 */}
<RankCard artist={artist} allArtists={allArtists} />
{/* 操作按钮 · 仅投票 */}
<div className="pt-1">
<Button
variant={hasVoted ? "outline" : "primary"}
size="lg"
pulse={!hasVoted}
className={cn("w-full", hasVoted && "cursor-not-allowed opacity-80")}
leftIcon={
hasVoted ? (
<Check size={16} strokeWidth={3} />
) : (
<Heart size={16} fill="currentColor" />
)
}
onClick={onVote}
>
{hasVoted ? "已投票" : "投票"}
</Button>
</div>
</div>
</div>
);
}
function MetaCell({
icon,
label,
value,
}: {
icon: React.ReactNode;
label: string;
value: string;
}) {
return (
<div className="bg-deep/60 border border-white/[0.06] rounded-lg px-3 py-2.5">
<div className="flex items-center gap-1.5 text-purple-300/60 mb-0.5">
{icon}
<span className="font-label text-[9px] tracking-widest uppercase">
{label}
</span>
</div>
<div className="text-sm text-white/90 truncate">{value}</div>
</div>
);
}
/**
* 性格 / 口头禅 双卡使用完全相同的容器规范:
* - 圆角 2xl + 玻璃化 surface 背景 + 同样的 border / padding
* - 左上角同样的紫色装饰条
* - 同款 SectionHeading
* 唯一差异:内容呈现 —— 性格是段落正文,口头禅是大字引号。
*/
function ProfileInfoCard({
title,
subtitle,
children,
}: {
title: string;
subtitle: string;
children: React.ReactNode;
}) {
return (
<div className="relative rounded-2xl border border-white/[0.08] bg-surface/50 backdrop-blur-md p-6 sm:p-8 overflow-hidden min-h-[200px]">
<div
aria-hidden
className="absolute top-0 left-0 w-1 h-16 rounded-r bg-[linear-gradient(180deg,#a78bfa_0%,transparent_100%)]"
/>
<SectionHeading title={title} subtitle={subtitle} />
<div className="mt-5">{children}</div>
</div>
);
}
function PersonalityCard({ text }: { text: string }) {
return (
<ProfileInfoCard title="性格" subtitle="Personality">
<p
className="text-sm sm:text-base leading-relaxed"
style={{ color: "rgba(255,255,255,0.95)" }}
>
{text}
</p>
</ProfileInfoCard>
);
}
function CatchphraseCard({ text }: { text: string }) {
return (
<ProfileInfoCard title="口头禅" subtitle="Catchphrase">
<div className="flex items-start gap-3 h-full">
<MessageCircle
size={22}
className="flex-shrink-0 mt-1 text-purple-400/70"
/>
<p className="text-lg sm:text-xl font-semibold text-white/95 leading-snug tracking-wide italic">
{text}
</p>
</div>
</ProfileInfoCard>
);
}
function ChipCard({
label,
subtitle,
icon,
chips,
}: {
label: string;
subtitle: string;
icon: React.ReactNode;
chips: string[];
}) {
return (
<div className="rounded-2xl border border-white/[0.08] bg-surface/40 backdrop-blur-md p-5 sm:p-6">
<div className="flex items-center justify-between">
<SectionHeading title={label} subtitle={subtitle} />
<span className="text-purple-300/60">{icon}</span>
</div>
{chips.length > 0 ? (
<div className="flex flex-wrap gap-2 mt-5">
{chips.map((c, i) => (
<span
key={`${c}-${i}`}
className={cn(
"px-3 py-1.5 rounded-full border text-xs whitespace-nowrap",
"bg-purple-500/12 border-purple-400/30 text-purple-200",
)}
>
{c}
</span>
))}
</div>
) : (
<p className="mt-4 text-sm text-white/45"></p>
)}
</div>
);
}
function BiographyCard({ bio }: { bio: string }) {
return (
<div className="rounded-2xl border border-white/[0.08] bg-surface/50 backdrop-blur-md p-6 sm:p-10">
<div className="flex items-baseline justify-between mb-2">
<SectionHeading title="人物小传" subtitle="Biography" />
<span className="text-purple-300/60">
<BookOpen size={14} />
</span>
</div>
<p
className={cn(
"mt-5 text-base sm:text-lg text-white/85 leading-[2.05]",
// 首字下沉
"first-letter:text-4xl sm:first-letter:text-5xl first-letter:font-display first-letter:text-purple-300",
"first-letter:mr-2 first-letter:float-left first-letter:leading-none first-letter:mt-1",
)}
>
{bio}
</p>
</div>
);
}
function SectionHeading({
title,
subtitle,
}: {
title: string;
subtitle: string;
}) {
return (
<div className="inline-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>
);
}