feat(vote): remove all voting limits (no daily quota, no per-artist cap, unlimited votes)

This commit is contained in:
iye 2026-05-12 14:15:50 +08:00
parent 9fe9fa914f
commit bd5a361a18
11 changed files with 67 additions and 242 deletions

View File

@ -4,7 +4,8 @@ import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
/** /**
* GET /api/me * GET /api/me
* *
* "今日剩余票数"
*/ */
export async function GET() { export async function GET() {
try { try {
@ -28,7 +29,7 @@ export async function GET() {
}; };
}; };
const [profile, quota, signIn, supports, config] = (await Promise.all([ const [profile, signIn, supports] = (await Promise.all([
prisma.user.findUnique({ prisma.user.findUnique({
where: { id: user.id }, where: { id: user.id },
select: { select: {
@ -39,9 +40,6 @@ export async function GET() {
createdAt: true, createdAt: true,
}, },
}), }),
prisma.dailyQuota.findUnique({
where: { userId_date: { userId: user.id, date: today } },
}),
prisma.signIn.findFirst({ prisma.signIn.findFirst({
where: { userId: user.id }, where: { userId: user.id },
orderBy: { date: "desc" }, orderBy: { date: "desc" },
@ -64,21 +62,14 @@ export async function GET() {
}, },
orderBy: { votedTotal: "desc" }, orderBy: { votedTotal: "desc" },
}), }),
prisma.activityConfig.findUnique({ where: { id: 1 } }),
])) as [ ])) as [
Awaited<ReturnType<typeof prisma.user.findUnique>>, Awaited<ReturnType<typeof prisma.user.findUnique>>,
Awaited<ReturnType<typeof prisma.dailyQuota.findUnique>>,
Awaited<ReturnType<typeof prisma.signIn.findFirst>>, Awaited<ReturnType<typeof prisma.signIn.findFirst>>,
SupportRow[], SupportRow[],
Awaited<ReturnType<typeof prisma.activityConfig.findUnique>>,
]; ];
if (!profile) return ERR.NOT_FOUND("用户不存在"); if (!profile) return ERR.NOT_FOUND("用户不存在");
const dailyQuota = config?.dailyQuota ?? 12;
const totalQuota = quota?.totalQuota ?? dailyQuota;
const usedQuota = quota?.usedQuota ?? 0;
// 累计投票数 // 累计投票数
const totalVotes = await prisma.vote.aggregate({ const totalVotes = await prisma.vote.aggregate({
where: { userId: user.id }, where: { userId: user.id },
@ -88,11 +79,6 @@ export async function GET() {
return ok( return ok(
sanitizeBigInt({ sanitizeBigInt({
profile, profile,
quota: {
total: totalQuota,
used: usedQuota,
remaining: Math.max(0, totalQuota - usedQuota),
},
signIn: { signIn: {
streak: signIn?.streak ?? 0, streak: signIn?.streak ?? 0,
lastDate: signIn?.date ?? null, lastDate: signIn?.date ?? null,

View File

@ -1,13 +1,11 @@
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { getCurrentUser } from "@/lib/current-user"; import { getCurrentUser } from "@/lib/current-user";
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response"; import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
type TxClient = Prisma.TransactionClient;
/** /**
* POST /api/me/signin * POST /api/me/signin
* · 7 * ·
*
*/ */
export async function POST() { export async function POST() {
try { try {
@ -27,7 +25,6 @@ export async function POST() {
sanitizeBigInt({ sanitizeBigInt({
alreadySigned: true, alreadySigned: true,
streak: existing.streak, streak: existing.streak,
bonusVotes: existing.bonusVotes,
}), }),
); );
} }
@ -37,43 +34,20 @@ export async function POST() {
where: { userId_date: { userId: user.id, date: yesterday } }, where: { userId_date: { userId: user.id, date: yesterday } },
}); });
const streak = yest ? yest.streak + 1 : 1; const streak = yest ? yest.streak + 1 : 1;
const bonusVotes = streak >= 7 ? 3 : streak >= 3 ? 2 : 1;
const config = await prisma.activityConfig.findUnique({ where: { id: 1 } }); const result = await prisma.signIn.create({
const dailyQuota = config?.dailyQuota ?? 12; data: {
userId: user.id,
const result = await prisma.$transaction(async (tx: TxClient) => { date: today,
const signIn = await tx.signIn.create({ streak,
data: { bonusVotes: 0, // 保留字段,但不再发放
userId: user.id, },
date: today,
streak,
bonusVotes,
},
});
// 把奖励票数追加到当日额度
await tx.dailyQuota.upsert({
where: { userId_date: { userId: user.id, date: today } },
create: {
userId: user.id,
date: today,
totalQuota: dailyQuota + bonusVotes,
usedQuota: 0,
},
update: {
totalQuota: { increment: bonusVotes },
},
});
return signIn;
}); });
return ok( return ok(
sanitizeBigInt({ sanitizeBigInt({
alreadySigned: false, alreadySigned: false,
streak: result.streak, streak: result.streak,
bonusVotes: result.bonusVotes,
}), }),
); );
} catch (e) { } catch (e) {

View File

@ -1,5 +1,6 @@
import type { NextRequest } from "next/server"; import type { NextRequest } from "next/server";
import { z } from "zod"; import { z } from "zod";
import { Prisma } from "@prisma/client";
import { prisma } from "@/lib/prisma"; import { prisma } from "@/lib/prisma";
import { rateLimit } from "@/lib/rate-limit"; import { rateLimit } from "@/lib/rate-limit";
import { import {
@ -7,43 +8,41 @@ import {
getClientIp, getClientIp,
getUserAgent, getUserAgent,
} from "@/lib/current-user"; } from "@/lib/current-user";
import { Prisma } from "@prisma/client";
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response"; import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
type TxClient = Prisma.TransactionClient; type TxClient = Prisma.TransactionClient;
const VoteBody = z.object({ const VoteBody = z.object({
artistId: z.string().min(1).max(8), artistId: z.string().min(1).max(8),
count: z.number().int().min(1).max(12), count: z.number().int().min(1).max(99_999),
}); });
/** /**
* POST /api/vote * POST /api/vote
* * · /
* *
* *
* 1. + IP / * 1. + IP /
* 2. * 2.
* 3. + + + * 3. + +
* 4. / * 4.
*/ */
export async function POST(req: NextRequest) { export async function POST(req: NextRequest) {
try { try {
const user = await getCurrentUser(); const user = await getCurrentUser();
if (!user) return ERR.UNAUTHORIZED(); if (!user) return ERR.UNAUTHORIZED();
// 限流:单用户 1 秒最多 5 次投票请求 // 限流:单用户 1 秒最多 5 次(仅做防刷,非数量限制)
const userRl = await rateLimit(`vote:user:${user.id}`, 1, 5); const userRl = await rateLimit(`vote:user:${user.id}`, 1, 5);
if (!userRl.allowed) return ERR.RATE_LIMITED(); if (!userRl.allowed) return ERR.RATE_LIMITED();
// 限流:单 IP 60 秒最多 60 次(更宽松) // 限流:单 IP 60 秒最多 60 次
const ip = await getClientIp(); const ip = await getClientIp();
if (ip) { if (ip) {
const ipRl = await rateLimit(`vote:ip:${ip}`, 60, 60); const ipRl = await rateLimit(`vote:ip:${ip}`, 60, 60);
if (!ipRl.allowed) return ERR.RATE_LIMITED(); if (!ipRl.allowed) return ERR.RATE_LIMITED();
} }
// 校验请求体
const raw = await req.json(); const raw = await req.json();
const parsed = VoteBody.safeParse(raw); const parsed = VoteBody.safeParse(raw);
if (!parsed.success) { if (!parsed.success) {
@ -58,41 +57,9 @@ export async function POST(req: NextRequest) {
if (now < config.startAt || now > config.endAt) return ERR.ACTIVITY_OFF(); if (now < config.startAt || now > config.endAt) return ERR.ACTIVITY_OFF();
const ua = await getUserAgent(); const ua = await getUserAgent();
const today = startOfDay();
// 事务
const result = await prisma.$transaction(async (tx: TxClient) => { const result = await prisma.$transaction(async (tx: TxClient) => {
// 1. 当日额度 // 1. 写入投票记录
const quota = await tx.dailyQuota.upsert({
where: { userId_date: { userId: user.id, date: today } },
create: {
userId: user.id,
date: today,
totalQuota: config.dailyQuota,
usedQuota: 0,
},
update: {},
});
const remaining = quota.totalQuota - quota.usedQuota;
if (remaining < count) {
throw new VoteBizError("QUOTA_EXHAUSTED", remaining);
}
// 2. 单艺人每日上限
const todayUsedForArtist = await tx.vote.aggregate({
where: {
userId: user.id,
artistId,
createdAt: { gte: today },
},
_sum: { count: true },
});
const usedForArtist = todayUsedForArtist._sum.count ?? 0;
if (usedForArtist + count > config.perArtistLimit) {
throw new VoteBizError("ARTIST_LIMIT", config.perArtistLimit);
}
// 3. 写入投票
const vote = await tx.vote.create({ const vote = await tx.vote.create({
data: { data: {
userId: user.id, userId: user.id,
@ -104,56 +71,32 @@ export async function POST(req: NextRequest) {
}, },
}); });
// 4. 扣减额度 + 累加艺人票数 // 2. 累加艺人票数
await tx.dailyQuota.update({
where: { userId_date: { userId: user.id, date: today } },
data: { usedQuota: { increment: count } },
});
const artist = await tx.artist.update({ const artist = await tx.artist.update({
where: { id: artistId }, where: { id: artistId },
data: { voteCount: { increment: count } }, data: { voteCount: { increment: count } },
select: { id: true, voteCount: true, name: true }, select: { id: true, voteCount: true, name: true },
}); });
// 5. 更新 / 创建应援关系 // 3. 更新 / 创建应援关系
await tx.fanSupport.upsert({ await tx.fanSupport.upsert({
where: { userId_artistId: { userId: user.id, artistId } }, where: { userId_artistId: { userId: user.id, artistId } },
create: { userId: user.id, artistId, votedTotal: count }, create: { userId: user.id, artistId, votedTotal: count },
update: { votedTotal: { increment: count } }, update: { votedTotal: { increment: count } },
}); });
return { vote, artist, remaining: remaining - count }; return { vote, artist };
}); });
return ok( return ok(
sanitizeBigInt({ sanitizeBigInt({
artistId: result.artist.id, artistId: result.artist.id,
artistVotes: result.artist.voteCount, artistVotes: result.artist.voteCount,
remainingQuota: result.remaining,
voteId: result.vote.id, voteId: result.vote.id,
}), }),
); );
} catch (e) { } catch (e) {
if (e instanceof VoteBizError) {
if (e.code === "QUOTA_EXHAUSTED") return ERR.QUOTA_EXHAUSTED();
if (e.code === "ARTIST_LIMIT") return ERR.ARTIST_LIMIT(e.detail as number);
}
console.error("[POST /api/vote]", e); console.error("[POST /api/vote]", e);
return ERR.INTERNAL(); return ERR.INTERNAL();
} }
} }
class VoteBizError extends Error {
constructor(
public code: "QUOTA_EXHAUSTED" | "ARTIST_LIMIT",
public detail?: unknown,
) {
super(code);
}
}
function startOfDay(d = new Date()): Date {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
}

View File

@ -20,9 +20,7 @@ interface MeContentProps {
} }
export default function MeContent({ session }: MeContentProps) { export default function MeContent({ session }: MeContentProps) {
const remaining = useVoteStore((s) => s.remainingVotes); const myTotalVotes = useVoteStore((s) => s.myTotalVotes);
const used = useVoteStore((s) => s.usedVotes);
const dailyQuota = useVoteStore((s) => s.dailyQuota);
const storeArtists = useVoteStore((s) => s.artists); const storeArtists = useVoteStore((s) => s.artists);
// 本地签到状态(数据库就绪后由 /api/me/signin 提供) // 本地签到状态(数据库就绪后由 /api/me/signin 提供)
@ -33,12 +31,9 @@ export default function MeContent({ session }: MeContentProps) {
...MOCK_USER, ...MOCK_USER,
id: session.id, id: session.id,
nickname: session.nickname, nickname: session.nickname,
remainingVotes: remaining,
usedVotes: used,
dailyQuota,
todaySignedIn: signedInToday, todaySignedIn: signedInToday,
weeklySignIn, weeklySignIn,
totalVotes: MOCK_USER.totalVotes + used, totalVotes: MOCK_USER.totalVotes + myTotalVotes,
}; };
// 用 store 里最新的艺人排名重算 "我的应援" 当前排名 // 用 store 里最新的艺人排名重算 "我的应援" 当前排名
@ -56,7 +51,7 @@ export default function MeContent({ session }: MeContentProps) {
try { try {
await navigator.share({ await navigator.share({
title: "CYBER STAR · 一起为偶像应援", title: "CYBER STAR · 一起为偶像应援",
text: "邀请你加入虚拟偶像 Top12 出道企划,双方各得 +5 票", text: "邀请你加入虚拟偶像 Top12 出道企划",
url, url,
}); });
return; return;
@ -66,7 +61,7 @@ export default function MeContent({ session }: MeContentProps) {
} }
try { try {
await navigator.clipboard.writeText(url); await navigator.clipboard.writeText(url);
toast.success("邀请链接已复制 · 朋友注册后双方各 +5 票"); toast.success("邀请链接已复制 · 快去喊朋友一起来");
} catch { } catch {
toast.error("复制失败,请手动复制地址"); toast.error("复制失败,请手动复制地址");
} }
@ -77,7 +72,6 @@ export default function MeContent({ session }: MeContentProps) {
toast("今日已签到", { icon: "✓" }); toast("今日已签到", { icon: "✓" });
return; return;
} }
// 在 weeklySignIn 数组里找到今天的位置(第一个 false
const idx = weeklySignIn.findIndex((v) => !v); const idx = weeklySignIn.findIndex((v) => !v);
if (idx === -1) { if (idx === -1) {
toast("本周已全部签到"); toast("本周已全部签到");
@ -87,7 +81,7 @@ export default function MeContent({ session }: MeContentProps) {
next[idx] = true; next[idx] = true;
setWeeklySignIn(next); setWeeklySignIn(next);
setSignedInToday(true); setSignedInToday(true);
toast.success("签到成功 · 获得 +3 票"); toast.success("签到成功");
}; };
const handleLogout = () => { const handleLogout = () => {
@ -99,11 +93,7 @@ export default function MeContent({ session }: MeContentProps) {
<div className="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-8 sm:py-10 space-y-8"> <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} /> <UserHeader user={user} />
<QuotaCard <QuotaCard total={user.totalVotes} onInvite={handleInvite} />
remaining={user.remainingVotes}
daily={user.dailyQuota}
onInvite={handleInvite}
/>
<StatsGrid user={user} /> <StatsGrid user={user} />

View File

@ -3,37 +3,31 @@
import { useEffect, useState, useCallback } from "react"; import { useEffect, useState, useCallback } from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { AnimatePresence, motion } from "framer-motion"; import { AnimatePresence, motion } from "framer-motion";
import { X, Heart, Gem } from "lucide-react"; import { X, Heart } from "lucide-react";
import type { Artist } from "@/types/artist"; import type { Artist } from "@/types/artist";
import { cn } from "@/lib/cn"; import { cn } from "@/lib/cn";
import Button from "./ui/Button"; import Button from "./ui/Button";
import ArtistPortrait from "./cards/ArtistPortrait"; import ArtistPortrait from "./cards/ArtistPortrait";
import { useVoteStore } from "@/lib/store";
interface VoteModalProps { interface VoteModalProps {
/** 当前要投票的艺人,传 null 关闭弹窗 */ /** 当前要投票的艺人,传 null 关闭弹窗 */
artist: Artist | null; artist: Artist | null;
/** 单艺人每日上限 */
perArtistLimit?: number;
/** 关闭弹窗 */ /** 关闭弹窗 */
onClose: () => void; onClose: () => void;
/** 确认投票 */ /** 确认投票 */
onConfirm: (artist: Artist, count: number) => void | Promise<void>; onConfirm: (artist: Artist, count: number) => void | Promise<void>;
} }
const VOTE_OPTIONS: Array<number | "ALL"> = [1, 3, 5, "ALL"]; const VOTE_OPTIONS: number[] = [1, 5, 10, 50];
const DEFAULT_COUNT = 5;
export default function VoteModal({ export default function VoteModal({
artist, artist,
perArtistLimit = 3,
onClose, onClose,
onConfirm, onConfirm,
}: VoteModalProps) { }: VoteModalProps) {
const remainingVotes = useVoteStore((s) => s.remainingVotes);
const usedVotes = useVoteStore((s) => s.usedVotes);
const open = artist != null; const open = artist != null;
const [selected, setSelected] = useState<number | "ALL">(3); const [selected, setSelected] = useState<number>(DEFAULT_COUNT);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
@ -42,7 +36,7 @@ export default function VoteModal({
// 打开时重置默认选择 // 打开时重置默认选择
useEffect(() => { useEffect(() => {
if (open) { if (open) {
setSelected(3); setSelected(DEFAULT_COUNT);
setLoading(false); setLoading(false);
} }
}, [open]); }, [open]);
@ -62,19 +56,15 @@ export default function VoteModal({
}; };
}, [open, onClose]); }, [open, onClose]);
const maxVotes = Math.min(remainingVotes, perArtistLimit);
const actualCount = selected === "ALL" ? maxVotes : Math.min(selected, maxVotes);
const canVote = actualCount > 0 && !loading;
const handleConfirm = useCallback(async () => { const handleConfirm = useCallback(async () => {
if (!artist || !canVote) return; if (!artist || loading) return;
setLoading(true); setLoading(true);
try { try {
await onConfirm(artist, actualCount); await onConfirm(artist, selected);
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [artist, actualCount, canVote, onConfirm]); }, [artist, selected, loading, onConfirm]);
if (!mounted) return null; if (!mounted) return null;
@ -145,25 +135,17 @@ export default function VoteModal({
{/* 票数选择 */} {/* 票数选择 */}
<div className="text-xs text-white/55 mb-2.5"></div> <div className="text-xs text-white/55 mb-2.5"></div>
<div className="flex gap-2.5 justify-center mb-4"> <div className="flex gap-2.5 justify-center mb-5">
{VOTE_OPTIONS.map((opt) => { {VOTE_OPTIONS.map((opt) => {
const isAll = opt === "ALL";
const optNum = isAll ? maxVotes : (opt as number);
const disabled = optNum > maxVotes || maxVotes === 0;
const active = selected === opt; const active = selected === opt;
return ( return (
<button <button
type="button" type="button"
key={String(opt)} key={opt}
disabled={disabled}
onClick={() => setSelected(opt)} onClick={() => setSelected(opt)}
className={cn( className={cn(
"rounded-lg font-display flex items-center justify-center transition-all", "rounded-lg font-display text-base flex items-center justify-center transition-all w-14 h-13 py-3.5 px-3",
isAll ? "w-16 text-[11px]" : "w-13 text-base",
"h-13 py-3.5 px-3",
disabled && "opacity-30 cursor-not-allowed",
!active && !active &&
!disabled &&
"bg-surface border border-white/14 text-white/65 hover:border-white/30", "bg-surface border border-white/14 text-white/65 hover:border-white/30",
active && active &&
"bg-purple-500/12 border border-purple-500 text-purple-300 shadow-[0_0_16px_rgba(139,92,246,0.35)]", "bg-purple-500/12 border border-purple-500 text-purple-300 shadow-[0_0_16px_rgba(139,92,246,0.35)]",
@ -175,36 +157,16 @@ export default function VoteModal({
})} })}
</div> </div>
{/* 票数余额 */}
<div className="flex items-center gap-2 px-3.5 py-2.5 rounded-lg bg-purple-500/8 border border-purple-500/25 text-xs text-purple-300 mb-4">
<Gem size={14} />
<span>
<b className="text-white">{remainingVotes} </b>
</span>
<span className="text-white/30 mx-1">|</span>
<span>{usedVotes} </span>
</div>
{/* 确认按钮 */} {/* 确认按钮 */}
<Button <Button
variant="primary" variant="primary"
className="w-full h-12 text-sm" className="w-full h-12 text-sm"
onClick={handleConfirm} onClick={handleConfirm}
loading={loading} loading={loading}
disabled={!canVote}
leftIcon={<Heart size={14} />} leftIcon={<Heart size={14} />}
> >
{canVote {`确认投出 ${selected}`}
? `确认投出 ${actualCount}`
: maxVotes === 0
? "今日已无可用票数"
: "请选择有效票数"}
</Button> </Button>
{/* 提示 */}
<p className="text-[11px] text-white/40 mt-3 text-center">
12 · {perArtistLimit}
</p>
</motion.div> </motion.div>
</motion.div> </motion.div>
)} )}

View File

@ -1,22 +1,16 @@
"use client"; "use client";
import { Gift, Users } from "lucide-react"; import { Heart, Users } from "lucide-react";
interface QuotaCardProps { interface QuotaCardProps {
remaining: number; /** 我累计投出的票数 */
daily: number; total: number;
onInvite?: () => void; onInvite?: () => void;
} }
export default function QuotaCard({ export default function QuotaCard({ total, onInvite }: QuotaCardProps) {
remaining,
daily,
onInvite,
}: QuotaCardProps) {
return ( return (
<div <div className="relative overflow-hidden rounded-2xl p-5 sm:p-6 bg-grad-purple shadow-purple-glow">
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 top-3 right-3 text-white/30 text-base"></span>
<span className="absolute bottom-4 right-12 text-white/15 text-xs"> <span className="absolute bottom-4 right-12 text-white/15 text-xs">
@ -36,21 +30,21 @@ export default function QuotaCard({
<div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="relative flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div> <div>
<div className="flex items-center gap-1.5 text-white/85 text-xs font-label tracking-widest uppercase"> <div className="flex items-center gap-1.5 text-white/85 text-xs font-label tracking-widest uppercase">
<Gift size={12} /> <Heart size={12} fill="currentColor" />
</div> </div>
<div className="font-display text-5xl sm:text-6xl text-white tabular-nums leading-none mt-2 tracking-wider"> <div className="font-display text-5xl sm:text-6xl text-white tabular-nums leading-none mt-2 tracking-wider">
{remaining}{" "} {total.toLocaleString()}{" "}
<span className="text-2xl font-body opacity-85"></span> <span className="text-2xl font-body opacity-85"></span>
</div> </div>
<div className="text-[11px] text-white/75 mt-2 tracking-wide"> <div className="text-[11px] text-white/75 mt-2 tracking-wide">
00:00 {daily} ·
</div> </div>
</div> </div>
<div className="flex flex-col items-start sm:items-end gap-2"> <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 className="font-label text-[10px] tracking-widest uppercase text-white/80">
</span> </span>
<button <button
type="button" type="button"
@ -58,7 +52,7 @@ export default function QuotaCard({
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" 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} /> <Users size={14} />
</button> </button>
</div> </div>
</div> </div>

View File

@ -55,7 +55,7 @@ export default function SignInCalendar({
<Check size={14} strokeWidth={3} /> <Check size={14} strokeWidth={3} />
) : isToday ? ( ) : isToday ? (
<span className="inline-flex items-center gap-0.5 text-[10px] font-display"> <span className="inline-flex items-center gap-0.5 text-[10px] font-display">
<Gift size={11} /> +3 <Gift size={11} />
</span> </span>
) : ( ) : (
<span className="w-1 h-1 rounded-full bg-current opacity-30" /> <span className="w-1 h-1 rounded-full bg-current opacity-30" />
@ -67,7 +67,7 @@ export default function SignInCalendar({
</div> </div>
<p className="mt-3 text-[11px] text-white/40"> <p className="mt-3 text-[11px] text-white/40">
7 · · ·
</p> </p>
</div> </div>
); );

View File

@ -24,34 +24,28 @@ interface UseVoteActionResult {
* - * -
* - store + API * - store + API
* - toast * - toast
*
* /
*/ */
export function useVoteAction(): UseVoteActionResult { export function useVoteAction(): UseVoteActionResult {
const router = useRouter(); const router = useRouter();
const pathname = usePathname(); const pathname = usePathname();
const { status } = useSession(); const { status } = useSession();
const recordVote = useVoteStore((s) => s.vote); const recordVote = useVoteStore((s) => s.vote);
const remainingVotes = useVoteStore((s) => s.remainingVotes);
const [target, setTarget] = useState<Artist | null>(null); const [target, setTarget] = useState<Artist | null>(null);
const openVote = useCallback( const openVote = useCallback(
(artist: Artist) => { (artist: Artist) => {
if (status === "loading") { if (status === "loading") return; // 会话还在加载,等一下
// 会话还在加载,等一下;用户可以再点
return;
}
if (status === "unauthenticated") { if (status === "unauthenticated") {
toast("请先登录后再为偶像投票", { icon: "🔐" }); toast("请先登录后再为偶像投票", { icon: "🔐" });
const back = encodeURIComponent(pathname || "/"); const back = encodeURIComponent(pathname || "/");
setTimeout(() => router.push(`/login?callbackUrl=${back}`), 350); setTimeout(() => router.push(`/login?callbackUrl=${back}`), 350);
return; return;
} }
if (remainingVotes <= 0) {
toast.error("今日票数已用完,明天再来吧~");
return;
}
setTarget(artist); setTarget(artist);
}, },
[status, pathname, router, remainingVotes], [status, pathname, router],
); );
const closeVote = useCallback(() => setTarget(null), []); const closeVote = useCallback(() => setTarget(null), []);

View File

@ -30,9 +30,6 @@ export const ERR = {
VALIDATION: (msg: string) => err("VALIDATION", msg, 422), VALIDATION: (msg: string) => err("VALIDATION", msg, 422),
INTERNAL: (msg = "服务器错误") => err("INTERNAL", msg, 500), INTERNAL: (msg = "服务器错误") => err("INTERNAL", msg, 500),
ACTIVITY_OFF: () => err("ACTIVITY_OFF", "投票活动暂未开放", 409), ACTIVITY_OFF: () => err("ACTIVITY_OFF", "投票活动暂未开放", 409),
QUOTA_EXHAUSTED: () => err("QUOTA_EXHAUSTED", "今日票数已用完", 409),
ARTIST_LIMIT: (limit: number) =>
err("ARTIST_LIMIT", `每艺人每日最多 ${limit}`, 409),
}; };
/** /**

View File

@ -7,17 +7,11 @@ export interface MockUser {
/** 头像 URL无则使用首字母占位 */ /** 头像 URL无则使用首字母占位 */
avatar: string; avatar: string;
signInStreak: number; signInStreak: number;
/** 今日签到状态:每天 true/false按周一开始的一周 7 天) */ /** 本周签到状态:每天 true/false按周一开始的一周 7 天) */
weeklySignIn: boolean[]; weeklySignIn: boolean[];
/** 今日是否已签到 */ /** 今日是否已签到 */
todaySignedIn: boolean; todaySignedIn: boolean;
/** 今日剩余票数 */ /** 累计投票数(无上限) */
remainingVotes: number;
/** 今日已用票数 */
usedVotes: number;
/** 每日基础票数 */
dailyQuota: number;
/** 累计投票数 */
totalVotes: number; totalVotes: number;
/** 应援的艺人 ID 列表 */ /** 应援的艺人 ID 列表 */
supportingIds: string[]; supportingIds: string[];
@ -37,9 +31,6 @@ export const MOCK_USER: MockUser = {
signInStreak: 7, signInStreak: 7,
weeklySignIn: [true, true, true, true, true, true, false], weeklySignIn: [true, true, true, true, true, true, false],
todaySignedIn: false, todaySignedIn: false,
remainingVotes: 9,
usedVotes: 3,
dailyQuota: 12,
totalVotes: 87, totalVotes: 87,
supportingIds: ["001", "005", "014"], supportingIds: ["001", "005", "014"],
invitedCount: 2, invitedCount: 2,

View File

@ -5,10 +5,8 @@ import type { Artist } from "@/types/artist";
interface VoteStore { interface VoteStore {
/** 当前所有艺人(含动态票数 / 实时排名) */ /** 当前所有艺人(含动态票数 / 实时排名) */
artists: Artist[]; artists: Artist[];
/** 用户今日已用 / 剩余票数mock */ /** 用户累计投出票数(无上限) */
remainingVotes: number; myTotalVotes: number;
usedVotes: number;
dailyQuota: number;
/** 给艺人投票(本地模拟,会重新排名) */ /** 给艺人投票(本地模拟,会重新排名) */
vote: (artistId: string, count: number) => void; vote: (artistId: string, count: number) => void;
/** 重置(开发时用) */ /** 重置(开发时用) */
@ -25,9 +23,7 @@ const INITIAL = rank(ARTISTS);
export const useVoteStore = create<VoteStore>((set) => ({ export const useVoteStore = create<VoteStore>((set) => ({
artists: INITIAL, artists: INITIAL,
remainingVotes: 9, myTotalVotes: 0,
usedVotes: 3,
dailyQuota: 12,
vote: (artistId, count) => vote: (artistId, count) =>
set((state) => { set((state) => {
const updated = state.artists.map((a) => const updated = state.artists.map((a) =>
@ -35,15 +31,13 @@ export const useVoteStore = create<VoteStore>((set) => ({
); );
return { return {
artists: rank(updated), artists: rank(updated),
remainingVotes: Math.max(0, state.remainingVotes - count), myTotalVotes: state.myTotalVotes + count,
usedVotes: state.usedVotes + count,
}; };
}), }),
reset: () => reset: () =>
set({ set({
artists: INITIAL, artists: INITIAL,
remainingVotes: 9, myTotalVotes: 0,
usedVotes: 3,
}), }),
})); }));