Compare commits
10 Commits
a9a6654977
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| ea63c83cbe | |||
| 0c6e642a33 | |||
| 25d3865a6c | |||
| b505c48a0f | |||
| 07e4df306e | |||
| 4b29089ec2 | |||
| d4ca396ab2 | |||
| 9a48cc1dee | |||
| 8bb7544b3e | |||
| 96288da150 |
@@ -1,8 +1,10 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useCallback, useState } from "react"
|
||||||
import { getRedPacketDrawsByRedPacketId } from "@/app/server/actions/get-red-packet-draws"
|
import { getRedPacketDrawsByRedPacketId } from "@/app/server/actions/get-red-packet-draws"
|
||||||
import { formatAmount } from "@/lib/format-amount"
|
import { formatAmount } from "@/lib/format-amount"
|
||||||
|
import { CreateRedPacketPayload } from "@/lib/types/red-packet"
|
||||||
|
import { formatAmountFromInt, parseAmountToInt } from "@/lib/utils"
|
||||||
|
|
||||||
type Draw = {
|
type Draw = {
|
||||||
id: string
|
id: string
|
||||||
@@ -12,10 +14,14 @@ type Draw = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function DrawList({
|
export function DrawList({
|
||||||
|
count,
|
||||||
|
rule,
|
||||||
precision,
|
precision,
|
||||||
redPacketId,
|
redPacketId,
|
||||||
initialDraws,
|
initialDraws,
|
||||||
}: {
|
}: {
|
||||||
|
count: number
|
||||||
|
rule: CreateRedPacketPayload['rule']
|
||||||
precision: number
|
precision: number
|
||||||
redPacketId: string
|
redPacketId: string
|
||||||
initialDraws: Draw[]
|
initialDraws: Draw[]
|
||||||
@@ -37,6 +43,39 @@ export function DrawList({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getBalance = useCallback(() => {
|
||||||
|
const used = draws.reduce((p, draw) => {
|
||||||
|
const pInt = parseAmountToInt(p, precision)
|
||||||
|
const dInt = parseAmountToInt(draw.amount, precision)
|
||||||
|
return formatAmountFromInt(dInt + pInt, precision)
|
||||||
|
}, '0.0');
|
||||||
|
|
||||||
|
if (rule.type === 'fixed') {
|
||||||
|
const amount = formatAmountFromInt(
|
||||||
|
parseAmountToInt(rule.singleAmount, precision) * BigInt(count),
|
||||||
|
precision
|
||||||
|
)
|
||||||
|
|
||||||
|
return formatAmountFromInt(
|
||||||
|
parseAmountToInt(amount, precision) - parseAmountToInt(used, precision),
|
||||||
|
precision,
|
||||||
|
)
|
||||||
|
} else if (rule.type === 'luck') {
|
||||||
|
const amount = formatAmountFromInt(
|
||||||
|
parseAmountToInt(rule.totalAmount, precision),
|
||||||
|
precision
|
||||||
|
)
|
||||||
|
|
||||||
|
return formatAmountFromInt(
|
||||||
|
parseAmountToInt(amount, precision) - parseAmountToInt(used, precision),
|
||||||
|
precision,
|
||||||
|
)
|
||||||
|
} else if (rule.type === 'random') {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return ''
|
||||||
|
}, [rule, draws])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<section className="rounded-lg border p-3 space-y-2">
|
<section className="rounded-lg border p-3 space-y-2">
|
||||||
{/* 标题 + 刷新按钮 */}
|
{/* 标题 + 刷新按钮 */}
|
||||||
@@ -54,6 +93,7 @@ export function DrawList({
|
|||||||
{draws.length === 0 ? (
|
{draws.length === 0 ? (
|
||||||
<div className="text-sm text-gray-400">暂无领取记录</div>
|
<div className="text-sm text-gray-400">暂无领取记录</div>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
<ul className="space-y-1">
|
<ul className="space-y-1">
|
||||||
{draws.map((draw) => (
|
{draws.map((draw) => (
|
||||||
<li
|
<li
|
||||||
@@ -79,6 +119,13 @@ export function DrawList({
|
|||||||
</li>
|
</li>
|
||||||
))}
|
))}
|
||||||
</ul>
|
</ul>
|
||||||
|
{getBalance() && <div className="border-t py-1">
|
||||||
|
<div className="text-sm">
|
||||||
|
<span>剩余:</span>
|
||||||
|
<span>{getBalance()}</span>
|
||||||
|
</div>
|
||||||
|
</div>}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,8 +3,6 @@ import { notFound } from "next/navigation"
|
|||||||
import { PublicIdSection } from "./public-id-section";
|
import { PublicIdSection } from "./public-id-section";
|
||||||
import { DrawList } from "./draw-list.client";
|
import { DrawList } from "./draw-list.client";
|
||||||
import { getRedPacketDrawsByRedPacketId } from "@/app/server/actions/get-red-packet-draws";
|
import { getRedPacketDrawsByRedPacketId } from "@/app/server/actions/get-red-packet-draws";
|
||||||
import { getDecimalPlacesFromStep } from "@/lib/currency";
|
|
||||||
import { formatAmount } from "@/lib/format-amount";
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "红包管理 - tonesc 红包",
|
title: "红包管理 - tonesc 红包",
|
||||||
@@ -37,7 +35,7 @@ export default async function AdminRedPacketPage({ params }: Props) {
|
|||||||
|
|
||||||
const initialDraws = await getRedPacketDrawsByRedPacketId(redPacketId);
|
const initialDraws = await getRedPacketDrawsByRedPacketId(redPacketId);
|
||||||
|
|
||||||
const precision = getDecimalPlacesFromStep(redPacket.currencyPrecision);
|
const precision = redPacket.currencyPrecision;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="mx-auto max-w-md p-4 space-y-4">
|
<main className="mx-auto max-w-md p-4 space-y-4">
|
||||||
@@ -57,26 +55,26 @@ export default async function AdminRedPacketPage({ params }: Props) {
|
|||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">金额单位:</span>
|
<span className="text-gray-500">金额单位:</span>
|
||||||
<span>
|
<span>
|
||||||
{redPacket.currencyName}(精度 {formatAmount(redPacket.currencyPrecision, precision)})
|
{redPacket.currencyName}(精度 {precision})
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{redPacket.maxDrawTimes !== null && (
|
|
||||||
<div>
|
<div>
|
||||||
<span className="text-gray-500">单用户最大抽取次数:</span>
|
<span className="text-gray-500">单用户最大抽取次数:</span>
|
||||||
<span>{redPacket.maxDrawTimes}</span>
|
<span>{redPacket.maxDrawTimes ?? 1}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section className="rounded-lg border p-3">
|
<section className="rounded-lg border p-3">
|
||||||
<div className="text-gray-500 mb-1">红包规则</div>
|
<div className="text-gray-500 mb-1">红包规则</div>
|
||||||
<pre className="text-sm bg-gray-50 rounded p-2 overflow-x-auto">
|
<pre className="text-sm bg-gray-50 dark:bg-gray-900 rounded p-2 overflow-x-auto">
|
||||||
{JSON.stringify(redPacket.rule, null, 2)}
|
{JSON.stringify(redPacket.rule, null, 2)}
|
||||||
</pre>
|
</pre>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<DrawList
|
<DrawList
|
||||||
|
count={redPacket.count}
|
||||||
|
rule={redPacket.rule}
|
||||||
precision={precision}
|
precision={precision}
|
||||||
redPacketId={redPacketId}
|
redPacketId={redPacketId}
|
||||||
initialDraws={initialDraws}
|
initialDraws={initialDraws}
|
||||||
|
|||||||
@@ -1,80 +0,0 @@
|
|||||||
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 })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
393
tonesc-red-packet/app/create/create-page.tsx
Normal file
393
tonesc-red-packet/app/create/create-page.tsx
Normal file
@@ -0,0 +1,393 @@
|
|||||||
|
"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"
|
||||||
|
|
||||||
|
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: 2 },
|
||||||
|
{ key: "custom", name: "自定义", precision: 0, custom: true },
|
||||||
|
]
|
||||||
|
|
||||||
|
/* ---------- utils ---------- */
|
||||||
|
function formatByPrecision(value: string, precision: number) {
|
||||||
|
if (!value) return ""
|
||||||
|
const num = Number(value)
|
||||||
|
if (Number.isNaN(num)) return ""
|
||||||
|
return num.toFixed(precision)
|
||||||
|
}
|
||||||
|
|
||||||
|
function isValidPrecision(p: string) {
|
||||||
|
const n = Number(p)
|
||||||
|
if (isNaN(n)) return false
|
||||||
|
if (n < 0 || n > 8) return false
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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")
|
||||||
|
|
||||||
|
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 dark:bg-zinc-900 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
|
||||||
|
type="number"
|
||||||
|
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="金额精度(支持0~8)">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
value={customPrecision}
|
||||||
|
onChange={(e) => {
|
||||||
|
let n = Number(e.target.value);
|
||||||
|
if (isNaN(n)) n = 0;
|
||||||
|
setCustomPrecision(`${n}`)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{!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 dark:bg-black 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,404 +1,19 @@
|
|||||||
"use client"
|
import { CreateRedPacketPage } from "./create-page";
|
||||||
|
|
||||||
import { useState } from "react"
|
export const metadata = {
|
||||||
import { Input } from "@/components/ui/input"
|
title: "创建红包 - tonesc 红包",
|
||||||
import { Button } from "@/components/ui/button"
|
description: "创建私有红包,支持多种金额规则、高精度金额和单用户抽取次数设置,立即生成可分享链接。",
|
||||||
import {
|
keywords: "创建红包, 私有红包, 随机红包, 固定红包, 拼手气红包, tonesc",
|
||||||
Select,
|
robots: "noindex, nofollow",
|
||||||
SelectContent,
|
openGraph: {
|
||||||
SelectItem,
|
title: "创建红包 - Tonesc 红包",
|
||||||
SelectTrigger,
|
description: "创建私有红包,支持多种金额规则、高精度金额和单用户抽取次数设置,立即生成可分享链接。",
|
||||||
SelectValue,
|
url: "https://redpacket.lab.tonesc.com/create",
|
||||||
} from "@/components/ui/select"
|
siteName: "Tonesc 红包",
|
||||||
import { CreateRedPacketPayload } from "@/lib/types/red-packet"
|
type: "website"
|
||||||
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 CreatePage() {
|
||||||
|
return <CreateRedPacketPage />
|
||||||
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,7 +1,6 @@
|
|||||||
import { notFound } from "next/navigation"
|
import { notFound } from "next/navigation"
|
||||||
import RedPacketClient from "./red-packet-client"
|
import RedPacketClient from "./red-packet-client"
|
||||||
import { getRedPacketByPublicId } from "@/app/server/actions/get-red-packet";
|
import { getRedPacketByPublicId } from "@/app/server/actions/get-red-packet";
|
||||||
import { getDecimalPlacesFromStep } from "@/lib/currency";
|
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: "抽红包 - tonesc 红包",
|
title: "抽红包 - tonesc 红包",
|
||||||
@@ -29,7 +28,7 @@ export default async function RedPacketPage({ params }: Props) {
|
|||||||
if (!packet) notFound()
|
if (!packet) notFound()
|
||||||
|
|
||||||
const { id, currencyName, currencyPrecision, maxDrawTimes } = packet;
|
const { id, currencyName, currencyPrecision, maxDrawTimes } = packet;
|
||||||
const precision = getDecimalPlacesFromStep(currencyPrecision);
|
const precision = currencyPrecision;
|
||||||
|
|
||||||
return <RedPacketClient packet={{
|
return <RedPacketClient packet={{
|
||||||
id, currencyName, precision, maxDrawTimes
|
id, currencyName, precision, maxDrawTimes
|
||||||
|
|||||||
@@ -29,8 +29,8 @@ export async function createRedPacket(
|
|||||||
|
|
||||||
count: payload.count,
|
count: payload.count,
|
||||||
|
|
||||||
currencyName: payload.currency.name,
|
currencyName: payload.currency.name.trim(),
|
||||||
currencyPrecision: payload.currency.precision.toString(),
|
currencyPrecision: payload.currency.precision,
|
||||||
|
|
||||||
rule: payload.rule,
|
rule: payload.rule,
|
||||||
|
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { redPackets } from "@/app/server/schema/red-packet"
|
|||||||
import { redPacketDraws } from "@/app/server/schema/red-packet-draws"
|
import { redPacketDraws } from "@/app/server/schema/red-packet-draws"
|
||||||
import { eq, sql } from "drizzle-orm"
|
import { eq, sql } from "drizzle-orm"
|
||||||
import { randomUUID } from "crypto"
|
import { randomUUID } from "crypto"
|
||||||
import { getDecimalPlacesFromStep } from "@/lib/currency"
|
|
||||||
import { formatAmount } from "@/lib/format-amount"
|
import { formatAmount } from "@/lib/format-amount"
|
||||||
|
import { formatAmountFromInt, parseAmountToInt, randomBigInt } from "@/lib/utils"
|
||||||
|
|
||||||
type DrawResult =
|
type DrawResult =
|
||||||
| { ok: true; amount: string }
|
| { ok: true; amount: string }
|
||||||
@@ -64,30 +64,69 @@ export async function drawRedPacket(
|
|||||||
.from(redPacketDraws)
|
.from(redPacketDraws)
|
||||||
.where(eq(redPacketDraws.redPacketId, redPacketId))
|
.where(eq(redPacketDraws.redPacketId, redPacketId))
|
||||||
|
|
||||||
const precision = getDecimalPlacesFromStep(packet.currencyPrecision);
|
const precision = packet.currencyPrecision;
|
||||||
const rule = packet.rule
|
const rule = packet.rule
|
||||||
|
|
||||||
let amount: string
|
let amount: string
|
||||||
|
|
||||||
/* 5️⃣ 金额计算(关键) */
|
/* 5️⃣ 金额计算(关键) */
|
||||||
if (rule.type === "fixed") {
|
if (rule.type === "fixed") {
|
||||||
amount = formatAmount(rule.singleAmount, precision)
|
amount = formatAmount(rule.singleAmount, precision)
|
||||||
} else if (rule.type === "random") {
|
} else if (rule.type === "random") {
|
||||||
const min = Number(rule.min)
|
const minInt = parseAmountToInt(rule.min, precision)
|
||||||
const max = Number(rule.max)
|
const maxInt = parseAmountToInt(rule.max, precision)
|
||||||
amount = randomByPrecision(min, max, precision)
|
|
||||||
|
if (minInt < 0n || maxInt < 0n) {
|
||||||
|
throw new Error("Invalid min/max: negative amount")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (minInt > maxInt) {
|
||||||
|
throw new Error("Invalid min/max: min > max")
|
||||||
|
}
|
||||||
|
|
||||||
|
const amountInt = randomBigInt(minInt, maxInt)
|
||||||
|
|
||||||
|
amount = formatAmountFromInt(amountInt, precision)
|
||||||
} else {
|
} else {
|
||||||
// 拼手气
|
// 拼手气
|
||||||
const total = Number(rule.totalAmount)
|
const totalInt = parseAmountToInt(rule.totalAmount, precision)
|
||||||
const remain = total - Number(usedAmount)
|
const usedInt = parseAmountToInt(usedAmount, precision)
|
||||||
|
|
||||||
|
const remainInt = totalInt - usedInt
|
||||||
|
if (remainInt < 0n) {
|
||||||
|
throw new Error("Invalid state: remaining amount < 0")
|
||||||
|
}
|
||||||
|
|
||||||
|
let amountInt: bigint
|
||||||
|
|
||||||
if (remainingCount === 1) {
|
if (remainingCount === 1) {
|
||||||
amount = remain.toFixed(precision)
|
amountInt = remainInt
|
||||||
} else {
|
} else {
|
||||||
const max = remain / remainingCount * 2
|
const count = BigInt(remainingCount)
|
||||||
amount = randomByPrecision(0.01, max, precision)
|
const MIN_UNIT = 1n // 1 分 / 1 sat / 1 最小单位
|
||||||
|
|
||||||
|
// max = (remain / remainingCount) * 2
|
||||||
|
const maxInt = (remainInt / count) * 2n
|
||||||
|
|
||||||
|
if (maxInt < MIN_UNIT) {
|
||||||
|
// 剩余过小,防御性兜底
|
||||||
|
amountInt = MIN_UNIT
|
||||||
|
} else {
|
||||||
|
amountInt = randomBigInt(MIN_UNIT, maxInt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (amountInt < 0n) {
|
||||||
|
throw new Error("Invalid amount: negative")
|
||||||
|
}
|
||||||
|
|
||||||
|
if (amountInt > remainInt) {
|
||||||
|
throw new Error("Invalid amount: exceeds remaining")
|
||||||
|
}
|
||||||
|
|
||||||
|
amount = formatAmountFromInt(amountInt, precision)
|
||||||
|
}
|
||||||
|
|
||||||
/* 6️⃣ 写入抽取记录 */
|
/* 6️⃣ 写入抽取记录 */
|
||||||
await tx.insert(redPacketDraws).values({
|
await tx.insert(redPacketDraws).values({
|
||||||
id: randomUUID(),
|
id: randomUUID(),
|
||||||
@@ -99,17 +138,3 @@ export async function drawRedPacket(
|
|||||||
return { ok: true, 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,8 +15,8 @@ export const redPacketDraws = pgTable("red_packet_draws", {
|
|||||||
userId: text("user_id").notNull(),
|
userId: text("user_id").notNull(),
|
||||||
|
|
||||||
amount: numeric("amount", {
|
amount: numeric("amount", {
|
||||||
precision: 20,
|
precision: 38,
|
||||||
scale: 10,
|
scale: 8,
|
||||||
}).notNull(),
|
}).notNull(),
|
||||||
|
|
||||||
createdAt: timestamp("created_at", { withTimezone: true })
|
createdAt: timestamp("created_at", { withTimezone: true })
|
||||||
|
|||||||
@@ -21,10 +21,7 @@ export const redPackets = pgTable("red_packets", {
|
|||||||
count: integer("count").notNull(),
|
count: integer("count").notNull(),
|
||||||
|
|
||||||
currencyName: text("currency_name").notNull(),
|
currencyName: text("currency_name").notNull(),
|
||||||
currencyPrecision: numeric("currency_precision", {
|
currencyPrecision: integer("currency_precision").notNull(),
|
||||||
precision: 20,
|
|
||||||
scale: 10,
|
|
||||||
}).notNull(),
|
|
||||||
|
|
||||||
rule: jsonb("rule")
|
rule: jsonb("rule")
|
||||||
.$type<CreateRedPacketPayload["rule"]>()
|
.$type<CreateRedPacketPayload["rule"]>()
|
||||||
|
|||||||
1
tonesc-red-packet/drizzle/0005_quiet_leech.sql
Normal file
1
tonesc-red-packet/drizzle/0005_quiet_leech.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "red_packet_draws" ALTER COLUMN "amount" SET DATA TYPE numeric(38, 8);
|
||||||
1
tonesc-red-packet/drizzle/0006_rare_maginty.sql
Normal file
1
tonesc-red-packet/drizzle/0006_rare_maginty.sql
Normal file
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE "red_packets" ALTER COLUMN "currency_precision" SET DATA TYPE integer;
|
||||||
215
tonesc-red-packet/drizzle/meta/0005_snapshot.json
Normal file
215
tonesc-red-packet/drizzle/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
{
|
||||||
|
"id": "d833ecb6-de56-4777-8907-a8d8d7737a24",
|
||||||
|
"prevId": "11a8b31d-ae2f-40d8-81fc-8a6b3bdcdd48",
|
||||||
|
"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(38, 8)",
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
215
tonesc-red-packet/drizzle/meta/0006_snapshot.json
Normal file
215
tonesc-red-packet/drizzle/meta/0006_snapshot.json
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
{
|
||||||
|
"id": "c91ad204-8ce6-4029-9490-763ed4dcc177",
|
||||||
|
"prevId": "d833ecb6-de56-4777-8907-a8d8d7737a24",
|
||||||
|
"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(38, 8)",
|
||||||
|
"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": "integer",
|
||||||
|
"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": {}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -36,6 +36,20 @@
|
|||||||
"when": 1767792389199,
|
"when": 1767792389199,
|
||||||
"tag": "0004_overconfident_nova",
|
"tag": "0004_overconfident_nova",
|
||||||
"breakpoints": true
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 5,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1767924016559,
|
||||||
|
"tag": "0005_quiet_leech",
|
||||||
|
"breakpoints": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idx": 6,
|
||||||
|
"version": "7",
|
||||||
|
"when": 1767924109495,
|
||||||
|
"tag": "0006_rare_maginty",
|
||||||
|
"breakpoints": true
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
/**
|
|
||||||
* 从最小单位(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
|
|
||||||
}
|
|
||||||
@@ -4,3 +4,48 @@ import { twMerge } from "tailwind-merge"
|
|||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function parseAmountToInt(amountStr: string, precision: number): bigint {
|
||||||
|
// 例:"123.45", precision=2 → 12345n
|
||||||
|
// 例:"0.00001234", precision=8 → 1234n
|
||||||
|
|
||||||
|
// 去掉正负号处理(如不需要可删)
|
||||||
|
const negative = amountStr.startsWith("-")
|
||||||
|
const normalizedStr = negative ? amountStr.slice(1) : amountStr
|
||||||
|
|
||||||
|
const [intPart, fracRaw = ""] = normalizedStr.split(".")
|
||||||
|
|
||||||
|
// 关键修复点:允许尾随 0
|
||||||
|
const fracTrimmed = fracRaw.replace(/0+$/, "")
|
||||||
|
|
||||||
|
if (fracTrimmed.length > precision) {
|
||||||
|
throw new Error(
|
||||||
|
`Amount ${amountStr} exceeds allowed precision ${precision}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const paddedFrac = fracTrimmed.padEnd(precision, "0")
|
||||||
|
const combined = intPart + paddedFrac
|
||||||
|
|
||||||
|
const value = BigInt(combined)
|
||||||
|
|
||||||
|
return negative ? -value : value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatAmountFromInt(value: bigint, precision: number): string {
|
||||||
|
const s = value.toString().padStart(precision + 1, "0")
|
||||||
|
|
||||||
|
if (precision === 0) return s
|
||||||
|
|
||||||
|
const i = s.length - precision
|
||||||
|
return `${s.slice(0, i)}.${s.slice(i)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function randomBigInt(min: bigint, max: bigint): bigint {
|
||||||
|
if (max < min) throw new Error("max < min")
|
||||||
|
|
||||||
|
const range = max - min + 1n
|
||||||
|
const rand = BigInt(Math.floor(Math.random() * Number(range)))
|
||||||
|
|
||||||
|
return min + rand
|
||||||
|
}
|
||||||
@@ -6,10 +6,6 @@ function isPositiveInteger(n: number) {
|
|||||||
return Number.isInteger(n) && n > 0
|
return Number.isInteger(n) && n > 0
|
||||||
}
|
}
|
||||||
|
|
||||||
function decimalsFromPrecision(p: number) {
|
|
||||||
return p.toString().split(".")[1]?.length ?? 0
|
|
||||||
}
|
|
||||||
|
|
||||||
function isValidAmountByPrecision(
|
function isValidAmountByPrecision(
|
||||||
value: string,
|
value: string,
|
||||||
precision: number
|
precision: number
|
||||||
@@ -18,9 +14,8 @@ function isValidAmountByPrecision(
|
|||||||
const num = Number(value)
|
const num = Number(value)
|
||||||
if (Number.isNaN(num) || num < 0) return false
|
if (Number.isNaN(num) || num < 0) return false
|
||||||
|
|
||||||
const decimals = decimalsFromPrecision(precision)
|
|
||||||
const [, frac = ""] = value.split(".")
|
const [, frac = ""] = value.split(".")
|
||||||
return frac.length <= decimals
|
return frac.length <= precision
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ---------- result type ---------- */
|
/* ---------- result type ---------- */
|
||||||
@@ -42,12 +37,12 @@ export function validateCreateRedPacketPayload(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* 金额单位 */
|
/* 金额单位 */
|
||||||
if (!currency.name) {
|
if (!currency.name.trim()) {
|
||||||
return { ok: false, message: "金额单位名称不能为空" }
|
return { ok: false, message: "金额单位名称不能为空" }
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currency.precision <= 0) {
|
if (currency.precision < 0 || currency.precision > 8) {
|
||||||
return { ok: false, message: "金额精度必须大于 0" }
|
return { ok: false, message: "金额精度必须大于 0 且小于 8" }
|
||||||
}
|
}
|
||||||
|
|
||||||
/* 抽取次数 */
|
/* 抽取次数 */
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2020",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
|
|||||||
Reference in New Issue
Block a user