feat(me): /me user center with quota card, sign-in calendar, stats and fan support
This commit is contained in:
parent
e7166ecf81
commit
4f87a7d36b
64
src/app/me/page.tsx
Normal file
64
src/app/me/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
src/components/me/MyFanSupport.tsx
Normal file
63
src/components/me/MyFanSupport.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
src/components/me/QuotaCard.tsx
Normal file
67
src/components/me/QuotaCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
74
src/components/me/SignInCalendar.tsx
Normal file
74
src/components/me/SignInCalendar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
src/components/me/StatsGrid.tsx
Normal file
52
src/components/me/StatsGrid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
45
src/components/me/UserHeader.tsx
Normal file
45
src/components/me/UserHeader.tsx
Normal 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
59
src/lib/mock-user.ts
Normal 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);
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user