diff --git a/tonesc-red-packet/app/admin/[id]/draw-list.client.tsx b/tonesc-red-packet/app/admin/[id]/draw-list.client.tsx
new file mode 100644
index 0000000..c604b5a
--- /dev/null
+++ b/tonesc-red-packet/app/admin/[id]/draw-list.client.tsx
@@ -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 (
+
+ {/* 标题 + 刷新按钮 */}
+
+
领取记录
+
+
+
+ {draws.length === 0 ? (
+ 暂无领取记录
+ ) : (
+
+ )}
+
+ )
+}
diff --git a/tonesc-red-packet/app/admin/[id]/page.tsx b/tonesc-red-packet/app/admin/[id]/page.tsx
new file mode 100644
index 0000000..8800c93
--- /dev/null
+++ b/tonesc-red-packet/app/admin/[id]/page.tsx
@@ -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 (
+
+ 红包管理
+
+
+
+ 红包 ID:
+ {redPacket.id}
+
+
+
+ 红包数量:
+ {redPacket.count}
+
+
+
+ 金额单位:
+
+ {redPacket.currencyName}(精度 {formatAmount(redPacket.currencyPrecision, precision)})
+
+
+
+ {redPacket.maxDrawTimes !== null && (
+
+ 单用户最大抽取次数:
+ {redPacket.maxDrawTimes}
+
+ )}
+
+
+
+ 红包规则
+
+ {JSON.stringify(redPacket.rule, null, 2)}
+
+
+
+
+
+
+
+ )
+}
diff --git a/tonesc-red-packet/app/admin/[id]/public-id-section.tsx b/tonesc-red-packet/app/admin/[id]/public-id-section.tsx
new file mode 100644
index 0000000..4a8a37f
--- /dev/null
+++ b/tonesc-red-packet/app/admin/[id]/public-id-section.tsx
@@ -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
加载中...
;
+ }
+
+ return (
+
+ 用户抽红包页面
+
+
+
+ {userPageUrl}
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
\ No newline at end of file
diff --git a/tonesc-red-packet/app/create/page.tsx b/tonesc-red-packet/app/create/page.tsx
new file mode 100644
index 0000000..7b98ddc
--- /dev/null
+++ b/tonesc-red-packet/app/create/page.tsx
@@ -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("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 (
+
+ )
+}
+
+/* ---------- 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/globals.css b/tonesc-red-packet/app/globals.css
index a2dc41e..0bc2dea 100644
--- a/tonesc-red-packet/app/globals.css
+++ b/tonesc-red-packet/app/globals.css
@@ -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;
-}
diff --git a/tonesc-red-packet/app/layout.tsx b/tonesc-red-packet/app/layout.tsx
index f7fa87e..2497c1a 100644
--- a/tonesc-red-packet/app/layout.tsx
+++ b/tonesc-red-packet/app/layout.tsx
@@ -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}
+