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>
455 lines
14 KiB
TypeScript
455 lines
14 KiB
TypeScript
"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>
|
||
);
|
||
}
|