From d4ca396ab243d8407b70e8450ac7e62b153e54f4 Mon Sep 17 00:00:00 2001 From: tone Date: Fri, 9 Jan 2026 10:23:09 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=96=B0=E7=9A=84?= =?UTF-8?q?=E9=87=91=E9=A2=9D=E8=A1=A8=E7=A4=BA=E6=A0=BC=E5=BC=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tonesc-red-packet/app/admin/[id]/page.tsx | 6 +- tonesc-red-packet/app/create/create-page.tsx | 393 +++++++++++++++++ tonesc-red-packet/app/create/page.tsx | 415 +----------------- tonesc-red-packet/app/p/[id]/page.tsx | 3 +- .../app/server/actions/create-red-packet.ts | 2 +- .../app/server/actions/draw-red-packet.ts | 3 +- tonesc-red-packet/lib/currency.ts | 15 - tonesc-red-packet/lib/validate-red-packet.ts | 11 +- 8 files changed, 416 insertions(+), 432 deletions(-) create mode 100644 tonesc-red-packet/app/create/create-page.tsx delete mode 100644 tonesc-red-packet/lib/currency.ts diff --git a/tonesc-red-packet/app/admin/[id]/page.tsx b/tonesc-red-packet/app/admin/[id]/page.tsx index 8800c93..556be28 100644 --- a/tonesc-red-packet/app/admin/[id]/page.tsx +++ b/tonesc-red-packet/app/admin/[id]/page.tsx @@ -3,8 +3,6 @@ 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 红包", @@ -37,7 +35,7 @@ export default async function AdminRedPacketPage({ params }: Props) { const initialDraws = await getRedPacketDrawsByRedPacketId(redPacketId); - const precision = getDecimalPlacesFromStep(redPacket.currencyPrecision); + const precision = redPacket.currencyPrecision; return (
@@ -57,7 +55,7 @@ export default async function AdminRedPacketPage({ params }: Props) {
金额单位: - {redPacket.currencyName}(精度 {formatAmount(redPacket.currencyPrecision, precision)}) + {redPacket.currencyName}(精度 {precision})
diff --git a/tonesc-red-packet/app/create/create-page.tsx b/tonesc-red-packet/app/create/create-page.tsx new file mode 100644 index 0000000..b819f5d --- /dev/null +++ b/tonesc-red-packet/app/create/create-page.tsx @@ -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("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 ( +
+
+

创建红包

+ + + + setCount(e.target.value)} + onBlur={() => { + if (!count || Number(count) < 1) setCount("1") + }} + /> + + + + + + + {preset.custom && ( + <> + + setCustomUnitName(e.target.value)} + /> + + + + { + let n = Number(e.target.value); + if (isNaN(n)) n = 0; + setCustomPrecision(`${n}`) + }} + /> + {!isValidPrecision(customPrecision) && ( +

精度格式不合法

+ )} +
+ + )} +
+ + + + + {ruleType === "fixed" && ( + + )} + + {ruleType === "random" && ( + <> + + + + )} + + {ruleType === "luck" && ( + + )} + + + {parsedCount >= 2 && ( + + + setMaxDrawTimes(e.target.value)} + /> + + + )} + + {renderTotalEstimate() && ( +
+ {renderTotalEstimate()} +
+ )} + + +
+
+ ) +} + +/* ---------- UI helpers ---------- */ + +function Card({ children }: { children: React.ReactNode }) { + return
{children}
+} + +function Field({ + label, + children, +}: { + label: string + children: React.ReactNode +}) { + return ( +
+ + {children} +
+ ) +} + +function MoneyInput({ + label, + value, + precision, + onChange, +}: { + label: string + value: string + precision: number + onChange: (v: string) => void +}) { + return ( + + onChange(e.target.value)} + onBlur={() => onChange(formatByPrecision(value, precision))} + placeholder={`精度 ${precision}`} + /> + + ) +} + +function RuleSelector({ + value, + onChange, +}: { + value: RuleType + onChange: (v: RuleType) => void +}) { + const rules = [ + { type: "fixed", label: "固定" }, + { type: "random", label: "随机" }, + { type: "luck", label: "拼手气" }, + ] as const + + return ( +
+ {rules.map((r) => ( + + ))} +
+ ) +} diff --git a/tonesc-red-packet/app/create/page.tsx b/tonesc-red-packet/app/create/page.tsx index 7b98ddc..47ff85d 100644 --- a/tonesc-red-packet/app/create/page.tsx +++ b/tonesc-red-packet/app/create/page.tsx @@ -1,404 +1,19 @@ -"use client" +import { CreateRedPacketPage } from "./create-page"; -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, +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" } } -/* ---------- 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("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 ( -
-
-

创建红包

- - - - setCount(e.target.value)} - onBlur={() => { - if (!count || Number(count) < 1) setCount("1") - }} - /> - - - - - - - {preset.custom && ( - <> - - setCustomUnitName(e.target.value)} - /> - - - - setCustomPrecision(e.target.value)} - /> - {!isValidPrecision(customPrecision) && ( -

精度格式不合法

- )} -
- - )} -
- - - - - {ruleType === "fixed" && ( - - )} - - {ruleType === "random" && ( - <> - - - - )} - - {ruleType === "luck" && ( - - )} - - - {parsedCount >= 2 && ( - - - setMaxDrawTimes(e.target.value)} - /> - - - )} - - {renderTotalEstimate() && ( -
- {renderTotalEstimate()} -
- )} - - -
-
- ) -} - -/* ---------- UI helpers ---------- */ - -function Card({ children }: { children: React.ReactNode }) { - return
{children}
-} - -function Field({ - label, - children, -}: { - label: string - children: React.ReactNode -}) { - return ( -
- - {children} -
- ) -} - -function MoneyInput({ - label, - value, - precision, - onChange, -}: { - label: string - value: string - precision: number - onChange: (v: string) => void -}) { - return ( - - onChange(e.target.value)} - onBlur={() => onChange(formatByPrecision(value, precision))} - placeholder={`精度 ${precision}`} - /> - - ) -} - -function RuleSelector({ - value, - onChange, -}: { - value: RuleType - onChange: (v: RuleType) => void -}) { - const rules = [ - { type: "fixed", label: "固定" }, - { type: "random", label: "随机" }, - { type: "luck", label: "拼手气" }, - ] as const - - return ( -
- {rules.map((r) => ( - - ))} -
- ) -} +export default function CreatePage() { + return +} \ No newline at end of file diff --git a/tonesc-red-packet/app/p/[id]/page.tsx b/tonesc-red-packet/app/p/[id]/page.tsx index d29c03c..6dd516d 100644 --- a/tonesc-red-packet/app/p/[id]/page.tsx +++ b/tonesc-red-packet/app/p/[id]/page.tsx @@ -1,7 +1,6 @@ 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 红包", @@ -29,7 +28,7 @@ export default async function RedPacketPage({ params }: Props) { if (!packet) notFound() const { id, currencyName, currencyPrecision, maxDrawTimes } = packet; - const precision = getDecimalPlacesFromStep(currencyPrecision); + const precision = currencyPrecision; return 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 -} diff --git a/tonesc-red-packet/lib/validate-red-packet.ts b/tonesc-red-packet/lib/validate-red-packet.ts index 5e41b98..40081b5 100644 --- a/tonesc-red-packet/lib/validate-red-packet.ts +++ b/tonesc-red-packet/lib/validate-red-packet.ts @@ -6,10 +6,6 @@ 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 @@ -18,9 +14,8 @@ function isValidAmountByPrecision( const num = Number(value) if (Number.isNaN(num) || num < 0) return false - const decimals = decimalsFromPrecision(precision) const [, frac = ""] = value.split(".") - return frac.length <= decimals + return frac.length <= precision } /* ---------- result type ---------- */ @@ -46,8 +41,8 @@ export function validateCreateRedPacketPayload( return { ok: false, message: "金额单位名称不能为空" } } - if (currency.precision <= 0) { - return { ok: false, message: "金额精度必须大于 0" } + if (currency.precision < 0 || currency.precision > 8) { + return { ok: false, message: "金额精度必须大于 0 且小于 8" } } /* 抽取次数 */