feat: 完成....
This commit is contained in:
85
tonesc-red-packet/app/admin/[id]/draw-list.client.tsx
Normal file
85
tonesc-red-packet/app/admin/[id]/draw-list.client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
tonesc-red-packet/app/admin/[id]/page.tsx
Normal file
88
tonesc-red-packet/app/admin/[id]/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
77
tonesc-red-packet/app/admin/[id]/public-id-section.tsx
Normal file
77
tonesc-red-packet/app/admin/[id]/public-id-section.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
404
tonesc-red-packet/app/create/page.tsx
Normal file
404
tonesc-red-packet/app/create/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,26 +1,125 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
@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);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
: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;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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",
|
||||
@@ -28,6 +29,7 @@ export default function RootLayout({
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
{children}
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
37
tonesc-red-packet/app/p/[id]/page.tsx
Normal file
37
tonesc-red-packet/app/p/[id]/page.tsx
Normal 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
|
||||
}} />
|
||||
}
|
||||
108
tonesc-red-packet/app/p/[id]/red-packet-client.tsx
Normal file
108
tonesc-red-packet/app/p/[id]/red-packet-client.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,51 @@
|
||||
import Link from "next/link"
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div></div>
|
||||
);
|
||||
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>
|
||||
)
|
||||
}
|
||||
|
||||
41
tonesc-red-packet/app/server/actions/create-red-packet.ts
Normal file
41
tonesc-red-packet/app/server/actions/create-red-packet.ts
Normal 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 }
|
||||
}
|
||||
115
tonesc-red-packet/app/server/actions/draw-red-packet.ts
Normal file
115
tonesc-red-packet/app/server/actions/draw-red-packet.ts
Normal 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)
|
||||
}
|
||||
15
tonesc-red-packet/app/server/actions/get-red-packet-draws.ts
Normal file
15
tonesc-red-packet/app/server/actions/get-red-packet-draws.ts
Normal 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
|
||||
}
|
||||
25
tonesc-red-packet/app/server/actions/get-red-packet.ts
Normal file
25
tonesc-red-packet/app/server/actions/get-red-packet.ts
Normal 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
|
||||
}
|
||||
9
tonesc-red-packet/app/server/db.ts
Normal file
9
tonesc-red-packet/app/server/db.ts
Normal 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)
|
||||
27
tonesc-red-packet/app/server/schema/red-packet-draws.ts
Normal file
27
tonesc-red-packet/app/server/schema/red-packet-draws.ts
Normal 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)),
|
||||
])
|
||||
46
tonesc-red-packet/app/server/schema/red-packet.ts
Normal file
46
tonesc-red-packet/app/server/schema/red-packet.ts
Normal 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),
|
||||
])
|
||||
22
tonesc-red-packet/components.json
Normal file
22
tonesc-red-packet/components.json
Normal 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": {}
|
||||
}
|
||||
62
tonesc-red-packet/components/ui/button.tsx
Normal file
62
tonesc-red-packet/components/ui/button.tsx
Normal 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 }
|
||||
92
tonesc-red-packet/components/ui/card.tsx
Normal file
92
tonesc-red-packet/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
21
tonesc-red-packet/components/ui/input.tsx
Normal file
21
tonesc-red-packet/components/ui/input.tsx
Normal 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 }
|
||||
48
tonesc-red-packet/components/ui/popover.tsx
Normal file
48
tonesc-red-packet/components/ui/popover.tsx
Normal 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 }
|
||||
190
tonesc-red-packet/components/ui/select.tsx
Normal file
190
tonesc-red-packet/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
40
tonesc-red-packet/components/ui/sonner.tsx
Normal file
40
tonesc-red-packet/components/ui/sonner.tsx
Normal 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 }
|
||||
31
tonesc-red-packet/components/ui/switch.tsx
Normal file
31
tonesc-red-packet/components/ui/switch.tsx
Normal 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 }
|
||||
10
tonesc-red-packet/drizzle.config.ts
Normal file
10
tonesc-red-packet/drizzle.config.ts
Normal 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
|
||||
9
tonesc-red-packet/drizzle/0000_complete_shadowcat.sql
Normal file
9
tonesc-red-packet/drizzle/0000_complete_shadowcat.sql
Normal 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
|
||||
);
|
||||
7
tonesc-red-packet/drizzle/0001_public_bill_hollister.sql
Normal file
7
tonesc-red-packet/drizzle/0001_public_bill_hollister.sql
Normal 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
|
||||
);
|
||||
6
tonesc-red-packet/drizzle/0002_flashy_ultragirl.sql
Normal file
6
tonesc-red-packet/drizzle/0002_flashy_ultragirl.sql
Normal 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");
|
||||
1
tonesc-red-packet/drizzle/0003_amusing_silver_surfer.sql
Normal file
1
tonesc-red-packet/drizzle/0003_amusing_silver_surfer.sql
Normal file
@@ -0,0 +1 @@
|
||||
ALTER TABLE "red_packets" ALTER COLUMN "id" SET DEFAULT gen_random_uuid();
|
||||
1
tonesc-red-packet/drizzle/0004_overconfident_nova.sql
Normal file
1
tonesc-red-packet/drizzle/0004_overconfident_nova.sql
Normal 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);
|
||||
75
tonesc-red-packet/drizzle/meta/0000_snapshot.json
Normal file
75
tonesc-red-packet/drizzle/meta/0000_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
119
tonesc-red-packet/drizzle/meta/0001_snapshot.json
Normal file
119
tonesc-red-packet/drizzle/meta/0001_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
192
tonesc-red-packet/drizzle/meta/0002_snapshot.json
Normal file
192
tonesc-red-packet/drizzle/meta/0002_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
193
tonesc-red-packet/drizzle/meta/0003_snapshot.json
Normal file
193
tonesc-red-packet/drizzle/meta/0003_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
215
tonesc-red-packet/drizzle/meta/0004_snapshot.json
Normal file
215
tonesc-red-packet/drizzle/meta/0004_snapshot.json
Normal 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": {}
|
||||
}
|
||||
}
|
||||
41
tonesc-red-packet/drizzle/meta/_journal.json
Normal file
41
tonesc-red-packet/drizzle/meta/_journal.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
15
tonesc-red-packet/lib/currency.ts
Normal file
15
tonesc-red-packet/lib/currency.ts
Normal 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
|
||||
}
|
||||
12
tonesc-red-packet/lib/fingerprint.ts
Normal file
12
tonesc-red-packet/lib/fingerprint.ts
Normal 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
|
||||
}
|
||||
19
tonesc-red-packet/lib/format-amount.ts
Normal file
19
tonesc-red-packet/lib/format-amount.ts
Normal 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)}`
|
||||
}
|
||||
29
tonesc-red-packet/lib/types/red-packet.ts
Normal file
29
tonesc-red-packet/lib/types/red-packet.ts
Normal 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
|
||||
}
|
||||
6
tonesc-red-packet/lib/utils.ts
Normal file
6
tonesc-red-packet/lib/utils.ts
Normal 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))
|
||||
}
|
||||
139
tonesc-red-packet/lib/validate-red-packet.ts
Normal file
139
tonesc-red-packet/lib/validate-red-packet.ts
Normal 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 }
|
||||
}
|
||||
@@ -6,21 +6,46 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
"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",
|
||||
"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"
|
||||
"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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
1637
tonesc-red-packet/pnpm-lock.yaml
generated
1637
tonesc-red-packet/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,5 +0,0 @@
|
||||
packages:
|
||||
- .
|
||||
ignoredBuiltDependencies:
|
||||
- sharp
|
||||
- unrs-resolver
|
||||
Reference in New Issue
Block a user