feat(me): /me user center with quota card, sign-in calendar, stats and fan support

This commit is contained in:
iye 2026-05-12 09:45:47 +08:00
parent e7166ecf81
commit 4f87a7d36b
7 changed files with 424 additions and 0 deletions

64
src/app/me/page.tsx Normal file
View File

@ -0,0 +1,64 @@
"use client";
import { Gift, Star } from "lucide-react";
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 } from "@/lib/mock-user";
export default function MePage() {
const user = MOCK_USER;
const supports = getFanSupports();
const handleInvite = () => {
// TODO: 邀请好友逻辑(生成专属海报 / 复制链接)
console.log("Invite friends");
};
const handleSignIn = () => {
// TODO: 调用签到 API
console.log("Sign in");
};
return (
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10 space-y-8">
<UserHeader user={user} />
<QuotaCard
remaining={user.remainingVotes}
daily={user.dailyQuota}
onInvite={handleInvite}
/>
<StatsGrid user={user} />
{/* 签到 */}
<section>
<div className="flex items-center gap-2 mb-3">
<Gift size={14} className="text-purple-300" />
<h2 className="font-display text-sm tracking-[0.25em] text-white uppercase">
</h2>
</div>
<SignInCalendar
weekly={user.weeklySignIn}
todaySigned={user.todaySignedIn}
onSignIn={handleSignIn}
/>
</section>
{/* 我的应援 */}
<section>
<div className="flex items-center gap-2 mb-3">
<Star size={14} className="text-purple-300" />
<h2 className="font-display text-sm tracking-[0.25em] text-white uppercase">
</h2>
</div>
<MyFanSupport supports={supports} />
</section>
</div>
);
}

View File

@ -0,0 +1,63 @@
import Link from "next/link";
import { AlertTriangle } from "lucide-react";
import type { FanSupport } from "@/lib/mock-user";
import ArtistPortrait from "@/components/cards/ArtistPortrait";
import { cn } from "@/lib/cn";
export default function MyFanSupport({ supports }: { supports: FanSupport[] }) {
if (supports.length === 0) {
return (
<div className="rounded-xl border border-dashed border-white/10 p-8 text-center text-white/45 text-sm">
·{" "}
<Link href="/" className="text-purple-300 hover:underline">
</Link>
</div>
);
}
return (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{supports.map(({ artist, votedCount }) => {
const inTop12 = artist.rank <= 12;
return (
<Link
key={artist.id}
href={`/artist/${artist.id}`}
className={cn(
"flex items-center gap-3 p-3 rounded-xl border transition-all",
inTop12
? "bg-purple-500/[0.05] border-purple-500/30 hover:bg-purple-500/[0.08]"
: "bg-pink-500/[0.04] border-pink-500/25 hover:bg-pink-500/[0.06]",
)}
>
<div className="w-12 h-12 rounded-full overflow-hidden border-2 border-white/15 flex-shrink-0">
<ArtistPortrait
artist={artist}
rounded="rounded-full"
className="w-full h-full"
/>
</div>
<div className="flex-1 min-w-0">
<div className="text-sm font-semibold text-white truncate">
{artist.name}
</div>
<div className="text-[11px] text-purple-300 mt-0.5 font-display tracking-wider tabular-nums">
{votedCount}
</div>
<div
className={cn(
"text-[11px] mt-0.5 inline-flex items-center gap-1",
inTop12 ? "text-white/50" : "text-pink-400",
)}
>
#{artist.rank}
{!inTop12 && <AlertTriangle size={10} />}
</div>
</div>
</Link>
);
})}
</div>
);
}

View File

@ -0,0 +1,67 @@
"use client";
import { Gift, Users } from "lucide-react";
interface QuotaCardProps {
remaining: number;
daily: number;
onInvite?: () => void;
}
export default function QuotaCard({
remaining,
daily,
onInvite,
}: QuotaCardProps) {
return (
<div
className="relative overflow-hidden rounded-2xl p-5 sm:p-6 bg-grad-purple shadow-purple-glow"
>
{/* 装饰星点 */}
<span className="absolute top-3 right-3 text-white/30 text-base"></span>
<span className="absolute bottom-4 right-12 text-white/15 text-xs">
</span>
{/* 高光 */}
<div
aria-hidden
className="absolute inset-0 pointer-events-none"
style={{
background:
"radial-gradient(circle at 80% 10%, rgba(255,255,255,0.2) 0%, transparent 60%)",
}}
/>
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div>
<div className="flex items-center gap-1.5 text-white/85 text-xs font-label tracking-widest uppercase">
<Gift size={12} />
</div>
<div className="font-display text-5xl sm:text-6xl text-white tabular-nums leading-none mt-2 tracking-wider">
{remaining}{" "}
<span className="text-2xl font-body opacity-85"></span>
</div>
<div className="text-[11px] text-white/75 mt-2 tracking-wide">
00:00 {daily}
</div>
</div>
<div className="flex flex-col items-start sm:items-end gap-2">
<span className="font-label text-[10px] tracking-widest uppercase text-white/80">
</span>
<button
type="button"
onClick={onInvite}
className="inline-flex items-center gap-2 px-4 sm:px-5 h-10 rounded-full bg-white text-purple-700 font-display text-xs tracking-widest uppercase hover:bg-white/95 transition-colors"
>
<Users size={14} />
</button>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,74 @@
"use client";
import { Check, Gift } from "lucide-react";
import { cn } from "@/lib/cn";
interface SignInCalendarProps {
weekly: boolean[];
todaySigned: boolean;
/** 今天是周几0 周日 ~ 6 周六),用 1~7 对应周一~周日 */
todayIndex?: number;
onSignIn?: () => void;
}
const WEEK_LABELS = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"];
export default function SignInCalendar({
weekly,
todaySigned,
todayIndex,
onSignIn,
}: SignInCalendarProps) {
// 默认今天 = 数组中第一个未签到(或最后一个 true 之后)
const computedToday =
todayIndex ??
(weekly.indexOf(false) === -1 ? weekly.length - 1 : weekly.indexOf(false));
return (
<div>
<div className="grid grid-cols-7 gap-2">
{WEEK_LABELS.map((label, i) => {
const signed = weekly[i];
const isToday = i === computedToday;
return (
<button
type="button"
key={label}
disabled={signed && !isToday}
onClick={isToday && !todaySigned ? onSignIn : undefined}
className={cn(
"rounded-lg p-2.5 text-center transition-all border",
signed && !isToday
? "bg-purple-500/8 border-purple-500/20 text-purple-300"
: isToday && !todaySigned
? "bg-grad-purple text-white border-transparent shadow-purple-glow animate-pulse-glow cursor-pointer hover:brightness-110"
: isToday && todaySigned
? "bg-purple-500/15 border-purple-500/50 text-purple-200"
: "bg-surface/40 border-white/10 text-white/35",
)}
>
<div className="font-label text-[10px] tracking-wider text-current opacity-80">
{label}
</div>
<div className="mt-1.5 flex items-center justify-center h-4">
{signed ? (
<Check size={14} strokeWidth={3} />
) : isToday ? (
<span className="inline-flex items-center gap-0.5 text-[10px] font-display">
<Gift size={11} /> +3
</span>
) : (
<span className="w-1 h-1 rounded-full bg-current opacity-30" />
)}
</div>
</button>
);
})}
</div>
<p className="mt-3 text-[11px] text-white/40">
7 ·
</p>
</div>
);
}

View File

@ -0,0 +1,52 @@
import { Heart, Star, Calendar, UserPlus } from "lucide-react";
import type { MockUser } from "@/lib/mock-user";
const ICON_MAP = {
votes: <Heart size={14} />,
fan: <Star size={14} />,
signin: <Calendar size={14} />,
invite: <UserPlus size={14} />,
};
export default function StatsGrid({ user }: { user: MockUser }) {
const stats = [
{ key: "votes", label: "累计投票", value: user.totalVotes, icon: ICON_MAP.votes },
{
key: "fan",
label: "应援艺人",
value: user.supportingIds.length,
icon: ICON_MAP.fan,
},
{
key: "signin",
label: "签到天数",
value: user.signInStreak,
icon: ICON_MAP.signin,
},
{
key: "invite",
label: "邀请好友",
value: user.invitedCount,
icon: ICON_MAP.invite,
},
];
return (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-2 sm:gap-3">
{stats.map((s) => (
<div
key={s.key}
className="bg-surface/60 border border-white/[0.08] rounded-xl px-3 py-3 sm:py-4 text-center"
>
<div className="font-display text-2xl sm:text-3xl text-purple-300 tabular-nums leading-none">
{s.value}
</div>
<div className="mt-1.5 text-[11px] text-white/55 inline-flex items-center gap-1">
<span className="text-purple-300/70">{s.icon}</span>
{s.label}
</div>
</div>
))}
</div>
);
}

View File

@ -0,0 +1,45 @@
import { Pencil } from "lucide-react";
import type { MockUser } from "@/lib/mock-user";
export default function UserHeader({ user }: { user: MockUser }) {
const initial = user.nickname.charAt(0).toUpperCase();
return (
<div className="flex items-center gap-4 pb-6 border-b border-white/[0.08]">
{/* 头像 */}
<div className="relative w-16 h-16 sm:w-20 sm:h-20 rounded-full overflow-hidden flex-shrink-0 border-2 border-purple-500/50 shadow-[0_0_16px_rgba(139,92,246,0.4)]">
<div
className="absolute inset-0 flex items-center justify-center font-logo text-2xl text-white"
style={{
background:
"linear-gradient(135deg, #6d28d9 0%, #8b5cf6 60%, #a78bfa 100%)",
}}
>
{initial}
</div>
</div>
<div className="flex-1 min-w-0">
<div className="text-lg sm:text-xl font-bold text-white truncate">
@{user.nickname}
</div>
<div className="text-xs text-white/45 mt-1">
ID: {user.id}
<span className="mx-2 text-white/20">|</span>
{" "}
<span className="text-purple-300 font-display">
{user.signInStreak}
</span>{" "}
</div>
</div>
<button
type="button"
className="hidden sm:inline-flex items-center gap-1.5 px-3 h-9 rounded-full bg-white/[0.05] border border-white/15 text-white/70 hover:bg-white/10 text-xs"
>
<Pencil size={12} />
</button>
</div>
);
}

59
src/lib/mock-user.ts Normal file
View File

@ -0,0 +1,59 @@
import { ARTISTS } from "./mock-data";
import type { Artist } from "@/types/artist";
export interface MockUser {
id: string;
nickname: string;
/** 头像 URL无则使用首字母占位 */
avatar: string;
signInStreak: number;
/** 今日签到状态:每天 true/false按周一开始的一周 7 天) */
weeklySignIn: boolean[];
/** 今日是否已签到 */
todaySignedIn: boolean;
/** 今日剩余票数 */
remainingVotes: number;
/** 今日已用票数 */
usedVotes: number;
/** 每日基础票数 */
dailyQuota: number;
/** 累计投票数 */
totalVotes: number;
/** 应援的艺人 ID 列表 */
supportingIds: string[];
/** 邀请好友数 */
invitedCount: number;
}
export interface FanSupport {
artist: Artist;
votedCount: number;
}
export const MOCK_USER: MockUser = {
id: "12345678",
nickname: "粉丝昵称",
avatar: "",
signInStreak: 7,
weeklySignIn: [true, true, true, true, true, true, false],
todaySignedIn: false,
remainingVotes: 9,
usedVotes: 3,
dailyQuota: 12,
totalVotes: 87,
supportingIds: ["001", "005", "014"],
invitedCount: 2,
};
export function getFanSupports(): FanSupport[] {
return MOCK_USER.supportingIds
.map((id) => {
const artist = ARTISTS.find((a) => a.id === id);
if (!artist) return null;
return {
artist,
votedCount: Math.floor(Math.random() * 40) + 10,
};
})
.filter((x): x is FanSupport => x !== null);
}