From 71a2672ff6c10ddbf7f6a9a651271a63c12db34e Mon Sep 17 00:00:00 2001 From: iye <1713042409@qq.com> Date: Wed, 13 May 2026 13:56:42 +0800 Subject: [PATCH] fix(data,ranking,ui): real dynamic ranking + data sync hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 数据准确性 - 票数初始为 0,不再用 Math.sqrt 公式造假票 - 排序 tiebreaker 统一为 votes desc + no asc,确保稳定 - store.rank() 与 sortArtists() 行为对齐 Top12 / 出道位 - Top12Bar 仅收纳真正有票的人(votes > 0),0 票时显示"出道位尚未产生"空态 - ArtistCard / RankingRow / SearchModal / MyFanSupport / RankCard 的 inTop12 高亮全部加 votes > 0 守卫 - ArtistFilters 新增"实时排名 / 编号顺序"分段切换 + 首页 sortKey 状态 领奖台 (Top3Podium) - 1 人有票即可显示领奖台(此前要求 >= 3 人才显示) - 缺位的 #2/#3 用"虚位以待"占位卡片填充,与正式卡片同 3:4 比例对齐 - 全员 0 票时三张全部显示虚位以待 - 卡片间距拉大到 gap-8 sm:gap-12 排行榜页 (/ranking) - API 票数与本地乐观投票取 max() 合并,避免 API 落后覆盖本地新票 - 合并后重新排序与赋 rank - 仅 >= 12 人有票才显示出道线分隔与复活位标记 - 复活位 gapToDebut 计算修正 跨日额度 - selectRemaining 按 quotaDate 判断是否跨日,跨日自动回满额(此前会卡在昨日剩余值) 搜索 (SearchModal) - 改为订阅 store 拿活的票数,投票后立即反映 - 默认"热门 Top12"只在真正有票时显示,否则降级为"推荐艺人 · 编号顺序" - 票数显示统一走 formatVotes(0 票不再显示 0.0w) 人物详情 - 36 人真实数据接入,移除全部静态数据(slogan/birthday/cv/themeColor) - 接入人物小传 docx 数据(年龄/身高/性格/口头禅/技能/赛道/座右铭/长简介) - 视频区与版心同宽 + 首帧自动作为封面 + 整区点击播放/暂停 + 可拖拽进度条 - 表演图片改为三张氛围图竖向 3:4,左对齐 - 移除分享按钮,投票按钮全宽 个人页 (/me) - 移除等级/邀请好友/签到/编辑资料等静态数据 - 退出登录按钮在移动端 icon-only 显示(此前 sm:hidden 导致移动端无法登出) - 我的应援 list 基于真实 myVotesByArtist 派生,凯之类的投票真正同步过去 导航 - 余票徽章未登录态显示 0/0,已登录显示 N/10 - 登录/注册按钮样式与登录后头像胶囊保持一致(紫色实心) Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 4 + public/rank-1.svg | 1 + public/rank-2.svg | 1 + public/rank-3.svg | 1 + src/app/api/me/route.ts | 4 - src/app/api/ranking/route.ts | 4 +- src/app/layout.tsx | 2 +- src/app/me/MeContent.tsx | 98 +-- src/app/page.tsx | 18 +- src/app/ranking/page.tsx | 67 ++- src/components/ArtistFilters.tsx | 67 ++- src/components/SearchModal.tsx | 58 +- src/components/Top12Bar.tsx | 38 +- src/components/artist/ArtistDetailContent.tsx | 512 ++++++++++------ src/components/artist/PerformanceGallery.tsx | 95 +-- src/components/artist/PerformanceVideo.tsx | 265 +++++--- src/components/artist/RankCard.tsx | 18 +- src/components/auth/AuthMenu.tsx | 4 +- src/components/auth/RemainingVotesBadge.tsx | 21 +- src/components/cards/ArtistCard.tsx | 5 +- src/components/cards/ArtistPortrait.tsx | 30 +- src/components/me/MyFanSupport.tsx | 6 +- src/components/me/QuotaCard.tsx | 53 +- src/components/me/StatsGrid.tsx | 47 +- src/components/me/UserHeader.tsx | 61 +- src/components/ranking/RankingRow.tsx | 5 +- src/components/ranking/Top3Podium.tsx | 168 ++++-- src/hooks/useRanking.ts | 2 - src/lib/artist-bios.ts | 564 ++++++++++++++++++ src/lib/mock-data.ts | 159 ++--- src/lib/mock-user.ts | 52 -- src/lib/store.ts | 53 +- src/types/artist.ts | 66 +- 33 files changed, 1681 insertions(+), 868 deletions(-) create mode 100644 public/rank-1.svg create mode 100644 public/rank-2.svg create mode 100644 public/rank-3.svg create mode 100644 src/lib/artist-bios.ts delete mode 100644 src/lib/mock-user.ts diff --git a/.gitignore b/.gitignore index 7b8da95..f9fc478 100644 --- a/.gitignore +++ b/.gitignore @@ -40,3 +40,7 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +# 大型静态资源 · 走 TOS 桶 + CDN,不入仓库 +/public/portraits/ +/public/videos/ diff --git a/public/rank-1.svg b/public/rank-1.svg new file mode 100644 index 0000000..78b1f64 --- /dev/null +++ b/public/rank-1.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/rank-2.svg b/public/rank-2.svg new file mode 100644 index 0000000..d82a108 --- /dev/null +++ b/public/rank-2.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/rank-3.svg b/public/rank-3.svg new file mode 100644 index 0000000..9bf5434 --- /dev/null +++ b/public/rank-3.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/src/app/api/me/route.ts b/src/app/api/me/route.ts index f54f84b..1491b83 100644 --- a/src/app/api/me/route.ts +++ b/src/app/api/me/route.ts @@ -21,8 +21,6 @@ export async function GET() { no: string; name: string; enName: string; - slogan: string; - themeColor: string; voteCount: number; currentRank: number | null; }; @@ -53,8 +51,6 @@ export async function GET() { no: true, name: true, enName: true, - slogan: true, - themeColor: true, voteCount: true, currentRank: true, }, diff --git a/src/app/api/ranking/route.ts b/src/app/api/ranking/route.ts index d096d46..3938a52 100644 --- a/src/app/api/ranking/route.ts +++ b/src/app/api/ranking/route.ts @@ -3,7 +3,7 @@ import { ok, ERR } from "@/lib/api-response"; /** * GET /api/ranking - * 返回完整 35 人实时排名(按 voteCount 降序)。 + * 返回完整 36 人实时排名(按 voteCount 降序)。 * * 该接口适合每分钟轮询。生产环境会优先读 Redis 缓存(由后台聚合任务每分钟刷新)。 */ @@ -17,8 +17,6 @@ export async function GET() { no: true, name: true, enName: true, - slogan: true, - themeColor: true, avatar: true, portrait: true, voteCount: true, diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 268ccfb..9a8c628 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -35,7 +35,7 @@ const inter = Inter({ export const metadata: Metadata = { title: "CYBER ✦ STAR · 虚拟偶像 Top12 出道企划", description: - "35 位虚拟偶像候选人,由你投票决出最终出道 Top12。Cyber Star · Virtual Idol Debut Project.", + "36 位虚拟偶像候选人,由你投票决出最终出道 Top12。Cyber Star · Virtual Idol Debut Project.", keywords: ["虚拟偶像", "出道", "投票", "Top12", "Cyber Star", "Virtual Idol"], openGraph: { title: "CYBER ✦ STAR", diff --git a/src/app/me/MeContent.tsx b/src/app/me/MeContent.tsx index 966f0d1..826889b 100644 --- a/src/app/me/MeContent.tsx +++ b/src/app/me/MeContent.tsx @@ -1,18 +1,17 @@ "use client"; -import { useState } from "react"; +import { useMemo } from "react"; import { signOut } from "next-auth/react"; import toast from "react-hot-toast"; import UserHeader from "@/components/me/UserHeader"; import QuotaCard from "@/components/me/QuotaCard"; 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, selectRemaining, DAILY_VOTE_QUOTA, + type MySupport, } from "@/lib/store"; interface MeContentProps { @@ -23,69 +22,23 @@ interface MeContentProps { } export default function MeContent({ session }: MeContentProps) { + // 订阅 store 原始引用(稳定,仅在 set() 时变更),组件内 useMemo 派生 supports, + // 避免 Zustand v5 + useSyncExternalStore 对"selector 返回新引用"报 infinite-loop 错。 const myTotalVotes = useVoteStore((s) => s.myTotalVotes); + const myVotesByArtist = useVoteStore((s) => s.myVotesByArtist); const storeArtists = useVoteStore((s) => s.artists); const remaining = useVoteStore(selectRemaining); - const [signedInToday, setSignedInToday] = useState(MOCK_USER.todaySignedIn); - const [weeklySignIn, setWeeklySignIn] = useState(MOCK_USER.weeklySignIn); - - const user: MockUser = { - ...MOCK_USER, - id: session.id, - nickname: session.nickname, - todaySignedIn: signedInToday, - weeklySignIn, - totalVotes: MOCK_USER.totalVotes + myTotalVotes, - }; - - // 用 store 里最新的艺人排名重算"我的应援"当前排名 - const supports = getFanSupports().map((s) => { - const fresh = storeArtists.find((a) => a.id === s.artist.id); - return fresh ? { ...s, artist: fresh } : s; - }); - - const handleInvite = async () => { - const url = - typeof window !== "undefined" - ? `${window.location.origin}?invite=${session.id}` - : ""; - if (typeof navigator !== "undefined" && navigator.share) { - try { - await navigator.share({ - title: "CYBER STAR · 一起为偶像应援", - text: "邀请你加入虚拟偶像 Top12 出道企划!", - url, - }); - return; - } catch { - return; - } + const supports = useMemo(() => { + const list: MySupport[] = []; + for (const [id, votedCount] of Object.entries(myVotesByArtist)) { + if (votedCount <= 0) continue; + const artist = storeArtists.find((a) => a.id === id); + if (artist) list.push({ artist, votedCount }); } - try { - await navigator.clipboard.writeText(url); - toast.success("邀请链接已复制 · 快去喊朋友一起来"); - } catch { - toast.error("复制失败,请手动复制地址"); - } - }; - - const handleSignIn = () => { - if (signedInToday) { - toast("今日已签到", { icon: "✓" }); - return; - } - const idx = weeklySignIn.findIndex((v) => !v); - if (idx === -1) { - toast("本周已全部签到"); - return; - } - const next = [...weeklySignIn]; - next[idx] = true; - setWeeklySignIn(next); - setSignedInToday(true); - toast.success("签到成功"); - }; + list.sort((a, b) => b.votedCount - a.votedCount); + return list; + }, [myVotesByArtist, storeArtists]); const handleLogout = () => { toast("正在退出登录…"); @@ -94,26 +47,15 @@ export default function MeContent({ session }: MeContentProps) { return (
- - - - + -
- - -
+
diff --git a/src/app/page.tsx b/src/app/page.tsx index 48b4df1..db875bc 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -7,7 +7,7 @@ import Top12Bar from "@/components/Top12Bar"; import ArtistCard from "@/components/cards/ArtistCard"; import ArtistFilters, { type TagFilter } from "@/components/ArtistFilters"; import VoteModal from "@/components/VoteModal"; -import { getActivityEndTime, sortArtists } from "@/lib/mock-data"; +import { getActivityEndTime, sortArtists, type SortKey } from "@/lib/mock-data"; import { useVoteStore } from "@/lib/store"; import { useVoteAction } from "@/hooks/useVoteAction"; import { cn } from "@/lib/cn"; @@ -18,6 +18,7 @@ export default function Home() { useVoteAction(); const [tagFilter, setTagFilter] = useState("all"); + const [sortKey, setSortKey] = useState("votes"); const [filterStuck, setFilterStuck] = useState(false); const filterSentinelRef = useRef(null); @@ -28,8 +29,8 @@ export default function Home() { if (tagFilter !== "all") { list = list.filter((a) => a.tags.includes(tagFilter)); } - return sortArtists(list, "votes"); - }, [artists, tagFilter]); + return sortArtists(list, sortKey); + }, [artists, tagFilter, sortKey]); // 仅在首页启用 scroll-snap mandatory:用户下滑就立即切换到下一个 snap 点 // (Hero → Top12 → 候选区)。卸载时还原。 @@ -69,7 +70,7 @@ export default function Home() { scrollMarginTop: "80px", }} > - +
{/* Top12 出道位 · 作为第二个 snap 点:滚动结束后自然落到这里,标题贴近顶部 */} @@ -97,7 +98,7 @@ export default function Home() {

- 35 位候选人 + {artists.length} 位候选人

当前显示{" "} @@ -122,7 +123,12 @@ export default function Home() { style={{ top: "80px" }} >

- +
diff --git a/src/app/ranking/page.tsx b/src/app/ranking/page.tsx index 7e70b41..2a05928 100644 --- a/src/app/ranking/page.tsx +++ b/src/app/ranking/page.tsx @@ -19,22 +19,48 @@ export default function RankingPage() { const live = useRanking({ pollInterval: 30_000 }); + // 数据同步:本地乐观投票 + 服务端最新票数取 max(避免 API 落后覆盖本地新票, + // 也避免本地缺其他用户的票数)。合并后按 votes desc + no asc 重新排序并赋 rank。 const sorted = useMemo(() => { - if (live.data?.list && live.data.list.length > 0) { - return live.data.list.map((row) => { - const base = storeArtists.find((a) => a.id === row.id); - if (!base) return row as unknown as Artist; - return { ...base, votes: row.voteCount, rank: row.rank }; - }); + const apiVotes = new Map(); + if (live.data?.list) { + for (const row of live.data.list) apiVotes.set(row.id, row.voteCount); } - return sortArtists(storeArtists, "votes"); + const merged = storeArtists.map((a) => { + const apiV = apiVotes.get(a.id) ?? 0; + return apiV > a.votes ? { ...a, votes: apiV } : a; + }); + const ranked = sortArtists(merged, "votes"); + // 重新按合并后的排名赋 rank(store 自带的 rank 仅来自本地 vote 后的 rank()) + return ranked.map((a, i) => ({ ...a, rank: i + 1 })); }, [storeArtists, live.data]); - const top3 = sorted.slice(0, 3); - const top4to12 = sorted.slice(3, 12); - const candidates = sorted.slice(12); + // 仅有票的人能"进榜单",0 票不参与排名兜底 + const ranked = sorted.filter((a) => a.votes > 0); + const zeros = sorted.filter((a) => a.votes === 0); + // 只要有 1 人有票就显示领奖台(#2/#3 缺位会自动以"虚位以待"占位); + // 至少 12 人有票才有"出道线 / 复活位"概念 + const podiumReady = ranked.length >= 1; + const debutReady = ranked.length >= 12; - const debutCutoff = sorted[11]?.votes ?? 0; + const top3 = podiumReady ? ranked.slice(0, 3) : []; + + // 出道线上方(top3 之后到出道线之间) + // - Top12 满员:经典 9 位(rank 4-12) + // - 已有领奖台但 Top12 未满:podium 之后的所有有票 + // - 连领奖台都没满:所有有票的人都从这里开始 + const aboveLine = debutReady + ? ranked.slice(3, 12) + : podiumReady + ? ranked.slice(3) + : ranked; + + // 出道线下方 + // - Top12 满员:ranked[12..] + 全部 0 票 + // - 否则:仅 0 票 + const belowLine = debutReady ? [...ranked.slice(12), ...zeros] : zeros; + + const debutCutoff = debutReady ? ranked[11].votes : 0; return ( <> @@ -59,8 +85,13 @@ export default function RankingPage() {
- {top4to12.map((a, idx) => { - const prev = idx === 0 ? top3[2] : top4to12[idx - 1]; + {aboveLine.map((a, idx) => { + const prev = + idx === 0 + ? podiumReady + ? top3[2] + : undefined + : aboveLine[idx - 1]; const gap = prev ? prev.votes - a.votes : undefined; return ( - + {/* 仅 Top12 满员才显示"出道线"分隔 */} + {debutReady && }
- {candidates.map((a, idx) => { + {belowLine.map((a, idx) => { const prev = - idx === 0 ? top4to12[top4to12.length - 1] : candidates[idx - 1]; + idx === 0 ? aboveLine[aboveLine.length - 1] : belowLine[idx - 1]; const gap = prev ? prev.votes - a.votes : undefined; - const isRescue = idx === 0; + // 仅 Top12 满员时第一位才是"复活位" + const isRescue = debutReady && idx === 0; const gapToDebut = isRescue ? debutCutoff - a.votes + 1 : undefined; return ( void; + sort: SortKey; + onSortChange: (sort: SortKey) => void; } const TAG_OPTIONS: { key: TagFilter; label: string }[] = [ { key: "all", label: "全部" }, - { key: "dance", label: "舞蹈担当" }, - { key: "vocal", label: "声乐担当" }, - { key: "rap", label: "rap担当" }, - { key: "all-rounder", label: "全能型" }, + { key: "rock", label: "摇滚" }, + { key: "pop", label: "流行" }, + { key: "chinese", label: "国风" }, + { key: "hiphop", label: "嘻哈说唱" }, + { key: "folk", label: "民谣治愈" }, + { key: "jazz", label: "爵士" }, +]; + +const SORT_OPTIONS: { key: SortKey; label: string }[] = [ + { key: "votes", label: "实时排名" }, + { key: "no", label: "编号顺序" }, ]; export default function ArtistFilters({ tagFilter, onTagChange, + sort, + onSortChange, }: ArtistFiltersProps) { return ( -
- {TAG_OPTIONS.map((opt) => ( - onTagChange(opt.key)} - > - {opt.label} - - ))} +
+ {/* 左:标签筛选 */} +
+ {TAG_OPTIONS.map((opt) => ( + onTagChange(opt.key)} + > + {opt.label} + + ))} +
+ + {/* 右:排序切换(segmented control 胶囊) */} +
+ {SORT_OPTIONS.map((opt) => { + const active = sort === opt.key; + return ( + + ); + })} +
); } diff --git a/src/components/SearchModal.tsx b/src/components/SearchModal.tsx index 302fbe0..0fd86fd 100644 --- a/src/components/SearchModal.tsx +++ b/src/components/SearchModal.tsx @@ -6,8 +6,13 @@ import { AnimatePresence, motion } from "framer-motion"; import { Search, X } from "lucide-react"; import Link from "next/link"; import { useRouter } from "next/navigation"; -import { ARTISTS } from "@/lib/mock-data"; +import { useVoteStore } from "@/lib/store"; import { TAG_LABEL, type Artist } from "@/types/artist"; + +function formatVotes(v: number): string { + if (v >= 10_000) return `${(v / 10_000).toFixed(1)}w`; + return v.toLocaleString(); +} import ArtistPortrait from "./cards/ArtistPortrait"; import { cn } from "@/lib/cn"; @@ -40,21 +45,30 @@ export default function SearchModal({ open, onClose }: SearchModalProps) { }; }, [open]); - // 过滤艺人:名字 / 编号 / slogan / 标签 + // 订阅 store 拿活的票数 / rank(投票后立即反映) + const storeArtists = useVoteStore((s) => s.artists); + + // 过滤艺人:名字 / 编号 / 座右铭 / 标签。默认显示真实 Top12(有票才上榜) const results = useMemo(() => { const q = query.trim().toLowerCase(); - if (!q) return ARTISTS.slice(0, 12); // 默认显示前 12 名 - return ARTISTS.filter((a) => { - const tagText = a.tags.map((t) => TAG_LABEL[t]).join(""); - return ( - a.name.toLowerCase().includes(q) || - a.enName.toLowerCase().includes(q) || - a.no.includes(q) || - a.slogan.toLowerCase().includes(q) || - tagText.includes(q) - ); - }).slice(0, 20); - }, [query]); + if (!q) { + const top12 = storeArtists.filter((a) => a.votes > 0).slice(0, 12); + // 还没产生 Top12 时退回到默认 12 位(按编号),保持空态可浏览 + return top12.length > 0 ? top12 : storeArtists.slice(0, 12); + } + return storeArtists + .filter((a) => { + const tagText = a.tags.map((t) => TAG_LABEL[t]).join(""); + return ( + a.name.toLowerCase().includes(q) || + a.enName.toLowerCase().includes(q) || + a.no.includes(q) || + (a.motto?.toLowerCase().includes(q) ?? false) || + tagText.includes(q) + ); + }) + .slice(0, 20); + }, [query, storeArtists]); // 键盘导航 useEffect(() => { @@ -162,7 +176,9 @@ export default function SearchModal({ open, onClose }: SearchModalProps) { {!query && (

- 热门艺人 · TOP 12 + {results.some((a) => a.votes > 0) + ? "热门艺人 · TOP 12" + : "推荐艺人 · 编号顺序"}

)} @@ -205,7 +221,7 @@ function ResultRow({ onHover: () => void; onClick: () => void; }) { - const inTop12 = artist.rank <= 12; + const inTop12 = artist.rank <= 12 && artist.votes > 0; return (
-
- {artist.slogan} -
+ {artist.motto && ( +
+ “{artist.motto}” +
+ )}
@@ -252,7 +270,7 @@ function ResultRow({ #{artist.rank}
- {(artist.votes / 10000).toFixed(1)}w 票 + {formatVotes(artist.votes)} 票
diff --git a/src/components/Top12Bar.tsx b/src/components/Top12Bar.tsx index f38b067..f6175f3 100644 --- a/src/components/Top12Bar.tsx +++ b/src/components/Top12Bar.tsx @@ -18,7 +18,8 @@ function formatVotes(v: number): string { } export default function Top12Bar({ artists, showHeader = true }: Top12BarProps) { - const top12 = artists.slice(0, 12); + // Top12 出道位 只看「真正有票」的人 —— 0 票时不靠编号兜底占位 + const top12 = artists.filter((a) => a.votes > 0).slice(0, 12); return (
{showHeader && ( @@ -37,16 +38,31 @@ export default function Top12Bar({ artists, showHeader = true }: Top12BarProps)
)} - {/* 12 张胶囊卡片 · grid 等分铺满,无滚动 · 无外边框无背景 */} -
- {top12.map((artist) => ( - - ))} -
+ {top12.length === 0 ? ( + + ) : ( + // 12 张胶囊卡片 · grid 等分铺满,无滚动 · 无外边框无背景 +
+ {top12.map((artist) => ( + + ))} +
+ )} + + ); +} + +function Top12Empty() { + return ( +
+

+ Awaiting Votes +

+

出道位尚未产生 · 等你为 ta 投下第一票

); } diff --git a/src/components/artist/ArtistDetailContent.tsx b/src/components/artist/ArtistDetailContent.tsx index 7148da4..3c8d8fe 100644 --- a/src/components/artist/ArtistDetailContent.tsx +++ b/src/components/artist/ArtistDetailContent.tsx @@ -1,8 +1,18 @@ "use client"; import Link from "next/link"; -import { ChevronLeft, Heart, Share2 } from "lucide-react"; -import toast from "react-hot-toast"; +import { + ChevronLeft, + Heart, + 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"; @@ -14,17 +24,27 @@ import PerformanceGallery from "./PerformanceGallery"; import FloatingVoteButton from "@/components/FloatingVoteButton"; import { useVoteStore, selectArtist } 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 数据覆盖(这样投票后票数能马上变) + // 用 store 数据覆盖(投票后票数能马上变) const storeArtist = useVoteStore(selectArtist(initialArtist.id)); const storeAll = useVoteStore((s) => s.artists); @@ -34,39 +54,9 @@ export default function ArtistDetailContent({ const { target, remaining, dailyQuota, openVote, closeVote, confirmVote } = useVoteAction(); - const handleShare = async () => { - const url = - typeof window !== "undefined" - ? `${window.location.origin}/artist/${artist.id}` - : `/artist/${artist.id}`; - const shareData = { - title: `${artist.name} · CYBER STAR`, - text: `为 ${artist.name}(${artist.enName})打 Call!${artist.slogan}`, - url, - }; - - // 优先用 Web Share API(移动端 / Safari 支持) - if (typeof navigator !== "undefined" && navigator.share) { - try { - await navigator.share(shareData); - return; - } catch { - // 用户取消分享:不报错 - return; - } - } - - // 兜底:复制到剪贴板 - try { - await navigator.clipboard.writeText(url); - toast.success("链接已复制,去粘贴给朋友吧~"); - } catch { - toast.error("复制失败,请手动复制地址栏"); - } - }; - return ( <> + {/* 面包屑 */}
/ - 艺人详情 - + {artist.name}
-
-
-
- -
- - - 应援色 - - - {artist.themeColor} - -
-
- -
-
-
- No.{artist.no} -
-

- {artist.name} -

-

- {artist.enName} -

-
- -
- {artist.tags.map((t) => ( - - {TAG_LABEL[t]} - - ))} -
- -
- - - -
- - - -
- - -
-
-
-
- -
- - -

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

-
- -
- - + openVote(artist)} />
-
- -
-

- {artist.bio} -

-
- - - + {/* 性格 · 口头禅 */} + {(artist.personality || artist.catchphrase) && ( +
+
+ {artist.personality && } + {artist.catchphrase && }
-
-
+ + )} + + {/* 核心技能 · 核心赛道 */} + {(artist.skills || artist.track) && ( +
+
+ {artist.skills && ( + } + chips={parseChips(artist.skills)} + /> + )} + {artist.track && ( + } + chips={parseChips(artist.track)} + /> + )} +
+
+ )} + + {/* 人物小传 · 长简介 */} + {artist.bio && ( +
+ +
+ )} + + {/* 表演视频 · 与版心同宽,首帧自动作为封面,整个视频区域可点击播放/暂停 */} + {artist.videoUrl && ( +
+ +
+ +
+

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

+
+ )} + + {/* 表演图片 · 三张氛围图,左对齐,竖向 3:4 */} + {artist.gallery && artist.gallery.filter(Boolean).length > 0 && ( +
+ +
+ +
+
+ )} openVote(artist)} /> @@ -220,24 +157,258 @@ export default function ArtistDetailContent({ ); } -function MetaCell({ label, value }: { label: string; value: string }) { +/* ============================================================ + * 子组件 · 统一品牌紫色,无 per-artist themeColor + * ============================================================ */ + +interface HeroPanelProps { + artist: Artist; + allArtists: Artist[]; + onVote: () => void; +} + +function HeroPanel({ artist, allArtists, onVote }: HeroPanelProps) { return ( -
-
- {label} +
+ {/* 装饰光晕 */} +
+ + {/* 立绘 */} +
+ +
+ + {/* 身份信息 */} +
+ {/* 编号 */} +
+ No.{artist.no} +
+ + {/* 中文名 / 英文名 */} +
+

+ {artist.name} +

+

+ {artist.enName} +

+
+ + {/* 实力标签 */} +
+ {artist.tags.map((t) => ( + + {TAG_LABEL[t]} + + ))} +
+ + {/* 年龄 / 身高 / 性别 */} +
+ } + label="年龄" + value={artist.age != null ? `${artist.age} 岁` : "未公开"} + /> + } + label="身高" + value={`${artist.height} cm`} + /> + } + label="性别" + value={ + artist.gender === "M" + ? "男生" + : artist.gender === "F" + ? "女生" + : "未公开" + } + /> +
+ + {/* 座右铭 · 品牌紫引文,保持与全站视觉一致 */} + {artist.motto && ( +
+ +

+ {artist.motto} +

+

+ Motto · 座右铭 +

+
+ )} + + {/* 排名卡片 */} + + + {/* 操作按钮 · 仅投票 */} +
+ +
-
{value}
); } -function BioMeta({ label, value }: { label: string; value: string }) { +function MetaCell({ + icon, + label, + value, +}: { + icon: React.ReactNode; + label: string; + value: string; +}) { return ( -
- - {label} - - {value} +
+
+ {icon} + + {label} + +
+
{value}
+
+ ); +} + +/** + * 性格 / 口头禅 双卡使用完全相同的容器规范: + * - 圆角 2xl + 玻璃化 surface 背景 + 同样的 border / padding + * - 左上角同样的紫色装饰条 + * - 同款 SectionHeading + * 唯一差异:内容呈现 —— 性格是段落正文,口头禅是大字引号。 + */ +function ProfileInfoCard({ + title, + subtitle, + children, +}: { + title: string; + subtitle: string; + children: React.ReactNode; +}) { + return ( +
+
+ +
{children}
+
+ ); +} + +function PersonalityCard({ text }: { text: string }) { + return ( + +

+ {text} +

+
+ ); +} + +function CatchphraseCard({ text }: { text: string }) { + return ( + +
+ +

+ “{text}” +

+
+
+ ); +} + +function ChipCard({ + label, + subtitle, + icon, + chips, +}: { + label: string; + subtitle: string; + icon: React.ReactNode; + chips: string[]; +}) { + return ( +
+
+ + {icon} +
+ {chips.length > 0 ? ( +
+ {chips.map((c, i) => ( + + {c} + + ))} +
+ ) : ( +

未公开

+ )} +
+ ); +} + +function BiographyCard({ bio }: { bio: string }) { + return ( +
+
+ + + + +
+

+ {bio} +

); } @@ -250,8 +421,11 @@ function SectionHeading({ subtitle: string; }) { return ( -
- +
+

{title}

diff --git a/src/components/artist/PerformanceGallery.tsx b/src/components/artist/PerformanceGallery.tsx index 29bd38e..5f0de4c 100644 --- a/src/components/artist/PerformanceGallery.tsx +++ b/src/components/artist/PerformanceGallery.tsx @@ -5,23 +5,14 @@ import { createPortal } from "react-dom"; import { X, ChevronLeft, ChevronRight } from "lucide-react"; import { AnimatePresence, motion } from "framer-motion"; import Image from "next/image"; -import { cn } from "@/lib/cn"; interface PerformanceGalleryProps { images: string[]; - /** 当无真实图时,用此颜色生成占位 */ - themeColor?: string; - /** 占位标签(如 "定妆照"、"表演中") */ - placeholderLabels?: string[]; } -const DEFAULT_LABELS = ["定妆照", "表演中", "幕后花絮", "舞台 1", "舞台 2", "未公开"]; - -export default function PerformanceGallery({ - images, - themeColor = "#8b5cf6", - placeholderLabels = DEFAULT_LABELS, -}: PerformanceGalleryProps) { +export default function PerformanceGallery({ images }: PerformanceGalleryProps) { + // 过滤掉空字符串,只渲染真实路径 + images = images.filter(Boolean); const [lightboxIndex, setLightboxIndex] = useState(null); const [mounted, setMounted] = useState(false); @@ -46,39 +37,31 @@ export default function PerformanceGallery({ }; }, [lightboxIndex, images.length]); + if (images.length === 0) { + return ( +
+ 暂无表演图片 +
+ ); + } + return ( <> -
+
{images.map((src, i) => ( ))}
@@ -153,35 +136,13 @@ export default function PerformanceGallery({ transition={{ duration: 0.25 }} className="relative max-w-5xl w-full mx-6 max-h-[85vh] aspect-[4/3] z-10" > - {images[lightboxIndex] ? ( - {`表演图 - ) : ( -
-
- - ✦ - -

- {placeholderLabels[lightboxIndex] || - `Image ${lightboxIndex + 1}`} -

-
-
- )} + {`表演图 {/* 索引 */} diff --git a/src/components/artist/PerformanceVideo.tsx b/src/components/artist/PerformanceVideo.tsx index 1623a98..ed33e7c 100644 --- a/src/components/artist/PerformanceVideo.tsx +++ b/src/components/artist/PerformanceVideo.tsx @@ -1,27 +1,66 @@ "use client"; -import { useRef, useState } from "react"; +import { + MouseEvent as ReactMouseEvent, + useEffect, + useRef, + useState, +} from "react"; import { Play, Pause, Volume2, VolumeX, Maximize2 } from "lucide-react"; import { cn } from "@/lib/cn"; interface PerformanceVideoProps { src?: string; poster?: string; - duration?: string; - themeColor?: string; className?: string; } +function fmtTime(s: number): string { + if (!isFinite(s) || s < 0) return "00:00"; + const m = Math.floor(s / 60); + const sec = Math.floor(s % 60); + return `${String(m).padStart(2, "0")}:${String(sec).padStart(2, "0")}`; +} + export default function PerformanceVideo({ src, poster, - duration = "00:15", - themeColor = "#8b5cf6", className, }: PerformanceVideoProps) { const videoRef = useRef(null); + const progressRef = useRef(null); const [playing, setPlaying] = useState(false); const [muted, setMuted] = useState(false); + const [current, setCurrent] = useState(0); + const [duration, setDuration] = useState(0); + const [seeking, setSeeking] = useState(false); + + // 视频时间事件 + 首帧封面(loadedmetadata 后 seek 0.001s 让浏览器渲染首帧) + useEffect(() => { + const v = videoRef.current; + if (!v) return; + const onTime = () => !seeking && setCurrent(v.currentTime); + const onMeta = () => { + setDuration(v.duration); + // 没有 poster 时强制渲染首帧 + if (!poster && v.currentTime === 0) { + try { + v.currentTime = 0.001; + } catch { + /* noop */ + } + } + }; + const onEnd = () => setPlaying(false); + v.addEventListener("timeupdate", onTime); + v.addEventListener("loadedmetadata", onMeta); + v.addEventListener("ended", onEnd); + return () => { + v.removeEventListener("timeupdate", onTime); + v.removeEventListener("loadedmetadata", onMeta); + v.removeEventListener("ended", onEnd); + }; + }, [seeking, poster]); const togglePlay = () => { const v = videoRef.current; @@ -46,101 +85,165 @@ export default function PerformanceVideo({ videoRef.current?.requestFullscreen?.(); }; + // 进度条:点击 / 拖拽 seek + const seekTo = (clientX: number) => { + const bar = progressRef.current; + const v = videoRef.current; + if (!bar || !v || !duration) return; + const rect = bar.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + const t = ratio * duration; + v.currentTime = t; + setCurrent(t); + }; + + const handleBarMouseDown = (e: ReactMouseEvent) => { + e.preventDefault(); + e.stopPropagation(); + setSeeking(true); + seekTo(e.clientX); + const onMove = (ev: MouseEvent) => seekTo(ev.clientX); + const onUp = () => { + setSeeking(false); + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + }; + + const handleBarTouchStart = (e: React.TouchEvent) => { + e.stopPropagation(); + setSeeking(true); + const t = e.touches[0]; + if (t) seekTo(t.clientX); + const onMove = (ev: TouchEvent) => { + const tt = ev.touches[0]; + if (tt) seekTo(tt.clientX); + }; + const onEnd = () => { + setSeeking(false); + window.removeEventListener("touchmove", onMove); + window.removeEventListener("touchend", onEnd); + }; + window.addEventListener("touchmove", onMove, { passive: true }); + window.addEventListener("touchend", onEnd); + }; + + const progress = duration > 0 ? (current / duration) * 100 : 0; + return (
{src ? (