Просмотр исходного кода

✨ feat(admin): 新增营销管理模块

- 添加营销管理菜单,包含【代金券批次】、【微信代金券】、【兑换码】三个子菜单
- 实现兑换码管理功能,支持创建、编辑、批量生成、导出等操作
- 实现微信代金券批次管理,支持创建、编辑、状态管理等操作
- 实现微信代金券管理,支持查看已发放代金券详情
- 添加Gift、QrCode、CreditCard等图标支持营销相关功能
yourname 5 месяцев назад
Родитель
Сommit
c904ea195c

+ 32 - 1
src/client/admin-shadcn/menu.tsx

@@ -9,7 +9,10 @@ import {
   BarChart3,
   LayoutDashboard,
   File,
-  Ticket
+  Ticket,
+  Gift,
+  QrCode,
+  CreditCard
 } from 'lucide-react';
 
 export interface MenuItem {
@@ -109,6 +112,34 @@ export const useMenu = () => {
       path: '/admin/coupon-logs',
       permission: 'coupon:view'
     },
+    {
+      key: 'marketing',
+      label: '营销管理',
+      icon: <Gift className="h-4 w-4" />,
+      children: [
+        {
+          key: 'wechat-coupon-stocks',
+          label: '代金券批次',
+          icon: <CreditCard className="h-4 w-4" />,
+          path: '/admin/wechat-coupon-stocks',
+          permission: 'coupon:manage'
+        },
+        {
+          key: 'wechat-coupons',
+          label: '微信代金券',
+          icon: <CreditCard className="h-4 w-4" />,
+          path: '/admin/wechat-coupons',
+          permission: 'coupon:view'
+        },
+        {
+          key: 'redemption-codes',
+          label: '兑换码',
+          icon: <QrCode className="h-4 w-4" />,
+          path: '/admin/redemption-codes',
+          permission: 'coupon:manage'
+        }
+      ]
+    },
     {
       key: 'settings',
       label: '系统设置',

+ 714 - 0
src/client/admin-shadcn/pages/RedemptionCodes.tsx

@@ -0,0 +1,714 @@
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { format } from 'date-fns'
+import { zhCN } from 'date-fns/locale'
+import { Plus, Search, Edit, Trash2, Download, Upload } from 'lucide-react'
+import { toast } from 'sonner'
+import type { InferRequestType, InferResponseType } from 'hono/client'
+
+import { redemptionCodeClient } from '@/client/api'
+import { CreateRedemptionCodeDto, UpdateRedemptionCodeDto } from '@/server/modules/redemption-codes/redemption-code.schema'
+import { Button } from '@/client/components/ui/button'
+import { Input } from '@/client/components/ui/input'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table'
+import { Badge } from '@/client/components/ui/badge'
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog'
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select'
+import { Textarea } from '@/client/components/ui/textarea'
+import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination'
+import { wechatCouponStockClient } from '@/client/api'
+
+type CreateRequest = InferRequestType<typeof redemptionCodeClient.$post>['json']
+type UpdateRequest = InferRequestType<typeof redemptionCodeClient[':id']['$put']>['json']
+type CodeResponse = InferResponseType<typeof redemptionCodeClient.$get, 200>['data'][0]
+
+export const RedemptionCodesPage = () => {
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    search: '',
+    status: '',
+    batchId: '',
+  })
+  const [isModalOpen, setIsModalOpen] = useState(false)
+  const [editingData, setEditingData] = useState<CodeResponse | null>(null)
+  const [isCreateForm, setIsCreateForm] = useState(true)
+  const [isBulkModalOpen, setIsBulkModalOpen] = useState(false)
+
+  // 获取代金券批次列表
+  const { data: stockData } = useQuery({
+    queryKey: ['wechat-coupon-stocks-all'],
+    queryFn: async () => {
+      const res = await wechatCouponStockClient.$get({
+        query: { page: 1, pageSize: 100 },
+      })
+      if (res.status !== 200) throw new Error('获取批次失败')
+      return await res.json()
+    },
+  })
+
+  // 获取兑换码列表
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['redemption-codes', searchParams],
+    queryFn: async () => {
+      const filters: any = {}
+      if (searchParams.status) filters.status = searchParams.status
+      if (searchParams.batchId) filters.batchId = Number(searchParams.batchId)
+
+      const res = await redemptionCodeClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search,
+          ...(Object.keys(filters).length > 0 && { filters: JSON.stringify(filters) }),
+        },
+      })
+      if (res.status !== 200) throw new Error('获取列表失败')
+      return await res.json()
+    },
+  })
+
+  // 表单实例
+  const createForm = useForm<CreateRequest>({
+    resolver: zodResolver(CreateRedemptionCodeDto),
+    defaultValues: {
+      code: '',
+      batchId: 0,
+      amount: 100,
+      validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
+      maxRedemptions: 1,
+      redemptionResult: '',
+      isActive: 1,
+    },
+  })
+
+  const bulkForm = useForm({
+    defaultValues: {
+      count: 10,
+      batchId: 0,
+      amount: 100,
+      prefix: 'CODE',
+      validUntil: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000),
+      maxRedemptions: 1,
+    },
+  })
+
+  const updateForm = useForm<UpdateRequest>({
+    resolver: zodResolver(UpdateRedemptionCodeDto),
+    defaultValues: {},
+  })
+
+  // 打开创建表单
+  const handleCreate = () => {
+    setEditingData(null)
+    setIsCreateForm(true)
+    createForm.reset()
+    setIsModalOpen(true)
+  }
+
+  // 打开编辑表单
+  const handleEdit = (data: CodeResponse) => {
+    setEditingData(data)
+    setIsCreateForm(false)
+    updateForm.reset({
+      code: data.code,
+      batchId: data.batchId,
+      amount: data.amount,
+      validUntil: new Date(data.validUntil),
+      maxRedemptions: data.maxRedemptions,
+      redemptionResult: data.redemptionResult,
+      isActive: data.isActive,
+    })
+    setIsModalOpen(true)
+  }
+
+  // 打开批量创建表单
+  const handleBulkCreate = () => {
+    bulkForm.reset()
+    setIsBulkModalOpen(true)
+  }
+
+  // 提交创建
+  const handleCreateSubmit = async (values: CreateRequest) => {
+    try {
+      const res = await redemptionCodeClient.$post({ json: values })
+      if (res.status !== 201) throw new Error('创建失败')
+      toast.success('创建成功')
+      setIsModalOpen(false)
+      refetch()
+    } catch (error) {
+      toast.error('创建失败,请重试')
+    }
+  }
+
+  // 提交批量创建
+  const handleBulkCreateSubmit = async (values: any) => {
+    try {
+      const res = await redemptionCodeClient['bulk-create']['$post']({ json: values })
+      if (res.status !== 201) throw new Error('批量创建失败')
+      const result = await res.json()
+      toast.success(`成功创建 ${result.count} 个兑换码`)
+      setIsBulkModalOpen(false)
+      refetch()
+    } catch (error) {
+      toast.error('批量创建失败,请重试')
+    }
+  }
+
+  // 提交更新
+  const handleUpdateSubmit = async (values: UpdateRequest) => {
+    if (!editingData) return
+    
+    try {
+      const res = await redemptionCodeClient[':id']['$put']({
+        param: { id: editingData.id },
+        json: values,
+      })
+      if (res.status !== 200) throw new Error('更新失败')
+      toast.success('更新成功')
+      setIsModalOpen(false)
+      refetch()
+    } catch (error) {
+      toast.error('更新失败,请重试')
+    }
+  }
+
+  // 处理删除
+  const handleDelete = async (id: number) => {
+    try {
+      const res = await redemptionCodeClient[':id']['$delete']({ param: { id } })
+      if (res.status !== 200) throw new Error('删除失败')
+      toast.success('删除成功')
+      refetch()
+    } catch (error) {
+      toast.error('删除失败,请重试')
+    }
+  }
+
+  // 处理搜索
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault()
+    setSearchParams(prev => ({ ...prev, page: 1 }))
+  }
+
+  // 获取状态样式
+  const getStatusBadge = (status: string) => {
+    const statusMap: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
+      ACTIVE: { label: '有效', variant: 'success' },
+      USED: { label: '已使用', variant: 'default' },
+      EXPIRED: { label: '已过期', variant: 'destructive' },
+      DISABLED: { label: '已禁用', variant: 'secondary' },
+    }
+    const config = statusMap[status] || { label: status, variant: 'default' }
+    return <Badge variant={config.variant}>{config.label}</Badge>
+  }
+
+  // 导出兑换码
+  const handleExport = async () => {
+    try {
+      const res = await redemptionCodeClient['export']['$get']({
+        query: {
+          ...(searchParams.batchId && { batchId: searchParams.batchId }),
+          ...(searchParams.status && { status: searchParams.status }),
+        },
+      })
+      if (res.status !== 200) throw new Error('导出失败')
+      
+      const blob = await res.blob()
+      const url = URL.createObjectURL(blob)
+      const a = document.createElement('a')
+      a.href = url
+      a.download = `兑换码_${format(new Date(), 'yyyyMMddHHmmss')}.xlsx`
+      a.click()
+      URL.revokeObjectURL(url)
+      toast.success('导出成功')
+    } catch (error) {
+      toast.error('导出失败,请重试')
+    }
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="flex justify-between items-center">
+        <div>
+          <h1 className="text-2xl font-bold">兑换码管理</h1>
+          <p className="text-muted-foreground">管理兑换码的生成和使用记录</p>
+        </div>
+        <div className="flex gap-2">
+          <Button onClick={handleBulkCreate} variant="outline">
+            <Plus className="mr-2 h-4 w-4" />
+            批量生成
+          </Button>
+          <Button onClick={handleCreate}>
+            <Plus className="mr-2 h-4 w-4" />
+            创建兑换码
+          </Button>
+          <Button onClick={handleExport} variant="outline">
+            <Download className="mr-2 h-4 w-4" />
+            导出
+          </Button>
+        </div>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>兑换码列表</CardTitle>
+          <CardDescription>查看和管理所有兑换码</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={handleSearch} className="flex gap-4 mb-4">
+            <div className="relative flex-1 max-w-sm">
+              <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+              <Input
+                placeholder="搜索兑换码..."
+                value={searchParams.search}
+                onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
+                className="pl-8"
+              />
+            </div>
+            <Select
+              value={searchParams.batchId}
+              onValueChange={(value) => setSearchParams(prev => ({ ...prev, batchId: value, page: 1 }))}
+            >
+              <SelectTrigger className="w-[150px]">
+                <SelectValue placeholder="选择批次" />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="">全部批次</SelectItem>
+                {stockData?.data.map((stock) => (
+                  <SelectItem key={stock.id} value={String(stock.id)}>
+                    {stock.stockName}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+            <Select
+              value={searchParams.status}
+              onValueChange={(value) => setSearchParams(prev => ({ ...prev, status: value, page: 1 }))}
+            >
+              <SelectTrigger className="w-[150px]">
+                <SelectValue placeholder="全部状态" />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="">全部状态</SelectItem>
+                <SelectItem value="ACTIVE">有效</SelectItem>
+                <SelectItem value="USED">已使用</SelectItem>
+                <SelectItem value="EXPIRED">已过期</SelectItem>
+                <SelectItem value="DISABLED">已禁用</SelectItem>
+              </SelectContent>
+            </Select>
+            <Button type="submit" variant="outline">
+              搜索
+            </Button>
+          </form>
+
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>兑换码</TableHead>
+                  <TableHead>批次名称</TableHead>
+                  <TableHead>面额</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead>使用次数</TableHead>
+                  <TableHead>有效期</TableHead>
+                  <TableHead>创建时间</TableHead>
+                  <TableHead>使用时间</TableHead>
+                  <TableHead>使用结果</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {isLoading ? (
+                  <TableRow>
+                    <TableCell colSpan={10} className="text-center">
+                      加载中...
+                    </TableCell>
+                  </TableRow>
+                ) : (
+                  data?.data.map((code) => (
+                    <TableRow key={code.id}>
+                      <TableCell className="font-medium font-mono">{code.code}</TableCell>
+                      <TableCell>{code.batch?.stockName}</TableCell>
+                      <TableCell>¥{(code.amount / 100).toFixed(2)}</TableCell>
+                      <TableCell>{getStatusBadge(code.status)}</TableCell>
+                      <TableCell>
+                        {code.currentRedemptions}/{code.maxRedemptions}
+                      </TableCell>
+                      <TableCell>
+                        {format(new Date(code.validUntil), 'yyyy-MM-dd', { locale: zhCN })}
+                      </TableCell>
+                      <TableCell>
+                        {format(new Date(code.createdAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })}
+                      </TableCell>
+                      <TableCell>
+                        {code.redeemedAt ? (
+                          format(new Date(code.redeemedAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })
+                        ) : (
+                          <span className="text-muted-foreground">未使用</span>
+                        )}
+                      </TableCell>
+                      <TableCell>
+                        {code.redemptionResult || (
+                          <span className="text-muted-foreground">无</span>
+                        )}
+                      </TableCell>
+                      <TableCell className="text-right">
+                        <div className="flex justify-end gap-2">
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            onClick={() => handleEdit(code)}
+                          >
+                            <Edit className="h-4 w-4" />
+                          </Button>
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            onClick={() => handleDelete(code.id)}
+                          >
+                            <Trash2 className="h-4 w-4" />
+                          </Button>
+                        </div>
+                      </TableCell>
+                    </TableRow>
+                  ))
+                )}
+              </TableBody>
+            </Table>
+          </div>
+
+          {data && (
+            <DataTablePagination
+              currentPage={searchParams.page}
+              totalPages={Math.ceil(data.pagination.total / searchParams.limit)}
+              pageSize={searchParams.limit}
+              totalItems={data.pagination.total}
+              onPageChange={(page) => setSearchParams(prev => ({ ...prev, page }))}
+              onPageSizeChange={(limit) => setSearchParams(prev => ({ ...prev, limit, page: 1 }))}
+            />
+          )}
+        </CardContent>
+      </Card>
+
+      <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+        <DialogContent className="sm:max-w-[500px] max-h-[90vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>{isCreateForm ? '创建兑换码' : '编辑兑换码'}</DialogTitle>
+            <DialogDescription>
+              {isCreateForm ? '创建一个新的兑换码' : '编辑现有兑换码信息'}
+            </DialogDescription>
+          </DialogHeader>
+
+          <Form {...(isCreateForm ? createForm : updateForm)}>
+            <form
+              onSubmit={(isCreateForm ? createForm : updateForm).handleSubmit(
+                isCreateForm ? handleCreateSubmit : handleUpdateSubmit
+              )}
+              className="space-y-4"
+            >
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="code"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      兑换码
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入兑换码" {...field} />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="batchId"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      代金券批次
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <Select
+                      onValueChange={(value) => field.onChange(Number(value))}
+                      value={String(field.value)}
+                    >
+                      <FormControl>
+                        <SelectTrigger>
+                          <SelectValue placeholder="请选择批次" />
+                        </SelectTrigger>
+                      </FormControl>
+                      <SelectContent>
+                        {stockData?.data.map((stock) => (
+                          <SelectItem key={stock.id} value={String(stock.id)}>
+                            {stock.stockName}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="amount"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      面额(分)
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input
+                        type="number"
+                        placeholder="请输入面额"
+                        {...field}
+                        onChange={(e) => field.onChange(Number(e.target.value))}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="maxRedemptions"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      最大使用次数
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input
+                        type="number"
+                        placeholder="请输入最大使用次数"
+                        {...field}
+                        onChange={(e) => field.onChange(Number(e.target.value))}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="validUntil"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>有效期至</FormLabel>
+                    <FormControl>
+                      <Input
+                        type="date"
+                        {...field}
+                        value={field.value ? format(new Date(field.value), 'yyyy-MM-dd') : ''}
+                        onChange={(e) => field.onChange(new Date(e.target.value))}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="isActive"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>状态</FormLabel>
+                    <Select onValueChange={(value) => field.onChange(Number(value))} value={String(field.value)}>
+                      <FormControl>
+                        <SelectTrigger>
+                          <SelectValue placeholder="请选择状态" />
+                        </SelectTrigger>
+                      </FormControl>
+                      <SelectContent>
+                        <SelectItem value="1">启用</SelectItem>
+                        <SelectItem value="0">禁用</SelectItem>
+                      </SelectContent>
+                    </Select>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="redemptionResult"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>使用结果</FormLabel>
+                    <FormControl>
+                      <Textarea
+                        placeholder="请输入使用结果"
+                        rows={3}
+                        {...field}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <DialogFooter>
+                <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                  取消
+                </Button>
+                <Button type="submit">
+                  {isCreateForm ? '创建' : '更新'}
+                </Button>
+              </DialogFooter>
+            </form>
+          </Form>
+        </DialogContent>
+      </Dialog>
+
+      <Dialog open={isBulkModalOpen} onOpenChange={setIsBulkModalOpen}>
+        <DialogContent className="sm:max-w-[500px]">
+          <DialogHeader>
+            <DialogTitle>批量生成兑换码</DialogTitle>
+            <DialogDescription>
+              批量生成多个兑换码
+            </DialogDescription>
+          </DialogHeader>
+
+          <Form {...bulkForm}>
+            <form
+              onSubmit={bulkForm.handleSubmit(handleBulkCreateSubmit)}
+              className="space-y-4"
+            >
+              <FormField
+                control={bulkForm.control}
+                name="count"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      生成数量
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input
+                        type="number"
+                        placeholder="请输入生成数量"
+                        {...field}
+                        onChange={(e) => field.onChange(Number(e.target.value))}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={bulkForm.control}
+                name="batchId"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      代金券批次
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <Select
+                      onValueChange={(value) => field.onChange(Number(value))}
+                      value={String(field.value)}
+                    >
+                      <FormControl>
+                        <SelectTrigger>
+                          <SelectValue placeholder="请选择批次" />
+                        </SelectTrigger>
+                      </FormControl>
+                      <SelectContent>
+                        {stockData?.data.map((stock) => (
+                          <SelectItem key={stock.id} value={String(stock.id)}>
+                            {stock.stockName}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={bulkForm.control}
+                name="amount"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      面额(分)
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input
+                        type="number"
+                        placeholder="请输入面额"
+                        {...field}
+                        onChange={(e) => field.onChange(Number(e.target.value))}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={bulkForm.control}
+                name="prefix"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>前缀</FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入兑换码前缀" {...field} />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={bulkForm.control}
+                name="validUntil"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>有效期至</FormLabel>
+                    <FormControl>
+                      <Input
+                        type="date"
+                        {...field}
+                        value={field.value ? format(new Date(field.value), 'yyyy-MM-dd') : ''}
+                        onChange={(e) => field.onChange(new Date(e.target.value))}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <DialogFooter>
+                <Button type="button" variant="outline" onClick={() => setIsBulkModalOpen(false)}>
+                  取消
+                </Button>
+                <Button type="submit">
+                  批量生成
+                </Button>
+              </DialogFooter>
+            </form>
+          </Form>
+        </DialogContent>
+      </Dialog>
+    </div>
+  )
+}

+ 543 - 0
src/client/admin-shadcn/pages/WechatCouponStocks.tsx

@@ -0,0 +1,543 @@
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { format } from 'date-fns'
+import { zhCN } from 'date-fns/locale'
+import { Plus, Search, Edit, Trash2, Eye } from 'lucide-react'
+import { toast } from 'sonner'
+import type { InferRequestType, InferResponseType } from 'hono/client'
+
+import { wechatCouponStockClient } from '@/client/api'
+import { CreateWechatCouponStockDto, UpdateWechatCouponStockDto } from '@/server/modules/wechat-pay/wechat-coupon-stock.schema'
+import { Button } from '@/client/components/ui/button'
+import { Input } from '@/client/components/ui/input'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table'
+import { Badge } from '@/client/components/ui/badge'
+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/client/components/ui/dialog'
+import { Form, FormControl, FormDescription, FormField, FormItem, FormLabel, FormMessage } from '@/client/components/ui/form'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select'
+import { Textarea } from '@/client/components/ui/textarea'
+import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination'
+import { wechatPayConfigClient } from '@/client/api'
+import { z } from 'zod'
+
+type CreateRequest = InferRequestType<typeof wechatCouponStockClient.$post>['json']
+type UpdateRequest = InferRequestType<typeof wechatCouponStockClient[':id']['$put']>['json']
+type StockResponse = InferResponseType<typeof wechatCouponStockClient.$get, 200>['data'][0]
+
+export const WechatCouponStocksPage = () => {
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    search: '',
+    status: '',
+  })
+  const [isModalOpen, setIsModalOpen] = useState(false)
+  const [editingData, setEditingData] = useState<StockResponse | null>(null)
+  const [isCreateForm, setIsCreateForm] = useState(true)
+
+  // 获取微信支付配置列表
+  const { data: configData } = useQuery({
+    queryKey: ['wechat-pay-configs'],
+    queryFn: async () => {
+      const res = await wechatPayConfigClient.$get()
+      if (res.status !== 200) throw new Error('获取配置失败')
+      return await res.json()
+    },
+  })
+
+  // 获取代金券批次列表
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['wechat-coupon-stocks', searchParams],
+    queryFn: async () => {
+      const res = await wechatCouponStockClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search,
+          ...(searchParams.status && { filters: JSON.stringify({ status: searchParams.status }) }),
+        },
+      })
+      if (res.status !== 200) throw new Error('获取列表失败')
+      return await res.json()
+    },
+  })
+
+  // 表单实例
+  const createForm = useForm<CreateRequest>({
+    resolver: zodResolver(CreateWechatCouponStockDto),
+    defaultValues: {
+      stockName: '',
+      stockCreatorMchid: '',
+      couponType: 'NORMAL',
+      couponUseRule: {},
+      stockSendRule: {},
+      couponAmount: 100,
+      couponQuantity: 100,
+      startTime: new Date(),
+      endTime: new Date(Date.now() + 30 * 24 * 60 * 60 * 1000), // 默认30天后
+      configId: 0,
+    },
+  })
+
+  const updateForm = useForm<UpdateRequest>({
+    resolver: zodResolver(UpdateWechatCouponStockDto),
+    defaultValues: {},
+  })
+
+  // 打开创建表单
+  const handleCreate = () => {
+    setEditingData(null)
+    setIsCreateForm(true)
+    createForm.reset()
+    setIsModalOpen(true)
+  }
+
+  // 打开编辑表单
+  const handleEdit = (data: StockResponse) => {
+    setEditingData(data)
+    setIsCreateForm(false)
+    updateForm.reset({
+      stockName: data.stockName,
+      couponUseRule: data.couponUseRule,
+      stockSendRule: data.stockSendRule,
+      couponAmount: data.couponAmount,
+      couponQuantity: data.couponQuantity,
+      availableQuantity: data.availableQuantity,
+      status: data.status,
+      startTime: new Date(data.startTime),
+      endTime: new Date(data.endTime),
+      configId: data.configId,
+    })
+    setIsModalOpen(true)
+  }
+
+  // 提交创建
+  const handleCreateSubmit = async (values: CreateRequest) => {
+    try {
+      const res = await wechatCouponStockClient.$post({ json: values })
+      if (res.status !== 201) throw new Error('创建失败')
+      toast.success('创建成功')
+      setIsModalOpen(false)
+      refetch()
+    } catch (error) {
+      toast.error('创建失败,请重试')
+    }
+  }
+
+  // 提交更新
+  const handleUpdateSubmit = async (values: UpdateRequest) => {
+    if (!editingData) return
+    
+    try {
+      const res = await wechatCouponStockClient[':id']['$put']({
+        param: { id: editingData.id },
+        json: values,
+      })
+      if (res.status !== 200) throw new Error('更新失败')
+      toast.success('更新成功')
+      setIsModalOpen(false)
+      refetch()
+    } catch (error) {
+      toast.error('更新失败,请重试')
+    }
+  }
+
+  // 处理搜索
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault()
+    setSearchParams(prev => ({ ...prev, page: 1 }))
+  }
+
+  // 获取状态样式
+  const getStatusBadge = (status: string) => {
+    const statusMap: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
+      CREATED: { label: '已创建', variant: 'secondary' },
+      PROCESSING: { label: '发放中', variant: 'warning' },
+      RUNNING: { label: '运行中', variant: 'success' },
+      STOPED: { label: '已停止', variant: 'destructive' },
+      PAUSED: { label: '暂停', variant: 'warning' },
+      FINISHED: { label: '已结束', variant: 'secondary' },
+    }
+    const config = statusMap[status] || { label: status, variant: 'default' }
+    return <Badge variant={config.variant}>{config.label}</Badge>
+  }
+
+  // 获取优惠券类型样式
+  const getCouponTypeBadge = (type: string) => {
+    const typeMap: Record<string, { label: string; variant: 'default' | 'secondary' }> = {
+      NORMAL: { label: '满减券', variant: 'default' },
+      CUT_TO: { label: '减至券', variant: 'secondary' },
+    }
+    const config = typeMap[type] || { label: type, variant: 'default' }
+    return <Badge variant={config.variant}>{config.label}</Badge>
+  }
+
+  return (
+    <div className="space-y-4">
+      <div className="flex justify-between items-center">
+        <div>
+          <h1 className="text-2xl font-bold">微信代金券批次</h1>
+          <p className="text-muted-foreground">管理微信代金券批次信息</p>
+        </div>
+        <Button onClick={handleCreate}>
+          <Plus className="mr-2 h-4 w-4" />
+          创建批次
+        </Button>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>批次列表</CardTitle>
+          <CardDescription>查看和管理所有微信代金券批次</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={handleSearch} className="flex gap-4 mb-4">
+            <div className="relative flex-1 max-w-sm">
+              <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+              <Input
+                placeholder="搜索批次名称或批次号..."
+                value={searchParams.search}
+                onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
+                className="pl-8"
+              />
+            </div>
+            <Select
+              value={searchParams.status}
+              onValueChange={(value) => setSearchParams(prev => ({ ...prev, status: value, page: 1 }))}
+            >
+              <SelectTrigger className="w-[150px]">
+                <SelectValue placeholder="全部状态" />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="">全部状态</SelectItem>
+                <SelectItem value="CREATED">已创建</SelectItem>
+                <SelectItem value="RUNNING">运行中</SelectItem>
+                <SelectItem value="STOPED">已停止</SelectItem>
+                <SelectItem value="FINISHED">已结束</SelectItem>
+              </SelectContent>
+            </Select>
+            <Button type="submit" variant="outline">
+              搜索
+            </Button>
+          </form>
+
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>批次号</TableHead>
+                  <TableHead>批次名称</TableHead>
+                  <TableHead>商户号</TableHead>
+                  <TableHead>代金券类型</TableHead>
+                  <TableHead>面额</TableHead>
+                  <TableHead>数量</TableHead>
+                  <TableHead>可用/已发</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead>时间范围</TableHead>
+                  <TableHead className="text-right">操作</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {isLoading ? (
+                  <TableRow>
+                    <TableCell colSpan={10} className="text-center">
+                      加载中...
+                    </TableCell>
+                  </TableRow>
+                ) : (
+                  data?.data.map((stock) => (
+                    <TableRow key={stock.id}>
+                      <TableCell className="font-medium">{stock.stockId}</TableCell>
+                      <TableCell>{stock.stockName}</TableCell>
+                      <TableCell>{stock.stockCreatorMchid}</TableCell>
+                      <TableCell>{getCouponTypeBadge(stock.couponType)}</TableCell>
+                      <TableCell>¥{(stock.couponAmount / 100).toFixed(2)}</TableCell>
+                      <TableCell>{stock.couponQuantity}</TableCell>
+                      <TableCell>
+                        {stock.availableQuantity}/{stock.distributedQuantity}
+                      </TableCell>
+                      <TableCell>{getStatusBadge(stock.status)}</TableCell>
+                      <TableCell>
+                        <div className="text-sm">
+                          <div>开始: {format(new Date(stock.startTime), 'MM-dd HH:mm', { locale: zhCN })}</div>
+                          <div>结束: {format(new Date(stock.endTime), 'MM-dd HH:mm', { locale: zhCN })}</div>
+                        </div>
+                      </TableCell>
+                      <TableCell className="text-right">
+                        <div className="flex justify-end gap-2">
+                          <Button
+                            variant="ghost"
+                            size="icon"
+                            onClick={() => handleEdit(stock)}
+                          >
+                            <Edit className="h-4 w-4" />
+                          </Button>
+                        </div>
+                      </TableCell>
+                    </TableRow>
+                  ))
+                )}
+              </TableBody>
+            </Table>
+          </div>
+
+          {data && (
+            <DataTablePagination
+              currentPage={searchParams.page}
+              totalPages={Math.ceil(data.pagination.total / searchParams.limit)}
+              pageSize={searchParams.limit}
+              totalItems={data.pagination.total}
+              onPageChange={(page) => setSearchParams(prev => ({ ...prev, page }))}
+              onPageSizeChange={(limit) => setSearchParams(prev => ({ ...prev, limit, page: 1 }))}
+            />
+          )}
+        </CardContent>
+      </Card>
+
+      <Dialog open={isModalOpen} onOpenChange={setIsModalOpen}>
+        <DialogContent className="sm:max-w-[600px] max-h-[90vh] overflow-y-auto">
+          <DialogHeader>
+            <DialogTitle>{isCreateForm ? '创建微信代金券批次' : '编辑微信代金券批次'}</DialogTitle>
+            <DialogDescription>
+              {isCreateForm ? '创建一个新的微信代金券批次' : '编辑现有批次信息'}
+            </DialogDescription>
+          </DialogHeader>
+
+          <Form {...(isCreateForm ? createForm : updateForm)}>
+            <form
+              onSubmit={(isCreateForm ? createForm : updateForm).handleSubmit(
+                isCreateForm ? handleCreateSubmit : handleUpdateSubmit
+              )}
+              className="space-y-4"
+            >
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="stockName"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      批次名称
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入批次名称" {...field} />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="stockCreatorMchid"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      商户号
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <FormControl>
+                      <Input placeholder="请输入商户号" {...field} />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="configId"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel className="flex items-center">
+                      支付配置
+                      <span className="text-red-500 ml-1">*</span>
+                    </FormLabel>
+                    <Select
+                      onValueChange={(value) => field.onChange(Number(value))}
+                      value={String(field.value)}
+                    >
+                      <FormControl>
+                        <SelectTrigger>
+                          <SelectValue placeholder="请选择支付配置" />
+                        </SelectTrigger>
+                      </FormControl>
+                      <SelectContent>
+                        {configData?.data.map((config) => (
+                          <SelectItem key={config.id} value={String(config.id)}>
+                            {config.merchantId} - {config.appId}
+                          </SelectItem>
+                        ))}
+                      </SelectContent>
+                    </Select>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="couponType"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>代金券类型</FormLabel>
+                    <Select onValueChange={field.onChange} value={field.value}>
+                      <FormControl>
+                        <SelectTrigger>
+                          <SelectValue placeholder="请选择代金券类型" />
+                        </SelectTrigger>
+                      </FormControl>
+                      <SelectContent>
+                        <SelectItem value="NORMAL">满减券</SelectItem>
+                        <SelectItem value="CUT_TO">减至券</SelectItem>
+                      </SelectContent>
+                    </Select>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <div className="grid grid-cols-2 gap-4">
+                <FormField
+                  control={(isCreateForm ? createForm : updateForm).control}
+                  name="couponAmount"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        代金券面额(分)
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          placeholder="请输入面额"
+                          {...field}
+                          onChange={(e) => field.onChange(Number(e.target.value))}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+
+                <FormField
+                  control={(isCreateForm ? createForm : updateForm).control}
+                  name="couponQuantity"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel className="flex items-center">
+                        代金券数量
+                        <span className="text-red-500 ml-1">*</span>
+                      </FormLabel>
+                      <FormControl>
+                        <Input
+                          type="number"
+                          placeholder="请输入数量"
+                          {...field}
+                          onChange={(e) => field.onChange(Number(e.target.value))}
+                        />
+                      </FormControl>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              </div>
+
+              {!isCreateForm && (
+                <FormField
+                  control={(isCreateForm ? createForm : updateForm).control}
+                  name="status"
+                  render={({ field }) => (
+                    <FormItem>
+                      <FormLabel>状态</FormLabel>
+                      <Select onValueChange={field.onChange} value={field.value}>
+                        <FormControl>
+                          <SelectTrigger>
+                            <SelectValue placeholder="请选择状态" />
+                          </SelectTrigger>
+                        </FormControl>
+                        <SelectContent>
+                          <SelectItem value="CREATED">已创建</SelectItem>
+                          <SelectItem value="PROCESSING">发放中</SelectItem>
+                          <SelectItem value="RUNNING">运行中</SelectItem>
+                          <SelectItem value="STOPED">已停止</SelectItem>
+                          <SelectItem value="PAUSED">暂停</SelectItem>
+                          <SelectItem value="FINISHED">已结束</SelectItem>
+                        </SelectContent>
+                      </Select>
+                      <FormMessage />
+                    </FormItem>
+                  )}
+                />
+              )}
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="couponUseRule"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>代金券使用规则</FormLabel>
+                    <FormControl>
+                      <Textarea
+                        placeholder='{"coupon_minimum": 100}'
+                        rows={3}
+                        {...field}
+                        value={typeof field.value === 'string' ? field.value : JSON.stringify(field.value, null, 2)}
+                        onChange={(e) => {
+                          try {
+                            field.onChange(JSON.parse(e.target.value))
+                          } catch {
+                            field.onChange(e.target.value)
+                          }
+                        }}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <FormField
+                control={(isCreateForm ? createForm : updateForm).control}
+                name="stockSendRule"
+                render={({ field }) => (
+                  <FormItem>
+                    <FormLabel>批次发放规则</FormLabel>
+                    <FormControl>
+                      <Textarea
+                        placeholder='{"max_coupons": 100}'
+                        rows={3}
+                        {...field}
+                        value={typeof field.value === 'string' ? field.value : JSON.stringify(field.value, null, 2)}
+                        onChange={(e) => {
+                          try {
+                            field.onChange(JSON.parse(e.target.value))
+                          } catch {
+                            field.onChange(e.target.value)
+                          }
+                        }}
+                      />
+                    </FormControl>
+                    <FormMessage />
+                  </FormItem>
+                )}
+              />
+
+              <DialogFooter>
+                <Button type="button" variant="outline" onClick={() => setIsModalOpen(false)}>
+                  取消
+                </Button>
+                <Button type="submit">
+                  {isCreateForm ? '创建' : '更新'}
+                </Button>
+              </DialogFooter>
+            </form>
+          </Form>
+        </DialogContent>
+      </Dialog>
+    </div>
+  )
+}

+ 217 - 0
src/client/admin-shadcn/pages/WechatCoupons.tsx

@@ -0,0 +1,217 @@
+import { useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { useForm } from 'react-hook-form'
+import { zodResolver } from '@hookform/resolvers/zod'
+import { format } from 'date-fns'
+import { zhCN } from 'date-fns/locale'
+import { Search, Eye } from 'lucide-react'
+import { toast } from 'sonner'
+import type { InferRequestType, InferResponseType } from 'hono/client'
+
+import { wechatCouponClient } from '@/client/api'
+import { CreateWechatCouponDto, UpdateWechatCouponDto } from '@/server/modules/wechat-pay/wechat-coupon.schema'
+import { Button } from '@/client/components/ui/button'
+import { Input } from '@/client/components/ui/input'
+import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/client/components/ui/card'
+import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/client/components/ui/table'
+import { Badge } from '@/client/components/ui/badge'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/client/components/ui/select'
+import { DataTablePagination } from '@/client/admin-shadcn/components/DataTablePagination'
+import { wechatCouponStockClient } from '@/client/api'
+
+type CouponResponse = InferResponseType<typeof wechatCouponClient.$get, 200>['data'][0]
+
+export const WechatCouponsPage = () => {
+  const [searchParams, setSearchParams] = useState({
+    page: 1,
+    limit: 10,
+    search: '',
+    stockId: '',
+    status: '',
+  })
+
+  // 获取代金券批次列表
+  const { data: stockData } = useQuery({
+    queryKey: ['wechat-coupon-stocks-all'],
+    queryFn: async () => {
+      const res = await wechatCouponStockClient.$get({
+        query: { page: 1, pageSize: 100 },
+      })
+      if (res.status !== 200) throw new Error('获取批次失败')
+      return await res.json()
+    },
+  })
+
+  // 获取代金券列表
+  const { data, isLoading, refetch } = useQuery({
+    queryKey: ['wechat-coupons', searchParams],
+    queryFn: async () => {
+      const filters: any = {}
+      if (searchParams.stockId) filters.stockId = searchParams.stockId
+      if (searchParams.status) filters.couponStatus = searchParams.status
+
+      const res = await wechatCouponClient.$get({
+        query: {
+          page: searchParams.page,
+          pageSize: searchParams.limit,
+          keyword: searchParams.search,
+          ...(Object.keys(filters).length > 0 && { filters: JSON.stringify(filters) }),
+        },
+      })
+      if (res.status !== 200) throw new Error('获取列表失败')
+      return await res.json()
+    },
+  })
+
+  // 获取状态样式
+  const getStatusBadge = (status: string) => {
+    const statusMap: Record<string, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive' }> = {
+      SENDED: { label: '已发放', variant: 'success' },
+      USED: { label: '已使用', variant: 'default' },
+      EXPIRED: { label: '已过期', variant: 'destructive' },
+    }
+    const config = statusMap[status] || { label: status, variant: 'default' }
+    return <Badge variant={config.variant}>{config.label}</Badge>
+  }
+
+  // 处理搜索
+  const handleSearch = (e: React.FormEvent) => {
+    e.preventDefault()
+    setSearchParams(prev => ({ ...prev, page: 1 }))
+  }
+
+  return (
+    <div className="space-y-4">
+      <div>
+        <h1 className="text-2xl font-bold">微信代金券</h1>
+        <p className="text-muted-foreground">查看和管理已发放的微信代金券</p>
+      </div>
+
+      <Card>
+        <CardHeader>
+          <CardTitle>代金券列表</CardTitle>
+          <CardDescription>查看所有已发放的微信代金券信息</CardDescription>
+        </CardHeader>
+        <CardContent>
+          <form onSubmit={handleSearch} className="flex gap-4 mb-4">
+            <div className="relative flex-1 max-w-sm">
+              <Search className="absolute left-2 top-2.5 h-4 w-4 text-muted-foreground" />
+              <Input
+                placeholder="搜索用户openid或批次号..."
+                value={searchParams.search}
+                onChange={(e) => setSearchParams(prev => ({ ...prev, search: e.target.value }))}
+                className="pl-8"
+              />
+            </div>
+            <Select
+              value={searchParams.stockId}
+              onValueChange={(value) => setSearchParams(prev => ({ ...prev, stockId: value, page: 1 }))}
+            >
+              <SelectTrigger className="w-[150px]">
+                <SelectValue placeholder="选择批次" />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="">全部批次</SelectItem>
+                {stockData?.data.map((stock) => (
+                  <SelectItem key={stock.id} value={stock.stockId}>
+                    {stock.stockName}
+                  </SelectItem>
+                ))}
+              </SelectContent>
+            </Select>
+            <Select
+              value={searchParams.status}
+              onValueChange={(value) => setSearchParams(prev => ({ ...prev, status: value, page: 1 }))}
+            >
+              <SelectTrigger className="w-[150px]">
+                <SelectValue placeholder="全部状态" />
+              </SelectTrigger>
+              <SelectContent>
+                <SelectItem value="">全部状态</SelectItem>
+                <SelectItem value="SENDED">已发放</SelectItem>
+                <SelectItem value="USED">已使用</SelectItem>
+                <SelectItem value="EXPIRED">已过期</SelectItem>
+              </SelectContent>
+            </Select>
+            <Button type="submit" variant="outline">
+              搜索
+            </Button>
+          </form>
+
+          <div className="rounded-md border">
+            <Table>
+              <TableHeader>
+                <TableRow>
+                  <TableHead>代金券ID</TableHead>
+                  <TableHead>批次号</TableHead>
+                  <TableHead>批次名称</TableHead>
+                  <TableHead>用户openid</TableHead>
+                  <TableHead>面额</TableHead>
+                  <TableHead>状态</TableHead>
+                  <TableHead>时间范围</TableHead>
+                  <TableHead>使用时间</TableHead>
+                  <TableHead>订单号</TableHead>
+                </TableRow>
+              </TableHeader>
+              <TableBody>
+                {isLoading ? (
+                  <TableRow>
+                    <TableCell colSpan={9} className="text-center">
+                      加载中...
+                    </TableCell>
+                  </TableRow>
+                ) : (
+                  data?.data.map((coupon) => (
+                    <TableRow key={coupon.id}>
+                      <TableCell className="font-medium font-mono text-xs">
+                        {coupon.couponId}
+                      </TableCell>
+                      <TableCell className="font-mono text-xs">{coupon.stockId}</TableCell>
+                      <TableCell>{coupon.stock?.stockName}</TableCell>
+                      <TableCell className="font-mono text-xs">
+                        {coupon.openid.substring(0, 8)}...
+                      </TableCell>
+                      <TableCell>¥{(coupon.amount / 100).toFixed(2)}</TableCell>
+                      <TableCell>{getStatusBadge(coupon.couponStatus)}</TableCell>
+                      <TableCell>
+                        <div className="text-sm">
+                          <div>开始: {format(new Date(coupon.availableStartTime), 'MM-dd HH:mm', { locale: zhCN })}</div>
+                          <div>结束: {format(new Date(coupon.availableEndTime), 'MM-dd HH:mm', { locale: zhCN })}</div>
+                        </div>
+                      </TableCell>
+                      <TableCell>
+                        {coupon.usedTime ? (
+                          format(new Date(coupon.usedTime), 'MM-dd HH:mm', { locale: zhCN })
+                        ) : (
+                          <span className="text-muted-foreground">未使用</span>
+                        )}
+                      </TableCell>
+                      <TableCell className="font-mono text-xs">
+                        {coupon.transactionId ? (
+                          coupon.transactionId
+                        ) : (
+                          <span className="text-muted-foreground">无</span>
+                        )}
+                      </TableCell>
+                    </TableRow>
+                  ))
+                )}
+              </TableBody>
+            </Table>
+          </div>
+
+          {data && (
+            <DataTablePagination
+              currentPage={searchParams.page}
+              totalPages={Math.ceil(data.pagination.total / searchParams.limit)}
+              pageSize={searchParams.limit}
+              totalItems={data.pagination.total}
+              onPageChange={(page) => setSearchParams(prev => ({ ...prev, page }))}
+              onPageSizeChange={(limit) => setSearchParams(prev => ({ ...prev, limit, page: 1 }))}
+            />
+          )}
+        </CardContent>
+      </Card>
+    </div>
+  )
+}

+ 18 - 0
src/client/admin-shadcn/routes.tsx

@@ -9,6 +9,9 @@ import { UsersPage } from './pages/Users';
 import { LoginPage } from './pages/Login';
 import { FilesPage } from './pages/Files';
 import { default as CouponLogsPage } from './pages/CouponLogs';
+import { WechatCouponStocksPage } from './pages/WechatCouponStocks';
+import { WechatCouponsPage } from './pages/WechatCoupons';
+import { RedemptionCodesPage } from './pages/RedemptionCodes';
 
 export const router = createBrowserRouter([
   {
@@ -51,6 +54,21 @@ export const router = createBrowserRouter([
         element: <CouponLogsPage />,
         errorElement: <ErrorPage />
       },
+      {
+        path: 'wechat-coupon-stocks',
+        element: <WechatCouponStocksPage />,
+        errorElement: <ErrorPage />
+      },
+      {
+        path: 'wechat-coupons',
+        element: <WechatCouponsPage />,
+        errorElement: <ErrorPage />
+      },
+      {
+        path: 'redemption-codes',
+        element: <RedemptionCodesPage />,
+        errorElement: <ErrorPage />
+      },
       {
         path: '*',
         element: <NotFoundPage />,