feat(vote): remove all voting limits (no daily quota, no per-artist cap, unlimited votes)
This commit is contained in:
parent
9fe9fa914f
commit
bd5a361a18
@ -4,7 +4,8 @@ import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
|
||||
|
||||
/**
|
||||
* GET /api/me
|
||||
* 当前用户信息:基础资料、今日票数余额、应援列表、连签天数。
|
||||
* 当前用户信息:基础资料、累计投票数、应援列表、签到状态。
|
||||
* 已取消"今日剩余票数"概念(无投票数量限制)。
|
||||
*/
|
||||
export async function GET() {
|
||||
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({
|
||||
where: { id: user.id },
|
||||
select: {
|
||||
@ -39,9 +40,6 @@ export async function GET() {
|
||||
createdAt: true,
|
||||
},
|
||||
}),
|
||||
prisma.dailyQuota.findUnique({
|
||||
where: { userId_date: { userId: user.id, date: today } },
|
||||
}),
|
||||
prisma.signIn.findFirst({
|
||||
where: { userId: user.id },
|
||||
orderBy: { date: "desc" },
|
||||
@ -64,21 +62,14 @@ export async function GET() {
|
||||
},
|
||||
orderBy: { votedTotal: "desc" },
|
||||
}),
|
||||
prisma.activityConfig.findUnique({ where: { id: 1 } }),
|
||||
])) as [
|
||||
Awaited<ReturnType<typeof prisma.user.findUnique>>,
|
||||
Awaited<ReturnType<typeof prisma.dailyQuota.findUnique>>,
|
||||
Awaited<ReturnType<typeof prisma.signIn.findFirst>>,
|
||||
SupportRow[],
|
||||
Awaited<ReturnType<typeof prisma.activityConfig.findUnique>>,
|
||||
];
|
||||
|
||||
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({
|
||||
where: { userId: user.id },
|
||||
@ -88,11 +79,6 @@ export async function GET() {
|
||||
return ok(
|
||||
sanitizeBigInt({
|
||||
profile,
|
||||
quota: {
|
||||
total: totalQuota,
|
||||
used: usedQuota,
|
||||
remaining: Math.max(0, totalQuota - usedQuota),
|
||||
},
|
||||
signIn: {
|
||||
streak: signIn?.streak ?? 0,
|
||||
lastDate: signIn?.date ?? null,
|
||||
|
||||
@ -1,13 +1,11 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getCurrentUser } from "@/lib/current-user";
|
||||
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
/**
|
||||
* POST /api/me/signin
|
||||
* 用户每日签到 · 获得额外票数(连续 7 天奖励翻倍)。
|
||||
* 用户每日签到 · 记录连续签到天数。
|
||||
* 已取消每日票数额度概念,签到不再向票数余额发放奖励。
|
||||
*/
|
||||
export async function POST() {
|
||||
try {
|
||||
@ -27,7 +25,6 @@ export async function POST() {
|
||||
sanitizeBigInt({
|
||||
alreadySigned: true,
|
||||
streak: existing.streak,
|
||||
bonusVotes: existing.bonusVotes,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@ -37,43 +34,20 @@ export async function POST() {
|
||||
where: { userId_date: { userId: user.id, date: yesterday } },
|
||||
});
|
||||
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 dailyQuota = config?.dailyQuota ?? 12;
|
||||
|
||||
const result = await prisma.$transaction(async (tx: TxClient) => {
|
||||
const signIn = await tx.signIn.create({
|
||||
data: {
|
||||
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;
|
||||
const result = await prisma.signIn.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
date: today,
|
||||
streak,
|
||||
bonusVotes: 0, // 保留字段,但不再发放
|
||||
},
|
||||
});
|
||||
|
||||
return ok(
|
||||
sanitizeBigInt({
|
||||
alreadySigned: false,
|
||||
streak: result.streak,
|
||||
bonusVotes: result.bonusVotes,
|
||||
}),
|
||||
);
|
||||
} catch (e) {
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
import type { NextRequest } from "next/server";
|
||||
import { z } from "zod";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { rateLimit } from "@/lib/rate-limit";
|
||||
import {
|
||||
@ -7,43 +8,41 @@ import {
|
||||
getClientIp,
|
||||
getUserAgent,
|
||||
} from "@/lib/current-user";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { ok, ERR, sanitizeBigInt } from "@/lib/api-response";
|
||||
|
||||
type TxClient = Prisma.TransactionClient;
|
||||
|
||||
const VoteBody = z.object({
|
||||
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
|
||||
* 投票主接口(核心热路径)。
|
||||
* 投票接口 · 已取消所有数量限制(无每日上限 / 无单艺人上限)。
|
||||
*
|
||||
* 流程:
|
||||
* 1. 鉴权 + 风控限流(IP / 用户)
|
||||
* 2. 校验活动状态
|
||||
* 3. 事务:检查每日额度 + 每艺人上限 → 扣减额度 + 写入投票 + 累加艺人票数
|
||||
* 4. 返回最新票数 / 排名
|
||||
* 1. 鉴权 + 反作弊限流(IP / 用户)
|
||||
* 2. 校验活动开关
|
||||
* 3. 事务:写入投票 + 累加艺人票数 + 更新应援关系
|
||||
* 4. 返回最新票数
|
||||
*/
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const user = await getCurrentUser();
|
||||
if (!user) return ERR.UNAUTHORIZED();
|
||||
|
||||
// 限流:单用户 1 秒最多 5 次投票请求
|
||||
// 限流:单用户 1 秒最多 5 次(仅做防刷,非数量限制)
|
||||
const userRl = await rateLimit(`vote:user:${user.id}`, 1, 5);
|
||||
if (!userRl.allowed) return ERR.RATE_LIMITED();
|
||||
|
||||
// 限流:单 IP 60 秒最多 60 次(更宽松)
|
||||
// 限流:单 IP 60 秒最多 60 次
|
||||
const ip = await getClientIp();
|
||||
if (ip) {
|
||||
const ipRl = await rateLimit(`vote:ip:${ip}`, 60, 60);
|
||||
if (!ipRl.allowed) return ERR.RATE_LIMITED();
|
||||
}
|
||||
|
||||
// 校验请求体
|
||||
const raw = await req.json();
|
||||
const parsed = VoteBody.safeParse(raw);
|
||||
if (!parsed.success) {
|
||||
@ -58,41 +57,9 @@ export async function POST(req: NextRequest) {
|
||||
if (now < config.startAt || now > config.endAt) return ERR.ACTIVITY_OFF();
|
||||
|
||||
const ua = await getUserAgent();
|
||||
const today = startOfDay();
|
||||
|
||||
// 事务
|
||||
const result = await prisma.$transaction(async (tx: TxClient) => {
|
||||
// 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. 写入投票
|
||||
// 1. 写入投票记录
|
||||
const vote = await tx.vote.create({
|
||||
data: {
|
||||
userId: user.id,
|
||||
@ -104,56 +71,32 @@ export async function POST(req: NextRequest) {
|
||||
},
|
||||
});
|
||||
|
||||
// 4. 扣减额度 + 累加艺人票数
|
||||
await tx.dailyQuota.update({
|
||||
where: { userId_date: { userId: user.id, date: today } },
|
||||
data: { usedQuota: { increment: count } },
|
||||
});
|
||||
// 2. 累加艺人票数
|
||||
const artist = await tx.artist.update({
|
||||
where: { id: artistId },
|
||||
data: { voteCount: { increment: count } },
|
||||
select: { id: true, voteCount: true, name: true },
|
||||
});
|
||||
|
||||
// 5. 更新 / 创建应援关系
|
||||
// 3. 更新 / 创建应援关系
|
||||
await tx.fanSupport.upsert({
|
||||
where: { userId_artistId: { userId: user.id, artistId } },
|
||||
create: { userId: user.id, artistId, votedTotal: count },
|
||||
update: { votedTotal: { increment: count } },
|
||||
});
|
||||
|
||||
return { vote, artist, remaining: remaining - count };
|
||||
return { vote, artist };
|
||||
});
|
||||
|
||||
return ok(
|
||||
sanitizeBigInt({
|
||||
artistId: result.artist.id,
|
||||
artistVotes: result.artist.voteCount,
|
||||
remainingQuota: result.remaining,
|
||||
voteId: result.vote.id,
|
||||
}),
|
||||
);
|
||||
} 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);
|
||||
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;
|
||||
}
|
||||
|
||||
@ -20,9 +20,7 @@ interface MeContentProps {
|
||||
}
|
||||
|
||||
export default function MeContent({ session }: MeContentProps) {
|
||||
const remaining = useVoteStore((s) => s.remainingVotes);
|
||||
const used = useVoteStore((s) => s.usedVotes);
|
||||
const dailyQuota = useVoteStore((s) => s.dailyQuota);
|
||||
const myTotalVotes = useVoteStore((s) => s.myTotalVotes);
|
||||
const storeArtists = useVoteStore((s) => s.artists);
|
||||
|
||||
// 本地签到状态(数据库就绪后由 /api/me/signin 提供)
|
||||
@ -33,12 +31,9 @@ export default function MeContent({ session }: MeContentProps) {
|
||||
...MOCK_USER,
|
||||
id: session.id,
|
||||
nickname: session.nickname,
|
||||
remainingVotes: remaining,
|
||||
usedVotes: used,
|
||||
dailyQuota,
|
||||
todaySignedIn: signedInToday,
|
||||
weeklySignIn,
|
||||
totalVotes: MOCK_USER.totalVotes + used,
|
||||
totalVotes: MOCK_USER.totalVotes + myTotalVotes,
|
||||
};
|
||||
|
||||
// 用 store 里最新的艺人排名重算 "我的应援" 当前排名
|
||||
@ -56,7 +51,7 @@ export default function MeContent({ session }: MeContentProps) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: "CYBER STAR · 一起为偶像应援",
|
||||
text: "邀请你加入虚拟偶像 Top12 出道企划,双方各得 +5 票!",
|
||||
text: "邀请你加入虚拟偶像 Top12 出道企划!",
|
||||
url,
|
||||
});
|
||||
return;
|
||||
@ -66,7 +61,7 @@ export default function MeContent({ session }: MeContentProps) {
|
||||
}
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
toast.success("邀请链接已复制 · 朋友注册后双方各 +5 票");
|
||||
toast.success("邀请链接已复制 · 快去喊朋友一起来");
|
||||
} catch {
|
||||
toast.error("复制失败,请手动复制地址");
|
||||
}
|
||||
@ -77,7 +72,6 @@ export default function MeContent({ session }: MeContentProps) {
|
||||
toast("今日已签到", { icon: "✓" });
|
||||
return;
|
||||
}
|
||||
// 在 weeklySignIn 数组里找到今天的位置(第一个 false)
|
||||
const idx = weeklySignIn.findIndex((v) => !v);
|
||||
if (idx === -1) {
|
||||
toast("本周已全部签到");
|
||||
@ -87,7 +81,7 @@ export default function MeContent({ session }: MeContentProps) {
|
||||
next[idx] = true;
|
||||
setWeeklySignIn(next);
|
||||
setSignedInToday(true);
|
||||
toast.success("签到成功 · 获得 +3 票");
|
||||
toast.success("签到成功");
|
||||
};
|
||||
|
||||
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">
|
||||
<UserHeader user={user} />
|
||||
|
||||
<QuotaCard
|
||||
remaining={user.remainingVotes}
|
||||
daily={user.dailyQuota}
|
||||
onInvite={handleInvite}
|
||||
/>
|
||||
<QuotaCard total={user.totalVotes} onInvite={handleInvite} />
|
||||
|
||||
<StatsGrid user={user} />
|
||||
|
||||
|
||||
@ -3,37 +3,31 @@
|
||||
import { useEffect, useState, useCallback } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
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 { cn } from "@/lib/cn";
|
||||
import Button from "./ui/Button";
|
||||
import ArtistPortrait from "./cards/ArtistPortrait";
|
||||
|
||||
import { useVoteStore } from "@/lib/store";
|
||||
|
||||
interface VoteModalProps {
|
||||
/** 当前要投票的艺人,传 null 关闭弹窗 */
|
||||
artist: Artist | null;
|
||||
/** 单艺人每日上限 */
|
||||
perArtistLimit?: number;
|
||||
/** 关闭弹窗 */
|
||||
onClose: () => 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({
|
||||
artist,
|
||||
perArtistLimit = 3,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: VoteModalProps) {
|
||||
const remainingVotes = useVoteStore((s) => s.remainingVotes);
|
||||
const usedVotes = useVoteStore((s) => s.usedVotes);
|
||||
const open = artist != null;
|
||||
const [selected, setSelected] = useState<number | "ALL">(3);
|
||||
const [selected, setSelected] = useState<number>(DEFAULT_COUNT);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
@ -42,7 +36,7 @@ export default function VoteModal({
|
||||
// 打开时重置默认选择
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setSelected(3);
|
||||
setSelected(DEFAULT_COUNT);
|
||||
setLoading(false);
|
||||
}
|
||||
}, [open]);
|
||||
@ -62,19 +56,15 @@ export default function VoteModal({
|
||||
};
|
||||
}, [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 () => {
|
||||
if (!artist || !canVote) return;
|
||||
if (!artist || loading) return;
|
||||
setLoading(true);
|
||||
try {
|
||||
await onConfirm(artist, actualCount);
|
||||
await onConfirm(artist, selected);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [artist, actualCount, canVote, onConfirm]);
|
||||
}, [artist, selected, loading, onConfirm]);
|
||||
|
||||
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="flex gap-2.5 justify-center mb-4">
|
||||
<div className="flex gap-2.5 justify-center mb-5">
|
||||
{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;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={String(opt)}
|
||||
disabled={disabled}
|
||||
key={opt}
|
||||
onClick={() => setSelected(opt)}
|
||||
className={cn(
|
||||
"rounded-lg font-display flex items-center justify-center transition-all",
|
||||
isAll ? "w-16 text-[11px]" : "w-13 text-base",
|
||||
"h-13 py-3.5 px-3",
|
||||
disabled && "opacity-30 cursor-not-allowed",
|
||||
"rounded-lg font-display text-base flex items-center justify-center transition-all w-14 h-13 py-3.5 px-3",
|
||||
!active &&
|
||||
!disabled &&
|
||||
"bg-surface border border-white/14 text-white/65 hover:border-white/30",
|
||||
active &&
|
||||
"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 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
|
||||
variant="primary"
|
||||
className="w-full h-12 text-sm"
|
||||
onClick={handleConfirm}
|
||||
loading={loading}
|
||||
disabled={!canVote}
|
||||
leftIcon={<Heart size={14} />}
|
||||
>
|
||||
{canVote
|
||||
? `确认投出 ${actualCount} 票`
|
||||
: maxVotes === 0
|
||||
? "今日已无可用票数"
|
||||
: "请选择有效票数"}
|
||||
{`确认投出 ${selected} 票`}
|
||||
</Button>
|
||||
|
||||
{/* 提示 */}
|
||||
<p className="text-[11px] text-white/40 mt-3 text-center">
|
||||
每日 12 票 · 每艺人每日最多 {perArtistLimit} 票
|
||||
</p>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
@ -1,22 +1,16 @@
|
||||
"use client";
|
||||
|
||||
import { Gift, Users } from "lucide-react";
|
||||
import { Heart, Users } from "lucide-react";
|
||||
|
||||
interface QuotaCardProps {
|
||||
remaining: number;
|
||||
daily: number;
|
||||
/** 我累计投出的票数 */
|
||||
total: number;
|
||||
onInvite?: () => void;
|
||||
}
|
||||
|
||||
export default function QuotaCard({
|
||||
remaining,
|
||||
daily,
|
||||
onInvite,
|
||||
}: QuotaCardProps) {
|
||||
export default function QuotaCard({ total, onInvite }: QuotaCardProps) {
|
||||
return (
|
||||
<div
|
||||
className="relative overflow-hidden rounded-2xl p-5 sm:p-6 bg-grad-purple shadow-purple-glow"
|
||||
>
|
||||
<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">
|
||||
@ -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>
|
||||
<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 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>
|
||||
</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"
|
||||
@ -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"
|
||||
>
|
||||
<Users size={14} />
|
||||
邀请好友得票
|
||||
邀请好友
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -55,7 +55,7 @@ export default function SignInCalendar({
|
||||
<Check size={14} strokeWidth={3} />
|
||||
) : isToday ? (
|
||||
<span className="inline-flex items-center gap-0.5 text-[10px] font-display">
|
||||
<Gift size={11} /> +3
|
||||
<Gift size={11} />
|
||||
</span>
|
||||
) : (
|
||||
<span className="w-1 h-1 rounded-full bg-current opacity-30" />
|
||||
@ -67,7 +67,7 @@ export default function SignInCalendar({
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-[11px] text-white/40">
|
||||
连续签到 7 天可获得额外票数奖励 · 中断后从头计算
|
||||
每日签到 · 解锁专属应援徽章 · 中断后从头计算
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -24,34 +24,28 @@ interface UseVoteActionResult {
|
||||
* - 未登录 → 提示并跳登录页(登录后回到当前路径)
|
||||
* - 已登录 → 打开投票弹窗 → 确认后调用本地 store + 尝试调用 API
|
||||
* - 任意态 → 用 toast 反馈结果
|
||||
*
|
||||
* 注意:当前已取消所有投票数量限制(无每日上限 / 无单艺人上限)。
|
||||
*/
|
||||
export function useVoteAction(): UseVoteActionResult {
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
const { status } = useSession();
|
||||
const recordVote = useVoteStore((s) => s.vote);
|
||||
const remainingVotes = useVoteStore((s) => s.remainingVotes);
|
||||
const [target, setTarget] = useState<Artist | null>(null);
|
||||
|
||||
const openVote = useCallback(
|
||||
(artist: Artist) => {
|
||||
if (status === "loading") {
|
||||
// 会话还在加载,等一下;用户可以再点
|
||||
return;
|
||||
}
|
||||
if (status === "loading") return; // 会话还在加载,等一下
|
||||
if (status === "unauthenticated") {
|
||||
toast("请先登录后再为偶像投票", { icon: "🔐" });
|
||||
const back = encodeURIComponent(pathname || "/");
|
||||
setTimeout(() => router.push(`/login?callbackUrl=${back}`), 350);
|
||||
return;
|
||||
}
|
||||
if (remainingVotes <= 0) {
|
||||
toast.error("今日票数已用完,明天再来吧~");
|
||||
return;
|
||||
}
|
||||
setTarget(artist);
|
||||
},
|
||||
[status, pathname, router, remainingVotes],
|
||||
[status, pathname, router],
|
||||
);
|
||||
|
||||
const closeVote = useCallback(() => setTarget(null), []);
|
||||
|
||||
@ -30,9 +30,6 @@ export const ERR = {
|
||||
VALIDATION: (msg: string) => err("VALIDATION", msg, 422),
|
||||
INTERNAL: (msg = "服务器错误") => err("INTERNAL", msg, 500),
|
||||
ACTIVITY_OFF: () => err("ACTIVITY_OFF", "投票活动暂未开放", 409),
|
||||
QUOTA_EXHAUSTED: () => err("QUOTA_EXHAUSTED", "今日票数已用完", 409),
|
||||
ARTIST_LIMIT: (limit: number) =>
|
||||
err("ARTIST_LIMIT", `每艺人每日最多 ${limit} 票`, 409),
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
@ -7,17 +7,11 @@ export interface MockUser {
|
||||
/** 头像 URL(无则使用首字母占位) */
|
||||
avatar: string;
|
||||
signInStreak: number;
|
||||
/** 今日签到状态:每天 true/false(按周一开始的一周 7 天) */
|
||||
/** 本周签到状态:每天 true/false(按周一开始的一周 7 天) */
|
||||
weeklySignIn: boolean[];
|
||||
/** 今日是否已签到 */
|
||||
todaySignedIn: boolean;
|
||||
/** 今日剩余票数 */
|
||||
remainingVotes: number;
|
||||
/** 今日已用票数 */
|
||||
usedVotes: number;
|
||||
/** 每日基础票数 */
|
||||
dailyQuota: number;
|
||||
/** 累计投票数 */
|
||||
/** 累计投票数(无上限) */
|
||||
totalVotes: number;
|
||||
/** 应援的艺人 ID 列表 */
|
||||
supportingIds: string[];
|
||||
@ -37,9 +31,6 @@ export const MOCK_USER: MockUser = {
|
||||
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,
|
||||
|
||||
@ -5,10 +5,8 @@ import type { Artist } from "@/types/artist";
|
||||
interface VoteStore {
|
||||
/** 当前所有艺人(含动态票数 / 实时排名) */
|
||||
artists: Artist[];
|
||||
/** 用户今日已用 / 剩余票数(mock) */
|
||||
remainingVotes: number;
|
||||
usedVotes: number;
|
||||
dailyQuota: number;
|
||||
/** 用户累计投出票数(无上限) */
|
||||
myTotalVotes: number;
|
||||
/** 给艺人投票(本地模拟,会重新排名) */
|
||||
vote: (artistId: string, count: number) => void;
|
||||
/** 重置(开发时用) */
|
||||
@ -25,9 +23,7 @@ const INITIAL = rank(ARTISTS);
|
||||
|
||||
export const useVoteStore = create<VoteStore>((set) => ({
|
||||
artists: INITIAL,
|
||||
remainingVotes: 9,
|
||||
usedVotes: 3,
|
||||
dailyQuota: 12,
|
||||
myTotalVotes: 0,
|
||||
vote: (artistId, count) =>
|
||||
set((state) => {
|
||||
const updated = state.artists.map((a) =>
|
||||
@ -35,15 +31,13 @@ export const useVoteStore = create<VoteStore>((set) => ({
|
||||
);
|
||||
return {
|
||||
artists: rank(updated),
|
||||
remainingVotes: Math.max(0, state.remainingVotes - count),
|
||||
usedVotes: state.usedVotes + count,
|
||||
myTotalVotes: state.myTotalVotes + count,
|
||||
};
|
||||
}),
|
||||
reset: () =>
|
||||
set({
|
||||
artists: INITIAL,
|
||||
remainingVotes: 9,
|
||||
usedVotes: 3,
|
||||
myTotalVotes: 0,
|
||||
}),
|
||||
}));
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user