Compare commits

...

4 Commits

Author SHA1 Message Date
a9a6654977 feat: 添加og支持 2026-01-07 23:31:51 +08:00
f01762dab2 feat: 完成.... 2026-01-07 23:22:15 +08:00
ad070b51f2 chore: 初始化项目 2026-01-07 11:51:40 +08:00
2a9b800186 chore: 添加项目特点 2026-01-07 11:41:36 +08:00
58 changed files with 8977 additions and 1 deletions

View File

@@ -1,3 +1,10 @@
# tonesc-red-packet
一个基于 Next.js 16 的随机红包生成器,支持高精度金额、硬编码金额单位及多种红包规则。
一个基于 Next.js 16 的随机红包生成器,支持高精度金额、硬编码金额单位及多种红包规则。
项目特点:
- 使用高精度数值类型进行金额存储与计算,避免浮点误差
- 金额单位硬编码,防止创建者恶意注入规则
- 支持固定金额、区间随机、拼手气红包等模式
- 红包抽取逻辑完全在服务端执行,保证一致性与安全性
- 每个红包自动生成用户页与管理页

41
tonesc-red-packet/.gitignore vendored Normal file
View File

@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

1
tonesc-red-packet/.nvmrc Normal file
View File

@@ -0,0 +1 @@
22

View File

@@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -0,0 +1,85 @@
"use client"
import { useState } from "react"
import { getRedPacketDrawsByRedPacketId } from "@/app/server/actions/get-red-packet-draws"
import { formatAmount } from "@/lib/format-amount"
type Draw = {
id: string
userId: string
amount: string
createdAt: Date
}
export function DrawList({
precision,
redPacketId,
initialDraws,
}: {
precision: number
redPacketId: string
initialDraws: Draw[]
}) {
const [draws, setDraws] = useState(initialDraws)
const [loading, setLoading] = useState(false)
const refresh = async () => {
if (loading) return
try {
setLoading(true)
const next = await getRedPacketDrawsByRedPacketId(redPacketId)
setDraws(next)
} catch (e) {
console.error(e)
} finally {
setLoading(false)
}
}
return (
<section className="rounded-lg border p-3 space-y-2">
{/* 标题 + 刷新按钮 */}
<div className="flex items-center justify-between">
<div className="text-gray-500"></div>
<button
onClick={refresh}
disabled={loading}
className="text-xs text-blue-600 disabled:text-gray-400"
>
{loading ? "刷新中…" : "刷新"}
</button>
</div>
{draws.length === 0 ? (
<div className="text-sm text-gray-400"></div>
) : (
<ul className="space-y-1">
{draws.map((draw) => (
<li
key={draw.id}
className="flex justify-between items-center text-sm font-mono"
>
{/* 用户 */}
<span>
{draw.userId.slice(0, 6)}
</span>
<div className="flex flex-1 flex-col">
{/* 金额 */}
<span className="text-right">
{formatAmount(draw.amount, precision)}
</span>
{/* 时间 */}
<span className="text-right text-gray-400">
{draw.createdAt.toLocaleString()}
</span>
</div>
</li>
))}
</ul>
)}
</section>
)
}

View File

@@ -0,0 +1,88 @@
import { getRedPacketByAdminId } from "@/app/server/actions/get-red-packet";
import { notFound } from "next/navigation"
import { PublicIdSection } from "./public-id-section";
import { DrawList } from "./draw-list.client";
import { getRedPacketDrawsByRedPacketId } from "@/app/server/actions/get-red-packet-draws";
import { getDecimalPlacesFromStep } from "@/lib/currency";
import { formatAmount } from "@/lib/format-amount";
export const metadata = {
title: "红包管理 - tonesc 红包",
description: "查看红包基本信息和抽取记录,支持后台发放及数据管理,保证高精度金额与并发安全。",
keywords: "红包管理, 红包后台, 抽取记录, 私有红包, tonesc",
robots: "noindex, nofollow", // 管理页不希望被搜索引擎收录
openGraph: {
title: "红包管理 - tonesc 红包",
description: "查看红包基本信息和抽取记录,支持后台发放及数据管理,保证高精度金额与并发安全。",
url: "https://redpacket.lab.tonesc.com/manage/[id]",
siteName: "tonesc 红包",
type: "website"
}
}
type Props = {
params: Promise<{ id: string }>
}
export default async function AdminRedPacketPage({ params }: Props) {
const { id: adminId } = await params;
const redPacket = await getRedPacketByAdminId(adminId)
if (!redPacket) {
notFound()
}
const { id: redPacketId } = redPacket;
const initialDraws = await getRedPacketDrawsByRedPacketId(redPacketId);
const precision = getDecimalPlacesFromStep(redPacket.currencyPrecision);
return (
<main className="mx-auto max-w-md p-4 space-y-4">
<h1 className="text-xl font-semibold"></h1>
<section className="rounded-lg border p-3 space-y-2">
<div>
<span className="text-gray-500"> ID</span>
<span className="font-mono">{redPacket.id}</span>
</div>
<div>
<span className="text-gray-500"></span>
<span>{redPacket.count}</span>
</div>
<div>
<span className="text-gray-500"></span>
<span>
{redPacket.currencyName} {formatAmount(redPacket.currencyPrecision, precision)}
</span>
</div>
{redPacket.maxDrawTimes !== null && (
<div>
<span className="text-gray-500"></span>
<span>{redPacket.maxDrawTimes}</span>
</div>
)}
</section>
<section className="rounded-lg border p-3">
<div className="text-gray-500 mb-1"></div>
<pre className="text-sm bg-gray-50 rounded p-2 overflow-x-auto">
{JSON.stringify(redPacket.rule, null, 2)}
</pre>
</section>
<DrawList
precision={precision}
redPacketId={redPacketId}
initialDraws={initialDraws}
/>
<PublicIdSection publicId={redPacket.publicId} />
</main>
)
}

View File

@@ -0,0 +1,77 @@
'use client'
import { Button } from '@/components/ui/button';
import {
Popover,
PopoverContent,
PopoverTrigger,
} from '@/components/ui/popover';
import { Copy, ExternalLink } from 'lucide-react';
import { useEffect, useState } from 'react';
import { toast } from 'sonner';
interface PublicIdSectionProps {
publicId: string;
}
export function PublicIdSection({ publicId }: PublicIdSectionProps) {
const [userPageUrl, setUserPageUrl] = useState('');
useEffect(() => {
setUserPageUrl(`${window.location.origin}/p/${publicId}`);
}, [publicId]);
const handleCopy = async () => {
try {
await navigator.clipboard.writeText(userPageUrl);
toast.success(`复制成功`);
} catch (err) {
toast.error(`复制失败: ${err}`);
}
};
const handleNavigate = () => {
window.open(userPageUrl, '_blank');
};
if (!userPageUrl) {
return <div className="rounded-lg border p-3">...</div>;
}
return (
<section className="rounded-lg border p-3 space-y-1">
<div className="text-gray-500"></div>
<Popover>
<PopoverTrigger asChild>
<span className="break-all cursor-pointer inline-block max-w-full">
{userPageUrl}
</span>
</PopoverTrigger>
<PopoverContent className="w-60 p-3">
<div className="space-y-3">
<div className="flex gap-3">
<Button
variant="outline"
size="sm"
onClick={handleNavigate}
className="flex items-center justify-start gap-2"
>
<ExternalLink className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleCopy}
className="flex items-center justify-start gap-2"
>
<Copy className="h-4 w-4" />
</Button>
</div>
</div>
</PopoverContent>
</Popover>
</section>
);
}

View File

@@ -0,0 +1,80 @@
import { db } from "@/app/server/db"
import { redPackets } from "@/app/server/schema/red-packet"
import { ImageResponse } from "@vercel/og"
import { eq } from "drizzle-orm"
import { NextRequest } from "next/server"
export const runtime = "edge" // 使用 Edge Runtime 加快生成速度
export async function GET(req: NextRequest) {
try {
const { searchParams } = new URL(req.url)
const id = searchParams.get("id")
if (!id) return new Response("Missing red packet ID", { status: 400 })
// 查询红包信息
const packet = (await db
.select()
.from(redPackets)
.where(eq(redPackets.id, id))
.limit(1))[0]
if (!packet) return new Response("Red packet not found", { status: 404 })
// 生成规则文字
let ruleText = ""
const rule = packet.rule as any
if (rule.type === "fixed") {
ruleText = `固定金额 ${rule.singleAmount} ${packet.currencyName}`
} else if (rule.type === "random") {
ruleText = `随机金额 ${rule.min} ~ ${rule.max} ${packet.currencyName}`
} else if (rule.type === "luck") {
ruleText = `拼手气,总额 ${rule.total} ${packet.currencyName}`
}
return new ImageResponse(
(
<div
style={{
width: "1200px",
height: "630px",
background: "linear-gradient(180deg, #FF4D4F 0%, #FF7875 100%)",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
fontFamily: "sans-serif",
}}
>
<div
style={{
fontSize: 80,
color: "white",
fontWeight: "bold",
marginBottom: 20,
}}
>
🎁 tonesc
</div>
<div
style={{
fontSize: 40,
color: "#FFD666",
marginBottom: 40,
}}
>
{ruleText}
</div>
{/* 卡通红包图标 */}
<div style={{ fontSize: 120 }}>🧧</div>
</div>
),
{
width: 1200,
height: 630,
}
)
} catch (e) {
return new Response("Failed to generate image", { status: 500 })
}
}

View File

@@ -0,0 +1,404 @@
"use client"
import { useState } from "react"
import { Input } from "@/components/ui/input"
import { Button } from "@/components/ui/button"
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select"
import { CreateRedPacketPayload } from "@/lib/types/red-packet"
import { createRedPacket } from "../server/actions/create-red-packet"
import { toast } from "sonner"
import { useRouter } from "next/navigation"
// export const metadata = {
// title: "创建红包 - tonesc 红包",
// description: "创建私有红包,支持多种金额规则、高精度金额和单用户抽取次数设置,立即生成可分享链接。",
// keywords: "创建红包, 私有红包, 随机红包, 固定红包, 拼手气红包, tonesc",
// robots: "noindex, nofollow",
// openGraph: {
// title: "创建红包 - Tonesc 红包",
// description: "创建私有红包,支持多种金额规则、高精度金额和单用户抽取次数设置,立即生成可分享链接。",
// url: "https://redpacket.lab.tonesc.com/create",
// siteName: "Tonesc 红包",
// type: "website"
// }
// }
type RuleType = "fixed" | "random" | "luck"
type CurrencyUnit = {
name: string
precision: number
}
type CurrencyUnitPreset = {
key: string
name: string
precision: number
custom?: boolean
}
const CURRENCY_PRESETS: CurrencyUnitPreset[] = [
{ key: "rmb", name: "人民币", precision: 0.01 },
{ key: "custom", name: "自定义", precision: 0.01, custom: true },
]
/* ---------- utils ---------- */
function decimalsFromPrecision(p: number) {
return p.toString().split(".")[1]?.length ?? 0
}
function formatByPrecision(value: string, precision: number) {
if (!value) return ""
const num = Number(value)
if (Number.isNaN(num)) return ""
return num.toFixed(decimalsFromPrecision(precision))
}
function isValidPrecision(p: string) {
return /^1$|^0\.0*1$/.test(p)
}
function buildCreatePayload(params: {
count: number
currency: { name: string; precision: number }
ruleType: RuleType
singleAmount: string
minAmount: string
maxAmount: string
totalAmount: string
maxDrawTimes?: string
}): CreateRedPacketPayload {
const {
count,
currency,
ruleType,
singleAmount,
minAmount,
maxAmount,
totalAmount,
maxDrawTimes,
} = params
const rule =
ruleType === "fixed"
? {
type: "fixed",
singleAmount,
} as const
: ruleType === "random"
? {
type: "random",
min: minAmount,
max: maxAmount,
} as const
: {
type: "luck",
totalAmount,
} as const;
return {
count,
currency,
rule,
maxDrawTimes: maxDrawTimes
? Number(maxDrawTimes)
: null,
}
}
/* ---------- page ---------- */
export default function CreateRedPacketPage() {
const router = useRouter();
/* 红包数量(允许为空) */
const [count, setCount] = useState("1")
const parsedCount = Math.max(1, Number(count) || 1)
/* 金额单位 */
const [unitKey, setUnitKey] = useState("rmb")
const preset = CURRENCY_PRESETS.find((u) => u.key === unitKey)!
const [customUnitName, setCustomUnitName] = useState("")
const [customPrecision, setCustomPrecision] = useState("0.01")
const currency: CurrencyUnit = preset.custom
? {
name: customUnitName,
precision: Number(customPrecision) || preset.precision,
}
: {
name: preset.name,
precision: preset.precision,
}
const precision = currency.precision
/* 规则 */
const [ruleType, setRuleType] = useState<RuleType>("fixed")
/* 金额 */
const [singleAmount, setSingleAmount] = useState("")
const [minAmount, setMinAmount] = useState("")
const [maxAmount, setMaxAmount] = useState("")
const [totalAmount, setTotalAmount] = useState("")
/* 抽取次数 */
const [maxDrawTimes, setMaxDrawTimes] = useState("")
/* ---------- total estimate ---------- */
function renderTotalEstimate() {
if (ruleType === "fixed" && singleAmount) {
return `总金额:${formatByPrecision(
(Number(singleAmount) * parsedCount).toString(),
precision
)} ${currency.name}`
}
if (ruleType === "luck" && totalAmount) {
return `总金额:${formatByPrecision(
totalAmount,
precision
)} ${currency.name}`
}
if (ruleType === "random" && minAmount && maxAmount) {
return `总金额范围:${formatByPrecision(
(Number(minAmount) * parsedCount).toString(),
precision
)} ${formatByPrecision(
(Number(maxAmount) * parsedCount).toString(),
precision
)} ${currency.name}`
}
return null
}
return (
<div className="min-h-screen bg-gray-100 px-4 py-6">
<div className="mx-auto max-w-md space-y-5">
<h1 className="text-center text-2xl font-semibold"></h1>
<Card>
<Field label="红包数量">
<Input
inputMode="numeric"
value={count}
onChange={(e) => setCount(e.target.value)}
onBlur={() => {
if (!count || Number(count) < 1) setCount("1")
}}
/>
</Field>
<Field label="金额单位">
<Select value={unitKey} onValueChange={setUnitKey}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CURRENCY_PRESETS.map((u) => (
<SelectItem key={u.key} value={u.key}>
{u.name}
</SelectItem>
))}
</SelectContent>
</Select>
</Field>
{preset.custom && (
<>
<Field label="单位名称">
<Input
value={customUnitName}
onChange={(e) => setCustomUnitName(e.target.value)}
/>
</Field>
<Field label="金额精度1 / 0.1 / 0.01...">
<Input
value={customPrecision}
onChange={(e) => setCustomPrecision(e.target.value)}
/>
{!isValidPrecision(customPrecision) && (
<p className="text-xs text-red-500"></p>
)}
</Field>
</>
)}
</Card>
<Card>
<RuleSelector value={ruleType} onChange={setRuleType} />
{ruleType === "fixed" && (
<MoneyInput
label="单个红包金额"
value={singleAmount}
precision={precision}
onChange={setSingleAmount}
/>
)}
{ruleType === "random" && (
<>
<MoneyInput
label="最小金额"
value={minAmount}
precision={precision}
onChange={setMinAmount}
/>
<MoneyInput
label="最大金额"
value={maxAmount}
precision={precision}
onChange={setMaxAmount}
/>
</>
)}
{ruleType === "luck" && (
<MoneyInput
label="总金额"
value={totalAmount}
precision={precision}
onChange={setTotalAmount}
/>
)}
</Card>
{parsedCount >= 2 && (
<Card>
<Field label="单个用户最大抽取次数(可选)">
<Input
inputMode="numeric"
placeholder="不填默认为1"
value={maxDrawTimes}
onChange={(e) => setMaxDrawTimes(e.target.value)}
/>
</Field>
</Card>
)}
{renderTotalEstimate() && (
<div className="rounded-xl bg-blue-50 px-4 py-3 text-sm text-blue-700">
{renderTotalEstimate()}
</div>
)}
<Button
className="h-12 w-full text-base"
onClick={async () => {
const payload = buildCreatePayload({
count: parsedCount,
currency,
ruleType,
singleAmount,
minAmount,
maxAmount,
totalAmount,
maxDrawTimes,
})
const result = await createRedPacket(payload).catch((e) => {
toast.error(`${e.message || '请求失败'}`)
return null;
});
if (result && result.adminId) {
router.push(`/admin/${result.adminId}`)
}
}}
>
</Button>
</div>
</div>
)
}
/* ---------- UI helpers ---------- */
function Card({ children }: { children: React.ReactNode }) {
return <div className="space-y-4 rounded-2xl bg-white p-4">{children}</div>
}
function Field({
label,
children,
}: {
label: string
children: React.ReactNode
}) {
return (
<div className="space-y-1.5">
<label className="text-sm font-medium">{label}</label>
{children}
</div>
)
}
function MoneyInput({
label,
value,
precision,
onChange,
}: {
label: string
value: string
precision: number
onChange: (v: string) => void
}) {
return (
<Field label={label}>
<Input
inputMode="decimal"
value={value}
onChange={(e) => onChange(e.target.value)}
onBlur={() => onChange(formatByPrecision(value, precision))}
placeholder={`精度 ${precision}`}
/>
</Field>
)
}
function RuleSelector({
value,
onChange,
}: {
value: RuleType
onChange: (v: RuleType) => void
}) {
const rules = [
{ type: "fixed", label: "固定" },
{ type: "random", label: "随机" },
{ type: "luck", label: "拼手气" },
] as const
return (
<div className="flex rounded-xl bg-muted p-1">
{rules.map((r) => (
<button
key={r.type}
onClick={() => onChange(r.type)}
className={`flex-1 rounded-lg py-2 text-sm font-medium transition
${value === r.type
? "bg-background shadow"
: "text-muted-foreground"
}`}
>
{r.label}
</button>
))}
</div>
)
}

View File

@@ -0,0 +1,125 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,36 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
import { Toaster } from "@/components/ui/sonner";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
>
{children}
<Toaster />
</body>
</html>
);
}

View File

@@ -0,0 +1,37 @@
import { notFound } from "next/navigation"
import RedPacketClient from "./red-packet-client"
import { getRedPacketByPublicId } from "@/app/server/actions/get-red-packet";
import { getDecimalPlacesFromStep } from "@/lib/currency";
export const metadata = {
title: "抽红包 - tonesc 红包",
description: "通过链接抽取红包,体验随机金额或固定金额规则,安全高精度计算,私有红包仅限接收者抽取。",
keywords: "抽红包, 私有红包, 随机红包, 固定红包, 拼手气红包, tonesc",
robots: "noindex, nofollow",
openGraph: {
title: "抽红包 - tonesc 红包",
description: "通过链接抽取红包,体验随机金额或固定金额规则,安全高精度计算,私有红包仅限接收者抽取。",
url: "https://redpacket.lab.tonesc.com/p/[id]",
siteName: "tonesc 红包",
type: "website"
}
}
type Props = {
params: Promise<{ id: string }>
}
export default async function RedPacketPage({ params }: Props) {
const { id: publicId } = await params;
const packet = await getRedPacketByPublicId(publicId)
if (!packet) notFound()
const { id, currencyName, currencyPrecision, maxDrawTimes } = packet;
const precision = getDecimalPlacesFromStep(currencyPrecision);
return <RedPacketClient packet={{
id, currencyName, precision, maxDrawTimes
}} />
}

View File

@@ -0,0 +1,108 @@
"use client"
import { useState, useTransition } from "react"
import { motion, AnimatePresence } from "framer-motion"
import { drawRedPacket } from "@/app/server/actions/draw-red-packet"
import { getBrowserFingerprint } from "@/lib/fingerprint"
type Props = {
packet: {
id: string
precision: number
currencyName: string
maxDrawTimes: number | null
}
}
export default function OptimizedRedPacket({ packet }: Props) {
const [drawCount, setDrawCount] = useState(0)
const [status, setStatus] = useState<"idle" | "opened">("idle")
const [amount, setAmount] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const [, startTransition] = useTransition()
const maxTimes = packet.maxDrawTimes ?? 1
const canDraw = drawCount < maxTimes
const isFirstVisit = drawCount === 0
const draw = () => {
if (error) return
if (!canDraw) return setError('红包已抽完')
setAmount(null)
setError(null)
startTransition(async () => {
try {
const fp = await getBrowserFingerprint()
const res = await drawRedPacket(packet.id, fp)
if (res.ok) {
setAmount(res.amount)
setStatus("opened")
setDrawCount((prev) => prev + 1)
} else {
setError(res.message || "今日已抽完")
setStatus("opened")
}
} catch {
setError("网络或系统错误,请稍后再试")
}
})
}
return (
<main className="min-h-screen bg-red-50 flex flex-col items-center justify-center p-4">
<motion.div
initial={{ scale: 0.95 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 250 }}
className="w-full max-w-sm bg-linear-to-b from-red-500 to-red-400 rounded-2xl shadow-2xl p-6 flex flex-col items-center space-y-4"
>
<h1 className="text-2xl font-bold text-white">🎁 </h1>
{/* 红包显示区域 */}
<div className="w-full h-40 bg-red-100 rounded-xl flex items-center justify-center text-3xl font-bold text-red-700 relative overflow-hidden">
<AnimatePresence>
{status === "opened" && amount !== null && (
<motion.div
key="amount"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ type: "spring", stiffness: 300, damping: 20 }}
className="absolute"
>
<span className="px-4 py-2 rounded-full text-red-700">
{amount} {packet.currencyName}
</span>
</motion.div>
)}
</AnimatePresence>
</div>
{/* 按钮区域 */}
<div className="w-full flex flex-col space-y-2">
{isFirstVisit && status === "idle" && canDraw && (
<motion.button
whileTap={{ scale: 0.95 }}
onClick={draw}
className="w-full py-3 bg-yellow-400 text-red-700 font-bold rounded-xl shadow-md"
>
</motion.button>
)}
{(!isFirstVisit || error) && (
<motion.button
whileTap={{ scale: 0.95 }}
onClick={draw}
className="w-full py-3 bg-yellow-400 text-red-700 font-bold rounded-xl shadow-md"
>
{error ?? '再抽一次'}
</motion.button>
)}
</div>
</motion.div>
</main>
)
}

View File

@@ -0,0 +1,51 @@
import Link from "next/link"
export const metadata = {
title: "tonesc 红包 - 私有红包应用",
description: "创建红包,分享给好友抽取。移动端优先,安全高精度金额,红包仅限收到链接的人抽取。",
keywords: "红包, 私有红包, 随机红包, 创建红包, 分享红包, tonesc",
robots: "index, follow",
openGraph: {
title: "Tonesc 红包 - 私有红包应用",
description: "创建红包,分享给好友抽取。移动端优先,安全高精度金额,红包仅限收到链接的人抽取。",
url: "https://redpacket.lab.tonesc.com/",
siteName: "tonesc 红包",
type: "website",
images: [
{
url: "https://redpacket.lab.tonesc.com/og-image.png",
width: 1200,
height: 630,
alt: "tonesc 红包"
}
]
},
}
export default function HomePage() {
return (
<main className="min-h-screen bg-red-50 flex flex-col items-center justify-center p-4">
<header className="mb-8 text-center">
<h1 className="text-4xl font-bold text-red-600">🎁 tonesc </h1>
<p className="text-gray-600 mt-2">
</p>
</header>
<div className="w-full max-w-sm flex flex-col items-center space-y-4">
{/* 创建红包入口 */}
<Link
href="/create"
className="w-full py-4 bg-yellow-400 text-red-700 font-bold rounded-2xl shadow-md text-center text-lg active:scale-95 transition-transform"
>
</Link>
{/* 可选的说明 */}
<p className="text-gray-500 text-sm text-center">
</p>
</div>
</main>
)
}

View File

@@ -0,0 +1,41 @@
"use server"
import { CreateRedPacketPayload } from "@/lib/types/red-packet"
import { validateCreateRedPacketPayload } from "@/lib/validate-red-packet"
import { db } from "../db"
import { redPackets } from "../schema/red-packet"
import { nanoid } from 'nanoid';
/**
* 创建红包Server Action
*/
export async function createRedPacket(
payload: CreateRedPacketPayload
): Promise<{ adminId: string }> {
/* 1⃣ 校验 */
const result = validateCreateRedPacketPayload(payload)
if (!result.ok) {
throw new Error(result.message)
}
/* 2⃣ 生成红包 ID临时方案 */
const publicId = nanoid(8) // 可分享,短
const adminId = nanoid(32) // 私有,长
await db.insert(redPackets).values({
publicId,
adminId,
count: payload.count,
currencyName: payload.currency.name,
currencyPrecision: payload.currency.precision.toString(),
rule: payload.rule,
maxDrawTimes: payload.maxDrawTimes,
})
return { adminId }
}

View File

@@ -0,0 +1,115 @@
"use server"
import { db } from "@/app/server/db"
import { redPackets } from "@/app/server/schema/red-packet"
import { redPacketDraws } from "@/app/server/schema/red-packet-draws"
import { eq, sql } from "drizzle-orm"
import { randomUUID } from "crypto"
import { getDecimalPlacesFromStep } from "@/lib/currency"
import { formatAmount } from "@/lib/format-amount"
type DrawResult =
| { ok: true; amount: string }
| { ok: false; message: string }
export async function drawRedPacket(
redPacketId: string,
userId: string
): Promise<DrawResult> {
return await db.transaction(async (tx) => {
/* 1⃣ 锁定红包行 */
const [packet] = await tx
.select()
.from(redPackets)
.where(eq(redPackets.id, redPacketId))
.for("update")
if (!packet) {
return { ok: false, message: "红包不存在" }
}
/* 2 已抽数量 & 剩余数量 */
const [{ usedCount }] = await tx
.select({
usedCount: sql<number>`COUNT(*)::int`,
})
.from(redPacketDraws)
.where(eq(redPacketDraws.redPacketId, redPacketId))
const remainingCount = packet.count - usedCount
if (remainingCount <= 0) {
return { ok: false, message: "红包已被抽完" }
}
/* 3 用户抽取次数校验 */
const [{ count }] = await tx
.select({
count: sql<number>`COUNT(*)::int`,
})
.from(redPacketDraws)
.where(
sql`${redPacketDraws.redPacketId} = ${redPacketId}
AND ${redPacketDraws.userId} = ${userId}`
)
if (count >= (packet.maxDrawTimes ?? 1)) {
return { ok: false, message: "已达到最大抽取次数" }
}
/* 4⃣ 计算剩余金额 */
const [{ usedAmount }] = await tx
.select({
usedAmount: sql<string>`COALESCE(SUM(amount), 0)`,
})
.from(redPacketDraws)
.where(eq(redPacketDraws.redPacketId, redPacketId))
const precision = getDecimalPlacesFromStep(packet.currencyPrecision);
const rule = packet.rule
let amount: string
/* 5⃣ 金额计算(关键) */
if (rule.type === "fixed") {
amount = formatAmount(rule.singleAmount, precision)
} else if (rule.type === "random") {
const min = Number(rule.min)
const max = Number(rule.max)
amount = randomByPrecision(min, max, precision)
} else {
// 拼手气
const total = Number(rule.totalAmount)
const remain = total - Number(usedAmount)
if (remainingCount === 1) {
amount = remain.toFixed(precision)
} else {
const max = remain / remainingCount * 2
amount = randomByPrecision(0.01, max, precision)
}
}
/* 6⃣ 写入抽取记录 */
await tx.insert(redPacketDraws).values({
id: randomUUID(),
redPacketId,
userId,
amount,
})
return { ok: true, amount }
})
}
function randomByPrecision(
min: number,
max: number,
precision: number
) {
const factor = Math.pow(10, precision)
const value =
Math.floor(
(Math.random() * (max - min) + min) * factor
) / factor
return value.toFixed(precision)
}

View File

@@ -0,0 +1,15 @@
"use server"
import { db } from "@/app/server/db"
import { eq, desc } from "drizzle-orm"
import { redPacketDraws } from "../schema/red-packet-draws"
export async function getRedPacketDrawsByRedPacketId(redPacketId: string) {
const draws = await db
.select()
.from(redPacketDraws)
.where(eq(redPacketDraws.redPacketId, redPacketId))
.orderBy(desc(redPacketDraws.createdAt))
return draws
}

View File

@@ -0,0 +1,25 @@
"use server"
import { db } from "@/app/server/db"
import { redPackets } from "@/app/server/schema/red-packet"
import { eq } from "drizzle-orm"
export async function getRedPacketByPublicId(id: string) {
const rows = await db
.select()
.from(redPackets)
.where(eq(redPackets.publicId, id))
.limit(1)
return rows[0] ?? null
}
export async function getRedPacketByAdminId(id: string) {
const rows = await db
.select()
.from(redPackets)
.where(eq(redPackets.adminId, id))
.limit(1)
return rows[0] ?? null
}

View File

@@ -0,0 +1,9 @@
import { drizzle } from "drizzle-orm/node-postgres"
/** @ts-ignore */
import { Pool } from "pg"
const pool = new Pool({
connectionString: process.env.DATABASE_URL,
})
export const db = drizzle(pool)

View File

@@ -0,0 +1,27 @@
import { desc } from "drizzle-orm"
import {
pgTable,
text,
numeric,
timestamp,
index,
} from "drizzle-orm/pg-core"
export const redPacketDraws = pgTable("red_packet_draws", {
id: text("id").primaryKey(),
redPacketId: text("red_packet_id").notNull(),
userId: text("user_id").notNull(),
amount: numeric("amount", {
precision: 20,
scale: 10,
}).notNull(),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
}, (t) => [
index('idx_red_packet_draws_packet_id_created_at').on(t.redPacketId, desc(t.createdAt)),
])

View File

@@ -0,0 +1,46 @@
import {
pgTable,
text,
integer,
numeric,
jsonb,
timestamp,
uniqueIndex,
index,
} from "drizzle-orm/pg-core"
import type { CreateRedPacketPayload } from "@/lib/types/red-packet"
import { sql } from "drizzle-orm"
export const redPackets = pgTable("red_packets", {
id: text("id").primaryKey().default(sql`gen_random_uuid()`),
publicId: text("public_id").notNull(),
adminId: text("admin_id").notNull(),
count: integer("count").notNull(),
currencyName: text("currency_name").notNull(),
currencyPrecision: numeric("currency_precision", {
precision: 20,
scale: 10,
}).notNull(),
rule: jsonb("rule")
.$type<CreateRedPacketPayload["rule"]>()
.notNull(),
maxDrawTimes: integer("max_draw_times"),
createdAt: timestamp("created_at", { withTimezone: true })
.defaultNow()
.notNull(),
}, (table) => [
uniqueIndex("uniq_red_packets_public_id").on(table.publicId),
uniqueIndex("uniq_red_packets_admin_id").on(table.adminId),
index("idx_red_packets_public_id").on(table.publicId),
index("idx_red_packets_admin_id").on(table.adminId),
])

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View File

@@ -0,0 +1,62 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"bg-card text-card-foreground flex flex-col gap-6 rounded-xl border py-6 shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-muted-foreground text-sm", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View File

@@ -0,0 +1,21 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Input({ className, type, ...props }: React.ComponentProps<"input">) {
return (
<input
type={type}
data-slot="input"
className={cn(
"file:text-foreground placeholder:text-muted-foreground selection:bg-primary selection:text-primary-foreground dark:bg-input/30 border-input h-9 w-full min-w-0 rounded-md border bg-transparent px-3 py-1 text-base shadow-xs transition-[color,box-shadow] outline-none file:inline-flex file:h-7 file:border-0 file:bg-transparent file:text-sm file:font-medium disabled:pointer-events-none disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
"focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px]",
"aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
className
)}
{...props}
/>
)
}
export { Input }

View File

@@ -0,0 +1,48 @@
"use client"
import * as React from "react"
import * as PopoverPrimitive from "@radix-ui/react-popover"
import { cn } from "@/lib/utils"
function Popover({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Root>) {
return <PopoverPrimitive.Root data-slot="popover" {...props} />
}
function PopoverTrigger({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Trigger>) {
return <PopoverPrimitive.Trigger data-slot="popover-trigger" {...props} />
}
function PopoverContent({
className,
align = "center",
sideOffset = 4,
...props
}: React.ComponentProps<typeof PopoverPrimitive.Content>) {
return (
<PopoverPrimitive.Portal>
<PopoverPrimitive.Content
data-slot="popover-content"
align={align}
sideOffset={sideOffset}
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 w-72 origin-(--radix-popover-content-transform-origin) rounded-md border p-4 shadow-md outline-hidden",
className
)}
{...props}
/>
</PopoverPrimitive.Portal>
)
}
function PopoverAnchor({
...props
}: React.ComponentProps<typeof PopoverPrimitive.Anchor>) {
return <PopoverPrimitive.Anchor data-slot="popover-anchor" {...props} />
}
export { Popover, PopoverTrigger, PopoverContent, PopoverAnchor }

View File

@@ -0,0 +1,190 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Select({
...props
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
return <SelectPrimitive.Root data-slot="select" {...props} />
}
function SelectGroup({
...props
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
return <SelectPrimitive.Group data-slot="select-group" {...props} />
}
function SelectValue({
...props
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
return <SelectPrimitive.Value data-slot="select-value" {...props} />
}
function SelectTrigger({
className,
size = "default",
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
size?: "sm" | "default"
}) {
return (
<SelectPrimitive.Trigger
data-slot="select-trigger"
data-size={size}
className={cn(
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDownIcon className="size-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
)
}
function SelectContent({
className,
children,
position = "item-aligned",
align = "center",
...props
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
return (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
data-slot="select-content"
className={cn(
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
align={align}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
)
}
function SelectLabel({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
return (
<SelectPrimitive.Label
data-slot="select-label"
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
{...props}
/>
)
}
function SelectItem({
className,
children,
...props
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
return (
<SelectPrimitive.Item
data-slot="select-item"
className={cn(
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
className
)}
{...props}
>
<span
data-slot="select-item-indicator"
className="absolute right-2 flex size-3.5 items-center justify-center"
>
<SelectPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
)
}
function SelectSeparator({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
return (
<SelectPrimitive.Separator
data-slot="select-separator"
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
{...props}
/>
)
}
function SelectScrollUpButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
return (
<SelectPrimitive.ScrollUpButton
data-slot="select-scroll-up-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUpIcon className="size-4" />
</SelectPrimitive.ScrollUpButton>
)
}
function SelectScrollDownButton({
className,
...props
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
return (
<SelectPrimitive.ScrollDownButton
data-slot="select-scroll-down-button"
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDownIcon className="size-4" />
</SelectPrimitive.ScrollDownButton>
)
}
export {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectScrollDownButton,
SelectScrollUpButton,
SelectSeparator,
SelectTrigger,
SelectValue,
}

View File

@@ -0,0 +1,40 @@
"use client"
import {
CircleCheckIcon,
InfoIcon,
Loader2Icon,
OctagonXIcon,
TriangleAlertIcon,
} from "lucide-react"
import { useTheme } from "next-themes"
import { Toaster as Sonner, type ToasterProps } from "sonner"
const Toaster = ({ ...props }: ToasterProps) => {
const { theme = "system" } = useTheme()
return (
<Sonner
theme={theme as ToasterProps["theme"]}
className="toaster group"
icons={{
success: <CircleCheckIcon className="size-4" />,
info: <InfoIcon className="size-4" />,
warning: <TriangleAlertIcon className="size-4" />,
error: <OctagonXIcon className="size-4" />,
loading: <Loader2Icon className="size-4 animate-spin" />,
}}
style={
{
"--normal-bg": "var(--popover)",
"--normal-text": "var(--popover-foreground)",
"--normal-border": "var(--border)",
"--border-radius": "var(--radius)",
} as React.CSSProperties
}
{...props}
/>
)
}
export { Toaster }

View File

@@ -0,0 +1,31 @@
"use client"
import * as React from "react"
import * as SwitchPrimitive from "@radix-ui/react-switch"
import { cn } from "@/lib/utils"
function Switch({
className,
...props
}: React.ComponentProps<typeof SwitchPrimitive.Root>) {
return (
<SwitchPrimitive.Root
data-slot="switch"
className={cn(
"peer data-[state=checked]:bg-primary data-[state=unchecked]:bg-input focus-visible:border-ring focus-visible:ring-ring/50 dark:data-[state=unchecked]:bg-input/80 inline-flex h-[1.15rem] w-8 shrink-0 items-center rounded-full border border-transparent shadow-xs transition-all outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<SwitchPrimitive.Thumb
data-slot="switch-thumb"
className={cn(
"bg-background dark:data-[state=unchecked]:bg-foreground dark:data-[state=checked]:bg-primary-foreground pointer-events-none block size-4 rounded-full ring-0 transition-transform data-[state=checked]:translate-x-[calc(100%-2px)] data-[state=unchecked]:translate-x-0"
)}
/>
</SwitchPrimitive.Root>
)
}
export { Switch }

View File

@@ -0,0 +1,10 @@
import type { Config } from "drizzle-kit"
export default {
dialect: "postgresql",
schema: "./app/server/schema",
out: "./drizzle",
dbCredentials: {
url: process.env.DATABASE_URL!,
}
} satisfies Config

View File

@@ -0,0 +1,9 @@
CREATE TABLE "red_packets" (
"id" text PRIMARY KEY NOT NULL,
"count" integer NOT NULL,
"currency_name" text NOT NULL,
"currency_precision" numeric(20, 10) NOT NULL,
"rule" jsonb NOT NULL,
"max_draw_times" integer,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);

View File

@@ -0,0 +1,7 @@
CREATE TABLE "red_packet_draws" (
"id" text PRIMARY KEY NOT NULL,
"red_packet_id" text NOT NULL,
"user_id" text NOT NULL,
"amount" numeric(20, 10) NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);

View File

@@ -0,0 +1,6 @@
ALTER TABLE "red_packets" ADD COLUMN "public_id" text NOT NULL;--> statement-breakpoint
ALTER TABLE "red_packets" ADD COLUMN "admin_id" text NOT NULL;--> statement-breakpoint
CREATE UNIQUE INDEX "uniq_red_packets_public_id" ON "red_packets" USING btree ("public_id");--> statement-breakpoint
CREATE UNIQUE INDEX "uniq_red_packets_admin_id" ON "red_packets" USING btree ("admin_id");--> statement-breakpoint
CREATE INDEX "idx_red_packets_public_id" ON "red_packets" USING btree ("public_id");--> statement-breakpoint
CREATE INDEX "idx_red_packets_admin_id" ON "red_packets" USING btree ("admin_id");

View File

@@ -0,0 +1 @@
ALTER TABLE "red_packets" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();

View File

@@ -0,0 +1 @@
CREATE INDEX "idx_red_packet_draws_packet_id_created_at" ON "red_packet_draws" USING btree ("red_packet_id","created_at" desc);

View File

@@ -0,0 +1,75 @@
{
"id": "4364f415-f126-4322-b8c8-7783581a1962",
"prevId": "00000000-0000-0000-0000-000000000000",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.red_packets": {
"name": "red_packets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"currency_name": {
"name": "currency_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"currency_precision": {
"name": "currency_precision",
"type": "numeric(20, 10)",
"primaryKey": false,
"notNull": true
},
"rule": {
"name": "rule",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"max_draw_times": {
"name": "max_draw_times",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,119 @@
{
"id": "9014ade0-6c3e-40da-8176-cd14eabb4ace",
"prevId": "4364f415-f126-4322-b8c8-7783581a1962",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.red_packet_draws": {
"name": "red_packet_draws",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"red_packet_id": {
"name": "red_packet_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"amount": {
"name": "amount",
"type": "numeric(20, 10)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.red_packets": {
"name": "red_packets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"currency_name": {
"name": "currency_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"currency_precision": {
"name": "currency_precision",
"type": "numeric(20, 10)",
"primaryKey": false,
"notNull": true
},
"rule": {
"name": "rule",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"max_draw_times": {
"name": "max_draw_times",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,192 @@
{
"id": "96a64af0-2aa8-4f30-b30f-c8d8fb31fc62",
"prevId": "9014ade0-6c3e-40da-8176-cd14eabb4ace",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.red_packet_draws": {
"name": "red_packet_draws",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"red_packet_id": {
"name": "red_packet_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"amount": {
"name": "amount",
"type": "numeric(20, 10)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.red_packets": {
"name": "red_packets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"public_id": {
"name": "public_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"currency_name": {
"name": "currency_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"currency_precision": {
"name": "currency_precision",
"type": "numeric(20, 10)",
"primaryKey": false,
"notNull": true
},
"rule": {
"name": "rule",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"max_draw_times": {
"name": "max_draw_times",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"uniq_red_packets_public_id": {
"name": "uniq_red_packets_public_id",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"uniq_red_packets_admin_id": {
"name": "uniq_red_packets_admin_id",
"columns": [
{
"expression": "admin_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_red_packets_public_id": {
"name": "idx_red_packets_public_id",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_red_packets_admin_id": {
"name": "idx_red_packets_admin_id",
"columns": [
{
"expression": "admin_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,193 @@
{
"id": "61988a83-95c7-4c9e-99a9-00c79bf42530",
"prevId": "96a64af0-2aa8-4f30-b30f-c8d8fb31fc62",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.red_packet_draws": {
"name": "red_packet_draws",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"red_packet_id": {
"name": "red_packet_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"amount": {
"name": "amount",
"type": "numeric(20, 10)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.red_packets": {
"name": "red_packets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"public_id": {
"name": "public_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"currency_name": {
"name": "currency_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"currency_precision": {
"name": "currency_precision",
"type": "numeric(20, 10)",
"primaryKey": false,
"notNull": true
},
"rule": {
"name": "rule",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"max_draw_times": {
"name": "max_draw_times",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"uniq_red_packets_public_id": {
"name": "uniq_red_packets_public_id",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"uniq_red_packets_admin_id": {
"name": "uniq_red_packets_admin_id",
"columns": [
{
"expression": "admin_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_red_packets_public_id": {
"name": "idx_red_packets_public_id",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_red_packets_admin_id": {
"name": "idx_red_packets_admin_id",
"columns": [
{
"expression": "admin_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,215 @@
{
"id": "11a8b31d-ae2f-40d8-81fc-8a6b3bdcdd48",
"prevId": "61988a83-95c7-4c9e-99a9-00c79bf42530",
"version": "7",
"dialect": "postgresql",
"tables": {
"public.red_packet_draws": {
"name": "red_packet_draws",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true
},
"red_packet_id": {
"name": "red_packet_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"user_id": {
"name": "user_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"amount": {
"name": "amount",
"type": "numeric(20, 10)",
"primaryKey": false,
"notNull": true
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"idx_red_packet_draws_packet_id_created_at": {
"name": "idx_red_packet_draws_packet_id_created_at",
"columns": [
{
"expression": "red_packet_id",
"isExpression": false,
"asc": true,
"nulls": "last"
},
{
"expression": "\"created_at\" desc",
"asc": true,
"isExpression": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
},
"public.red_packets": {
"name": "red_packets",
"schema": "",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"default": "gen_random_uuid()"
},
"public_id": {
"name": "public_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"admin_id": {
"name": "admin_id",
"type": "text",
"primaryKey": false,
"notNull": true
},
"count": {
"name": "count",
"type": "integer",
"primaryKey": false,
"notNull": true
},
"currency_name": {
"name": "currency_name",
"type": "text",
"primaryKey": false,
"notNull": true
},
"currency_precision": {
"name": "currency_precision",
"type": "numeric(20, 10)",
"primaryKey": false,
"notNull": true
},
"rule": {
"name": "rule",
"type": "jsonb",
"primaryKey": false,
"notNull": true
},
"max_draw_times": {
"name": "max_draw_times",
"type": "integer",
"primaryKey": false,
"notNull": false
},
"created_at": {
"name": "created_at",
"type": "timestamp with time zone",
"primaryKey": false,
"notNull": true,
"default": "now()"
}
},
"indexes": {
"uniq_red_packets_public_id": {
"name": "uniq_red_packets_public_id",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"uniq_red_packets_admin_id": {
"name": "uniq_red_packets_admin_id",
"columns": [
{
"expression": "admin_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": true,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_red_packets_public_id": {
"name": "idx_red_packets_public_id",
"columns": [
{
"expression": "public_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
},
"idx_red_packets_admin_id": {
"name": "idx_red_packets_admin_id",
"columns": [
{
"expression": "admin_id",
"isExpression": false,
"asc": true,
"nulls": "last"
}
],
"isUnique": false,
"concurrently": false,
"method": "btree",
"with": {}
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"policies": {},
"checkConstraints": {},
"isRLSEnabled": false
}
},
"enums": {},
"schemas": {},
"sequences": {},
"roles": {},
"policies": {},
"views": {},
"_meta": {
"columns": {},
"schemas": {},
"tables": {}
}
}

View File

@@ -0,0 +1,41 @@
{
"version": "7",
"dialect": "postgresql",
"entries": [
{
"idx": 0,
"version": "7",
"when": 1767774971231,
"tag": "0000_complete_shadowcat",
"breakpoints": true
},
{
"idx": 1,
"version": "7",
"when": 1767777494881,
"tag": "0001_public_bill_hollister",
"breakpoints": true
},
{
"idx": 2,
"version": "7",
"when": 1767786847604,
"tag": "0002_flashy_ultragirl",
"breakpoints": true
},
{
"idx": 3,
"version": "7",
"when": 1767791327747,
"tag": "0003_amusing_silver_surfer",
"breakpoints": true
},
{
"idx": 4,
"version": "7",
"when": 1767792389199,
"tag": "0004_overconfident_nova",
"breakpoints": true
}
]
}

View File

@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;

View File

@@ -0,0 +1,15 @@
/**
* 从最小单位step中推导小数位数
*
* "1.00000000" -> 0
* "0.01000000" -> 2
* "0.00010000" -> 4
*/
export function getDecimalPlacesFromStep(step: string): number {
const [, decimal = ""] = step.split(".")
if (!decimal) return 0
const index = decimal.search(/[1-9]/)
return index === -1 ? 0 : index + 1
}

View File

@@ -0,0 +1,12 @@
import FingerprintJS from "@fingerprintjs/fingerprintjs"
let fpPromise: Promise<string> | null = null
export async function getBrowserFingerprint(): Promise<string> {
if (!fpPromise) {
fpPromise = FingerprintJS.load().then(fp =>
fp.get().then(result => result.visitorId)
)
}
return fpPromise
}

View File

@@ -0,0 +1,19 @@
/**
* 根据精度裁剪金额字符串(不做四舍五入)
*
* amount: "2.00000000"
* precision: "2"
* => "2.00"
*/
export function formatAmount(
amount: string,
precision: number,
): string {
const [intPart, decimalPart = ""] = amount.split(".")
if (precision === 0) {
return intPart
}
return `${intPart}.${decimalPart.padEnd(precision, "0").slice(0, precision)}`
}

View File

@@ -0,0 +1,29 @@
export type CurrencyUnit = {
name: string
precision: number
}
export type FixedRule = {
type: "fixed"
singleAmount: string
}
export type RandomRule = {
type: "random"
min: string
max: string
}
export type LuckRule = {
type: "luck"
totalAmount: string
}
export type RedPacketRule = FixedRule | RandomRule | LuckRule
export type CreateRedPacketPayload = {
count: number
currency: CurrencyUnit
rule: RedPacketRule
maxDrawTimes: number | null
}

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,139 @@
import { CreateRedPacketPayload } from "./types/red-packet"
/* ---------- helpers ---------- */
function isPositiveInteger(n: number) {
return Number.isInteger(n) && n > 0
}
function decimalsFromPrecision(p: number) {
return p.toString().split(".")[1]?.length ?? 0
}
function isValidAmountByPrecision(
value: string,
precision: number
): boolean {
if (!value) return false
const num = Number(value)
if (Number.isNaN(num) || num < 0) return false
const decimals = decimalsFromPrecision(precision)
const [, frac = ""] = value.split(".")
return frac.length <= decimals
}
/* ---------- result type ---------- */
export type ValidationResult =
| { ok: true }
| { ok: false; message: string }
/* ---------- main ---------- */
export function validateCreateRedPacketPayload(
payload: CreateRedPacketPayload
): ValidationResult {
const { count, currency, rule, maxDrawTimes } = payload
/* 红包数量 */
if (!isPositiveInteger(count)) {
return { ok: false, message: "红包数量必须为正整数" }
}
/* 金额单位 */
if (!currency.name) {
return { ok: false, message: "金额单位名称不能为空" }
}
if (currency.precision <= 0) {
return { ok: false, message: "金额精度必须大于 0" }
}
/* 抽取次数 */
if (maxDrawTimes !== null) {
if (!isPositiveInteger(maxDrawTimes)) {
return {
ok: false,
message: "最大抽取次数必须为正整数",
}
}
if (maxDrawTimes > count) {
return {
ok: false,
message: "最大抽取次数不能大于红包数量",
}
}
}
/* 规则校验 */
switch (rule.type) {
case "fixed": {
if (
!isValidAmountByPrecision(
rule.singleAmount,
currency.precision
)
) {
return {
ok: false,
message: "单个红包金额不合法",
}
}
break
}
case "random": {
if (
!isValidAmountByPrecision(
rule.min,
currency.precision
) ||
!isValidAmountByPrecision(
rule.max,
currency.precision
)
) {
return {
ok: false,
message: "随机金额范围不合法",
}
}
if (Number(rule.min) > Number(rule.max)) {
return {
ok: false,
message: "最小金额不能大于最大金额",
}
}
break
}
case "luck": {
if (
!isValidAmountByPrecision(
rule.totalAmount,
currency.precision
)
) {
return {
ok: false,
message: "总金额不合法",
}
}
break
}
default: {
return {
ok: false,
message: "未知的红包规则类型",
}
}
}
return { ok: true }
}

View File

@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
/* config options here */
};
export default nextConfig;

View File

@@ -0,0 +1,52 @@
{
"name": "tonesc-red-packet",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint",
"db:generate": "drizzle-kit generate",
"db:migrate": "drizzle-kit migrate",
"db:push": "drizzle-kit push"
},
"dependencies": {
"@fingerprintjs/fingerprintjs": "^5.0.1",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@vercel/og": "^0.8.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"drizzle-orm": "^0.45.1",
"framer-motion": "^12.24.10",
"lucide-react": "^0.562.0",
"nanoid": "^5.1.6",
"next": "16.1.1",
"next-themes": "^0.4.6",
"pg": "^8.16.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"drizzle-kit": "^0.31.8",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
},
"pnpm": {
"overrides": {
"@types/pg": "8.11.6"
}
}
}

5804
tonesc-red-packet/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;

View File

@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

View File

@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

View File

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": ["./*"]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts",
"**/*.mts"
],
"exclude": ["node_modules"]
}