feat: 兑换中心
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
import {post, type PageResult, type PageParams } from '@/lib/request'
|
||||
|
||||
// ==================== Types ====================
|
||||
|
||||
export interface ExchangeItem {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
imageId: string
|
||||
type: 'PHYSICAL' | 'VIRTUAL' | 'COUPON'
|
||||
costSunlight: number
|
||||
stock: number
|
||||
limitPerUser: number
|
||||
status: number // 1=上架 2=下架
|
||||
sort: number
|
||||
startTime: string | null
|
||||
endTime: string | null
|
||||
image?: { id: string; url: string }
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export interface ExchangeOrder {
|
||||
id: string
|
||||
userId: string
|
||||
itemId: string
|
||||
itemName: string
|
||||
costSunlight: number
|
||||
quantity: number
|
||||
status: number // 1=待处理 2=处理中 3=已发货 4=已完成 5=已取消
|
||||
itemType: string
|
||||
recipientName: string
|
||||
phone: string
|
||||
address: string
|
||||
trackingNo: string
|
||||
remark: string
|
||||
completedAt: string | null
|
||||
item?: ExchangeItem
|
||||
createdAt: string
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// ==================== Item API ====================
|
||||
|
||||
export interface CreateItemParams {
|
||||
name: string
|
||||
description: string
|
||||
imageId?: string
|
||||
type: string
|
||||
costSunlight: number
|
||||
stock: number
|
||||
limitPerUser: number
|
||||
status: number
|
||||
sort: number
|
||||
startTime?: string | null
|
||||
endTime?: string | null
|
||||
}
|
||||
|
||||
export interface UpdateItemParams extends Partial<CreateItemParams> {
|
||||
id: string
|
||||
}
|
||||
|
||||
export interface ItemListParams extends PageParams {
|
||||
type?: string
|
||||
status?: number
|
||||
}
|
||||
|
||||
export function getItemList(data: ItemListParams) {
|
||||
return post<{ data: PageResult<ExchangeItem> }>('/exchange/item/list', data)
|
||||
}
|
||||
|
||||
export function createItem(data: CreateItemParams) {
|
||||
return post<string>('/exchange/item/create', data)
|
||||
}
|
||||
|
||||
export function updateItem(data: UpdateItemParams) {
|
||||
return post<string>('/exchange/item/update', data)
|
||||
}
|
||||
|
||||
export function deleteItem(id: string) {
|
||||
return post<string>('/exchange/item/delete', { id })
|
||||
}
|
||||
|
||||
// ==================== Order API ====================
|
||||
|
||||
export interface OrderListParams extends PageParams {
|
||||
status?: number
|
||||
userId?: string
|
||||
}
|
||||
|
||||
export interface UpdateOrderParams {
|
||||
id: string
|
||||
status: number
|
||||
trackingNo?: string
|
||||
remark?: string
|
||||
}
|
||||
|
||||
export function getOrderList(data: OrderListParams) {
|
||||
return post<{ data: PageResult<ExchangeOrder> }>('/exchange/order/list', data)
|
||||
}
|
||||
|
||||
export function updateOrderStatus(data: UpdateOrderParams) {
|
||||
return post<string>('/exchange/order/update', data)
|
||||
}
|
||||
@@ -66,3 +66,8 @@ export function likePost(id: string, type: 1 | 2) {
|
||||
export function commentPost(data: CreateCommentParams) {
|
||||
return post<{ msg: string }>('/post/comment', data)
|
||||
}
|
||||
|
||||
// 删除帖子(支持批量)
|
||||
export function deletePost(ids: string[]) {
|
||||
return post<{ msg: string }>('/post/delete', { ids })
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState, useEffect } from 'react'
|
||||
import { Search, Eye, Heart, MessageCircle, Trash2, ShieldCheck, User } from 'lucide-react'
|
||||
import { Search, Eye, Heart, MessageCircle, Trash2, ShieldCheck, User, Loader2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -33,6 +34,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
|
||||
// import { Separator } from '@/components/ui/separator'
|
||||
import {
|
||||
getPostPage,
|
||||
deletePost,
|
||||
type Post,
|
||||
type PostPageParams,
|
||||
} from '@/api/post'
|
||||
@@ -52,6 +54,8 @@ export default function PostsPage() {
|
||||
const [previewDialogOpen, setPreviewDialogOpen] = useState(false)
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [selectedPost, setSelectedPost] = useState<Post | null>(null)
|
||||
const [selectedIds, setSelectedIds] = useState<string[]>([])
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
// 获取帖子列表
|
||||
const fetchPosts = async () => {
|
||||
@@ -85,16 +89,53 @@ export default function PostsPage() {
|
||||
setPreviewDialogOpen(true)
|
||||
}
|
||||
|
||||
// 处理删除确认
|
||||
// 处理删除确认(单个)
|
||||
const handleDeleteConfirm = (post: Post) => {
|
||||
setSelectedPost(post)
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除 (Mock)
|
||||
const handleDelete = () => {
|
||||
alert('删除功能开发中')
|
||||
setDeleteDialogOpen(false)
|
||||
// 处理批量删除确认
|
||||
const handleBatchDeleteConfirm = () => {
|
||||
setSelectedPost(null) // null means batch mode
|
||||
setDeleteDialogOpen(true)
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
const handleDelete = async () => {
|
||||
const ids = selectedPost ? [selectedPost.id] : selectedIds
|
||||
if (ids.length === 0) return
|
||||
|
||||
setDeleting(true)
|
||||
try {
|
||||
await deletePost(ids)
|
||||
setDeleteDialogOpen(false)
|
||||
setSelectedPost(null)
|
||||
setSelectedIds([])
|
||||
fetchPosts()
|
||||
} catch (error) {
|
||||
console.error('删除帖子失败:', error)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// 选择相关
|
||||
const isAllSelected = posts.length > 0 && selectedIds.length === posts.length
|
||||
const isSomeSelected = selectedIds.length > 0 && selectedIds.length < posts.length
|
||||
|
||||
const toggleSelectAll = () => {
|
||||
if (isAllSelected) {
|
||||
setSelectedIds([])
|
||||
} else {
|
||||
setSelectedIds(posts.map(p => p.id))
|
||||
}
|
||||
}
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds(prev =>
|
||||
prev.includes(id) ? prev.filter(i => i !== id) : [...prev, id]
|
||||
)
|
||||
}
|
||||
|
||||
// 审核 (Mock)
|
||||
@@ -164,6 +205,18 @@ export default function PostsPage() {
|
||||
搜索
|
||||
</Button>
|
||||
</div>
|
||||
{selectedIds.length > 0 && (
|
||||
<div className="flex items-center gap-3 pt-3 border-t mt-4">
|
||||
<span className="text-sm text-muted-foreground">已选择 <span className="font-semibold text-foreground">{selectedIds.length}</span> 项</span>
|
||||
<Button variant="destructive" size="sm" onClick={handleBatchDeleteConfirm} className="gap-1">
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
批量删除
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={() => setSelectedIds([])}>
|
||||
取消选择
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -186,7 +239,13 @@ export default function PostsPage() {
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-muted/30">
|
||||
<TableHead className="pl-6 w-[350px]">帖子内容</TableHead>
|
||||
<TableHead className="pl-4 w-[50px]">
|
||||
<Checkbox
|
||||
checked={isAllSelected ? true : isSomeSelected ? 'indeterminate' : false}
|
||||
onCheckedChange={toggleSelectAll}
|
||||
/>
|
||||
</TableHead>
|
||||
<TableHead className="w-[350px]">帖子内容</TableHead>
|
||||
<TableHead>发布者</TableHead>
|
||||
<TableHead>互动数据</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
@@ -197,7 +256,7 @@ export default function PostsPage() {
|
||||
<TableBody>
|
||||
{posts.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="h-64 text-center">
|
||||
<TableCell colSpan={7} className="h-64 text-center">
|
||||
<div className="flex flex-col items-center justify-center text-muted-foreground gap-3">
|
||||
<div className="bg-muted p-4 rounded-full">
|
||||
<Search className="h-8 w-8 opacity-40" />
|
||||
@@ -214,8 +273,14 @@ export default function PostsPage() {
|
||||
const timeDisplay = post.createdAtStr || post.createdAt?.replace('T', ' ').slice(0, 16) || '-'
|
||||
|
||||
return (
|
||||
<TableRow key={post.id} className="hover:bg-muted/10">
|
||||
<TableCell className="pl-6 align-top py-4">
|
||||
<TableRow key={post.id} className={`hover:bg-muted/10 ${selectedIds.includes(post.id) ? 'bg-primary/5' : ''}`}>
|
||||
<TableCell className="pl-4 align-top py-4">
|
||||
<Checkbox
|
||||
checked={selectedIds.includes(post.id)}
|
||||
onCheckedChange={() => toggleSelect(post.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="align-top py-4">
|
||||
<div className="space-y-2">
|
||||
<div className="font-medium line-clamp-1 text-base">{post.title}</div>
|
||||
<div className="text-sm text-muted-foreground line-clamp-2 leading-relaxed">
|
||||
@@ -505,14 +570,19 @@ export default function PostsPage() {
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认操作</DialogTitle>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除帖子"{selectedPost?.title}"吗?删除后无法恢复。
|
||||
{selectedPost
|
||||
? <>确定要删除帖子「{selectedPost.title}」吗?删除后无法恢复。</>
|
||||
: <>确定要删除选中的 <span className="font-semibold text-foreground">{selectedIds.length}</span> 条帖子吗?删除后无法恢复。</>
|
||||
}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>确认删除</Button>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleting}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting} className="min-w-[90px]">
|
||||
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认删除'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -0,0 +1,581 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Plus, Search, MoreHorizontal, Pencil, Trash2, Gift, Package, Sun, Archive,
|
||||
ArrowUpDown, Clock, Loader2
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import {
|
||||
getItemList, createItem, updateItem, deleteItem,
|
||||
type ExchangeItem, type CreateItemParams
|
||||
} from '@/api/exchange'
|
||||
|
||||
// ==================== Form Types ====================
|
||||
|
||||
interface ItemFormData {
|
||||
id?: string
|
||||
name: string
|
||||
description: string
|
||||
type: string
|
||||
costSunlight: number
|
||||
stock: number
|
||||
limitPerUser: number
|
||||
status: number
|
||||
sort: number
|
||||
startTime: string
|
||||
endTime: string
|
||||
}
|
||||
|
||||
const defaultFormData: ItemFormData = {
|
||||
name: '',
|
||||
description: '',
|
||||
type: 'PHYSICAL',
|
||||
costSunlight: 0,
|
||||
stock: -1,
|
||||
limitPerUser: 0,
|
||||
status: 1,
|
||||
sort: 0,
|
||||
startTime: '',
|
||||
endTime: '',
|
||||
}
|
||||
|
||||
// ==================== Main Component ====================
|
||||
|
||||
export default function ExchangeItemPage() {
|
||||
const [items, setItems] = useState<ExchangeItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(10)
|
||||
const [searchTerm, setSearchTerm] = useState('')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [typeFilter, setTypeFilter] = useState<string>('all')
|
||||
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [isEdit, setIsEdit] = useState(false)
|
||||
const [formData, setFormData] = useState<ItemFormData>(defaultFormData)
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
|
||||
const [itemToDelete, setItemToDelete] = useState<ExchangeItem | null>(null)
|
||||
|
||||
// Fetch list
|
||||
const fetchItems = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: Record<string, unknown> = {
|
||||
current: page,
|
||||
pageSize,
|
||||
keyword: searchTerm || undefined,
|
||||
}
|
||||
if (statusFilter !== 'all') params.status = parseInt(statusFilter)
|
||||
if (typeFilter !== 'all') params.type = typeFilter
|
||||
|
||||
const res = await getItemList(params as any)
|
||||
if (res?.data) {
|
||||
setItems(res.data.list || [])
|
||||
setTotal(res.data.total || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取商品列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, searchTerm, statusFilter, typeFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchItems()
|
||||
}, [fetchItems])
|
||||
|
||||
// Stats
|
||||
const onShelfCount = items.filter(i => i.status === 1).length
|
||||
const lowStockCount = items.filter(i => i.stock >= 0 && i.stock <= 5).length
|
||||
|
||||
// Open create dialog
|
||||
const handleAdd = () => {
|
||||
setIsEdit(false)
|
||||
setFormData(defaultFormData)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// Open edit dialog
|
||||
const handleEdit = (item: ExchangeItem) => {
|
||||
setIsEdit(true)
|
||||
setFormData({
|
||||
id: item.id,
|
||||
name: item.name,
|
||||
description: item.description || '',
|
||||
type: item.type,
|
||||
costSunlight: item.costSunlight,
|
||||
stock: item.stock,
|
||||
limitPerUser: item.limitPerUser,
|
||||
status: item.status,
|
||||
sort: item.sort,
|
||||
startTime: item.startTime ? item.startTime.slice(0, 16) : '',
|
||||
endTime: item.endTime ? item.endTime.slice(0, 16) : '',
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
// Submit form
|
||||
const handleSubmit = async () => {
|
||||
if (!formData.name || formData.costSunlight <= 0) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const data: CreateItemParams = {
|
||||
name: formData.name,
|
||||
description: formData.description,
|
||||
type: formData.type,
|
||||
costSunlight: formData.costSunlight,
|
||||
stock: formData.stock,
|
||||
limitPerUser: formData.limitPerUser,
|
||||
status: formData.status,
|
||||
sort: formData.sort,
|
||||
startTime: formData.startTime ? new Date(formData.startTime).toISOString() : null,
|
||||
endTime: formData.endTime ? new Date(formData.endTime).toISOString() : null,
|
||||
}
|
||||
|
||||
if (isEdit && formData.id) {
|
||||
await updateItem({ ...data, id: formData.id })
|
||||
} else {
|
||||
await createItem(data)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchItems()
|
||||
} catch (error) {
|
||||
console.error('保存商品失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Delete
|
||||
const handleDelete = async () => {
|
||||
if (!itemToDelete) return
|
||||
try {
|
||||
await deleteItem(itemToDelete.id)
|
||||
setDeleteDialogOpen(false)
|
||||
setItemToDelete(null)
|
||||
fetchItems()
|
||||
} catch (error) {
|
||||
console.error('删除商品失败:', error)
|
||||
}
|
||||
}
|
||||
|
||||
// Helpers
|
||||
const getStatusBadge = (status: number) => {
|
||||
return status === 1
|
||||
? <Badge className="bg-green-500/10 text-green-700 border-green-500/20 hover:bg-green-500/20">上架中</Badge>
|
||||
: <Badge variant="secondary">已下架</Badge>
|
||||
}
|
||||
|
||||
const getTypeBadge = (type: string) => {
|
||||
switch (type) {
|
||||
case 'PHYSICAL': return <Badge variant="outline" className="border-blue-500/30 text-blue-600">实物</Badge>
|
||||
case 'VIRTUAL': return <Badge variant="outline" className="border-purple-500/30 text-purple-600">虚拟</Badge>
|
||||
case 'COUPON': return <Badge variant="outline" className="border-orange-500/30 text-orange-600">优惠券</Badge>
|
||||
default: return <Badge variant="outline">{type}</Badge>
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="border-l-4 border-l-primary shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">商品总数</CardTitle>
|
||||
<Gift className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{total}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">所有兑换商品</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-green-500 shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">上架商品</CardTitle>
|
||||
<Package className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{onShelfCount}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">当前可兑换的商品</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-orange-500 shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">低库存预警</CardTitle>
|
||||
<Archive className="h-4 w-4 text-orange-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{lowStockCount}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">库存≤5的商品</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-purple-500 shadow-sm hover:shadow-md transition-all group cursor-pointer bg-gradient-to-br from-background to-purple-50/50 hover:to-purple-100/50" onClick={handleAdd}>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-purple-600">快速添加</CardTitle>
|
||||
<div className="bg-purple-100 p-1 rounded-full group-hover:scale-110 transition-transform">
|
||||
<Plus className="h-4 w-4 text-purple-600" />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold text-purple-700">New</div>
|
||||
<p className="text-xs text-purple-600/80 mt-1">点击添加新兑换商品</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Table */}
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
商品列表
|
||||
<Badge variant="secondary" className="font-normal text-xs">{total}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>管理兑换中心的所有商品,设置价格、库存和活动时间</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 w-full sm:w-auto flex-wrap">
|
||||
<div className="relative flex-1 sm:flex-none">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索商品..."
|
||||
className="pl-9 w-full sm:w-[200px] h-10 bg-muted/30 focus:bg-background transition-colors"
|
||||
value={searchTerm}
|
||||
onChange={(e) => { setSearchTerm(e.target.value); setPage(1) }}
|
||||
/>
|
||||
</div>
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[120px] h-10">
|
||||
<SelectValue placeholder="状态" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="1">上架中</SelectItem>
|
||||
<SelectItem value="2">已下架</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={typeFilter} onValueChange={(v) => { setTypeFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[120px] h-10">
|
||||
<SelectValue placeholder="类型" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部类型</SelectItem>
|
||||
<SelectItem value="PHYSICAL">实物</SelectItem>
|
||||
<SelectItem value="VIRTUAL">虚拟</SelectItem>
|
||||
<SelectItem value="COUPON">优惠券</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleAdd} className="h-10 gap-2 shadow-sm">
|
||||
<Plus className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">添加商品</span>
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 space-y-4 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm font-medium">正在加载...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">商品信息</TableHead>
|
||||
<TableHead className="font-semibold">类型</TableHead>
|
||||
<TableHead className="font-semibold">
|
||||
<div className="flex items-center gap-1">
|
||||
<Sun className="h-3.5 w-3.5 text-amber-500" />
|
||||
阳光值
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold">库存</TableHead>
|
||||
<TableHead className="font-semibold">限兑</TableHead>
|
||||
<TableHead className="font-semibold">活动时间</TableHead>
|
||||
<TableHead className="font-semibold">状态</TableHead>
|
||||
<TableHead className="font-semibold">
|
||||
<div className="flex items-center gap-1">
|
||||
<ArrowUpDown className="h-3.5 w-3.5" />
|
||||
排序
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{items.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={9} className="h-48 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<Gift className="h-10 w-10 opacity-30" />
|
||||
<p className="font-medium">暂无商品数据</p>
|
||||
<p className="text-sm">点击"添加商品"创建第一个兑换商品</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
items.map((item, index) => (
|
||||
<TableRow key={item.id} className={cn(
|
||||
"group hover:bg-muted/30 transition-colors",
|
||||
index % 2 === 1 && "bg-muted/5"
|
||||
)}>
|
||||
<TableCell className="pl-6">
|
||||
<div className="flex items-center gap-3">
|
||||
{item.image ? (
|
||||
<img src={item.image.url} alt={item.name}
|
||||
className="h-10 w-10 rounded-lg object-cover border" />
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded-lg bg-muted/50 flex items-center justify-center">
|
||||
<Gift className="h-5 w-5 text-muted-foreground/40" />
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<p className="font-medium leading-tight">{item.name}</p>
|
||||
{item.description && (
|
||||
<p className="text-xs text-muted-foreground line-clamp-1 max-w-[200px]">{item.description}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{getTypeBadge(item.type)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono font-semibold text-amber-600">{item.costSunlight}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.stock < 0 ? (
|
||||
<span className="text-muted-foreground text-sm">无限</span>
|
||||
) : (
|
||||
<span className={cn("font-mono", item.stock <= 5 ? "text-red-500 font-semibold" : "")}>
|
||||
{item.stock}
|
||||
</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.limitPerUser > 0 ? (
|
||||
<span className="text-sm">{item.limitPerUser}次/人</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-sm">不限</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{item.startTime || item.endTime ? (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<Clock className="h-3 w-3" />
|
||||
<span>
|
||||
{item.startTime ? formatDate(item.startTime) : '不限'}
|
||||
{' ~ '}
|
||||
{item.endTime ? formatDate(item.endTime) : '不限'}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">长期</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(item.status)}</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono text-sm text-muted-foreground">{item.sort}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => handleEdit(item)}>
|
||||
<Pencil className="mr-2 h-4 w-4" /> 编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
className="text-destructive"
|
||||
onClick={() => { setItemToDelete(item); setDeleteDialogOpen(true) }}
|
||||
>
|
||||
<Trash2 className="mr-2 h-4 w-4" /> 删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
显示第 {(page - 1) * pageSize + 1}-{Math.min(page * pageSize, total)} 条,共 {total} 条
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}>
|
||||
上一页
|
||||
</Button>
|
||||
<span className="text-sm text-muted-foreground px-2">
|
||||
{page} / {totalPages}
|
||||
</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages}
|
||||
onClick={() => setPage(p => p + 1)}>
|
||||
下一页
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Create / Edit Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[600px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-xl">
|
||||
<div className={cn("p-2 rounded-lg", isEdit ? "bg-primary/10 text-primary" : "bg-purple-500/10 text-purple-600")}>
|
||||
{isEdit ? <Pencil className="h-5 w-5" /> : <Plus className="h-5 w-5" />}
|
||||
</div>
|
||||
{isEdit ? '编辑商品' : '添加商品'}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{isEdit ? '修改兑换商品信息' : '创建新的兑换商品,设置价格和库存'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-5 py-4 max-h-[60vh] overflow-y-auto pr-1">
|
||||
{/* Name */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">商品名称 *</Label>
|
||||
<Input value={formData.name} onChange={e => setFormData(p => ({ ...p, name: e.target.value }))}
|
||||
placeholder="如:多肉植物盲盒" className="h-10 bg-muted/30 focus:bg-background" />
|
||||
</div>
|
||||
|
||||
{/* Description */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">商品描述</Label>
|
||||
<Textarea value={formData.description} onChange={e => setFormData(p => ({ ...p, description: e.target.value }))}
|
||||
placeholder="详细描述商品信息..." className="h-20 resize-none bg-muted/30 focus:bg-background" />
|
||||
</div>
|
||||
|
||||
{/* Type & Status */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">商品类型 *</Label>
|
||||
<Select value={formData.type} onValueChange={v => setFormData(p => ({ ...p, type: v }))}>
|
||||
<SelectTrigger className="h-10"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="PHYSICAL">实物</SelectItem>
|
||||
<SelectItem value="VIRTUAL">虚拟</SelectItem>
|
||||
<SelectItem value="COUPON">优惠券</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">上架状态</Label>
|
||||
<Select value={String(formData.status)} onValueChange={v => setFormData(p => ({ ...p, status: parseInt(v) }))}>
|
||||
<SelectTrigger className="h-10"><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="1">上架</SelectItem>
|
||||
<SelectItem value="2">下架</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Cost & Stock */}
|
||||
<div className="grid grid-cols-3 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">阳光值 *</Label>
|
||||
<Input type="number" min={0} value={formData.costSunlight}
|
||||
onChange={e => setFormData(p => ({ ...p, costSunlight: parseInt(e.target.value) || 0 }))}
|
||||
className="h-10 font-mono bg-muted/30 focus:bg-background" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">库存 (-1无限)</Label>
|
||||
<Input type="number" min={-1} value={formData.stock}
|
||||
onChange={e => setFormData(p => ({ ...p, stock: parseInt(e.target.value) ?? -1 }))}
|
||||
className="h-10 font-mono bg-muted/30 focus:bg-background" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">限兑次数 (0不限)</Label>
|
||||
<Input type="number" min={0} value={formData.limitPerUser}
|
||||
onChange={e => setFormData(p => ({ ...p, limitPerUser: parseInt(e.target.value) || 0 }))}
|
||||
className="h-10 font-mono bg-muted/30 focus:bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sort */}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">排序(数值越大越靠前)</Label>
|
||||
<Input type="number" min={0} value={formData.sort}
|
||||
onChange={e => setFormData(p => ({ ...p, sort: parseInt(e.target.value) || 0 }))}
|
||||
className="h-10 font-mono bg-muted/30 focus:bg-background w-[120px]" />
|
||||
</div>
|
||||
|
||||
{/* Time Range */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">开始时间(可选)</Label>
|
||||
<Input type="datetime-local" value={formData.startTime}
|
||||
onChange={e => setFormData(p => ({ ...p, startTime: e.target.value }))}
|
||||
className="h-10 bg-muted/30 focus:bg-background" />
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">结束时间(可选)</Label>
|
||||
<Input type="datetime-local" value={formData.endTime}
|
||||
onChange={e => setFormData(p => ({ ...p, endTime: e.target.value }))}
|
||||
className="h-10 bg-muted/30 focus:bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)} disabled={submitting} className="h-10 px-6">取消</Button>
|
||||
<Button onClick={handleSubmit} disabled={submitting || !formData.name} className="h-10 px-6 min-w-[80px]">
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : '保存'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Delete Dialog */}
|
||||
<Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除商品「{itemToDelete?.name}」吗?此操作无法撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>删除</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,480 @@
|
||||
import { useState, useEffect, useCallback } from 'react'
|
||||
import {
|
||||
Search, MoreHorizontal, Package, ClipboardList, Truck, CheckCircle, XCircle,
|
||||
Clock, Loader2, Sun, User, MapPin, Phone
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table, TableBody, TableCell, TableHead, TableHeader, TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import {
|
||||
Select, SelectContent, SelectItem, SelectTrigger, SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { cn, formatDate } from '@/lib/utils'
|
||||
import { getOrderList, updateOrderStatus, type ExchangeOrder, type UpdateOrderParams } from '@/api/exchange'
|
||||
|
||||
// ==================== Status Config ====================
|
||||
|
||||
const ORDER_STATUS: Record<number, { label: string; color: string; icon: React.ReactNode }> = {
|
||||
1: { label: '待处理', color: 'bg-yellow-500/10 text-yellow-700 border-yellow-500/20', icon: <Clock className="h-3.5 w-3.5" /> },
|
||||
2: { label: '处理中', color: 'bg-blue-500/10 text-blue-700 border-blue-500/20', icon: <Package className="h-3.5 w-3.5" /> },
|
||||
3: { label: '已发货', color: 'bg-indigo-500/10 text-indigo-700 border-indigo-500/20', icon: <Truck className="h-3.5 w-3.5" /> },
|
||||
4: { label: '已完成', color: 'bg-green-500/10 text-green-700 border-green-500/20', icon: <CheckCircle className="h-3.5 w-3.5" /> },
|
||||
5: { label: '已取消', color: 'bg-gray-500/10 text-gray-500 border-gray-500/20', icon: <XCircle className="h-3.5 w-3.5" /> },
|
||||
}
|
||||
|
||||
// ==================== Main Component ====================
|
||||
|
||||
export default function ExchangeOrderPage() {
|
||||
const [orders, setOrders] = useState<ExchangeOrder[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize] = useState(10)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
|
||||
// Action dialog
|
||||
const [actionDialogOpen, setActionDialogOpen] = useState(false)
|
||||
const [selectedOrder, setSelectedOrder] = useState<ExchangeOrder | null>(null)
|
||||
const [actionType, setActionType] = useState<'ship' | 'complete' | 'cancel' | 'process'>('process')
|
||||
const [trackingNo, setTrackingNo] = useState('')
|
||||
const [remark, setRemark] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
// Detail dialog
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false)
|
||||
const [detailOrder, setDetailOrder] = useState<ExchangeOrder | null>(null)
|
||||
|
||||
// Fetch list
|
||||
const fetchOrders = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const params: Record<string, unknown> = { current: page, pageSize }
|
||||
if (statusFilter !== 'all') params.status = parseInt(statusFilter)
|
||||
|
||||
const res = await getOrderList(params as any)
|
||||
if (res?.data) {
|
||||
setOrders(res.data.list || [])
|
||||
setTotal(res.data.total || 0)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('获取订单列表失败:', error)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, pageSize, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchOrders()
|
||||
}, [fetchOrders])
|
||||
|
||||
// Stats
|
||||
const pendingCount = orders.filter(o => o.status === 1).length
|
||||
const shippedCount = orders.filter(o => o.status === 3).length
|
||||
const completedCount = orders.filter(o => o.status === 4).length
|
||||
|
||||
// Open action dialog
|
||||
const openAction = (order: ExchangeOrder, type: 'ship' | 'complete' | 'cancel' | 'process') => {
|
||||
setSelectedOrder(order)
|
||||
setActionType(type)
|
||||
setTrackingNo(order.trackingNo || '')
|
||||
setRemark(order.remark || '')
|
||||
setActionDialogOpen(true)
|
||||
}
|
||||
|
||||
// Submit action
|
||||
const handleAction = async () => {
|
||||
if (!selectedOrder) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const statusMap = { process: 2, ship: 3, complete: 4, cancel: 5 }
|
||||
const data: UpdateOrderParams = {
|
||||
id: selectedOrder.id,
|
||||
status: statusMap[actionType],
|
||||
}
|
||||
if (actionType === 'ship') data.trackingNo = trackingNo
|
||||
if (remark) data.remark = remark
|
||||
|
||||
await updateOrderStatus(data)
|
||||
setActionDialogOpen(false)
|
||||
fetchOrders()
|
||||
} catch (error) {
|
||||
console.error('更新订单失败:', error)
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Status badge
|
||||
const getStatusBadge = (status: number) => {
|
||||
const cfg = ORDER_STATUS[status]
|
||||
if (!cfg) return <Badge variant="outline">未知</Badge>
|
||||
return (
|
||||
<Badge className={cn("gap-1", cfg.color, "hover:" + cfg.color.split(' ')[0])}>
|
||||
{cfg.icon}
|
||||
{cfg.label}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// Action label
|
||||
const getActionLabel = () => {
|
||||
switch (actionType) {
|
||||
case 'process': return { title: '开始处理', desc: '将订单标记为处理中', btn: '确认处理', variant: 'default' as const }
|
||||
case 'ship': return { title: '发货', desc: '标记订单已发货并填写快递信息', btn: '确认发货', variant: 'default' as const }
|
||||
case 'complete': return { title: '完成订单', desc: '标记此订单为已完成', btn: '确认完成', variant: 'default' as const }
|
||||
case 'cancel': return { title: '取消订单', desc: '取消此订单,阳光值将自动退回用户', btn: '确认取消', variant: 'destructive' as const }
|
||||
}
|
||||
}
|
||||
|
||||
const totalPages = Math.ceil(total / pageSize)
|
||||
const actionLabels = getActionLabel()
|
||||
|
||||
return (
|
||||
<div className="space-y-6 animate-fadeIn">
|
||||
{/* Stats Cards */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className="border-l-4 border-l-primary shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">总订单数</CardTitle>
|
||||
<ClipboardList className="h-4 w-4 text-primary" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{total}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">所有兑换订单</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-yellow-500 shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">待处理</CardTitle>
|
||||
<Clock className="h-4 w-4 text-yellow-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{pendingCount}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">需要尽快处理</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-indigo-500 shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">已发货</CardTitle>
|
||||
<Truck className="h-4 w-4 text-indigo-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{shippedCount}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">等待用户确认收货</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card className="border-l-4 border-l-green-500 shadow-sm hover:shadow-md transition-all">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium text-muted-foreground">已完成</CardTitle>
|
||||
<CheckCircle className="h-4 w-4 text-green-500" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="text-2xl font-bold">{completedCount}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">成功完成的订单</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Main Table */}
|
||||
<Card className="border-border/60 shadow-sm">
|
||||
<CardHeader className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4 pb-6">
|
||||
<div className="space-y-1">
|
||||
<CardTitle className="text-xl flex items-center gap-2">
|
||||
订单列表
|
||||
<Badge variant="secondary" className="font-normal text-xs">{total}</Badge>
|
||||
</CardTitle>
|
||||
<CardDescription>管理用户的兑换订单,处理发货和状态更新</CardDescription>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[140px] h-10">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="1">待处理</SelectItem>
|
||||
<SelectItem value="2">处理中</SelectItem>
|
||||
<SelectItem value="3">已发货</SelectItem>
|
||||
<SelectItem value="4">已完成</SelectItem>
|
||||
<SelectItem value="5">已取消</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{loading ? (
|
||||
<div className="flex flex-col items-center justify-center py-16 space-y-4 text-muted-foreground">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm font-medium">正在加载...</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="rounded-lg border border-border/50 overflow-hidden">
|
||||
<Table>
|
||||
<TableHeader className="bg-muted/50">
|
||||
<TableRow className="hover:bg-transparent">
|
||||
<TableHead className="font-semibold pl-6">商品信息</TableHead>
|
||||
<TableHead className="font-semibold">
|
||||
<div className="flex items-center gap-1">
|
||||
<Sun className="h-3.5 w-3.5 text-amber-500" />
|
||||
消耗阳光
|
||||
</div>
|
||||
</TableHead>
|
||||
<TableHead className="font-semibold">数量</TableHead>
|
||||
<TableHead className="font-semibold">收货信息</TableHead>
|
||||
<TableHead className="font-semibold">状态</TableHead>
|
||||
<TableHead className="font-semibold">创建时间</TableHead>
|
||||
<TableHead className="w-[60px]"></TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="h-48 text-center">
|
||||
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||
<ClipboardList className="h-10 w-10 opacity-30" />
|
||||
<p className="font-medium">暂无订单数据</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
orders.map((order, index) => (
|
||||
<TableRow key={order.id} className={cn(
|
||||
"group hover:bg-muted/30 transition-colors",
|
||||
index % 2 === 1 && "bg-muted/5"
|
||||
)}>
|
||||
<TableCell className="pl-6">
|
||||
<div>
|
||||
<p className="font-medium leading-tight">{order.itemName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{order.itemType === 'PHYSICAL' ? '实物' : order.itemType === 'VIRTUAL' ? '虚拟' : '优惠券'}
|
||||
{order.trackingNo && <span className="ml-2">📦 {order.trackingNo}</span>}
|
||||
</p>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono font-semibold text-amber-600">{order.costSunlight}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<span className="font-mono">{order.quantity}</span>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{order.recipientName ? (
|
||||
<div className="text-xs space-y-0.5">
|
||||
<div className="flex items-center gap-1">
|
||||
<User className="h-3 w-3 text-muted-foreground" />
|
||||
{order.recipientName}
|
||||
</div>
|
||||
{order.phone && (
|
||||
<div className="flex items-center gap-1 text-muted-foreground">
|
||||
<Phone className="h-3 w-3" />
|
||||
{order.phone}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-muted-foreground text-xs">—</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{getStatusBadge(order.status)}</TableCell>
|
||||
<TableCell className="text-muted-foreground text-sm">
|
||||
{formatDate(order.createdAt)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => { setDetailOrder(order); setDetailDialogOpen(true) }}>
|
||||
<Search className="mr-2 h-4 w-4" /> 查看详情
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
{order.status === 1 && (
|
||||
<>
|
||||
<DropdownMenuItem onClick={() => openAction(order, 'process')}>
|
||||
<Package className="mr-2 h-4 w-4" /> 开始处理
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={() => openAction(order, 'ship')}>
|
||||
<Truck className="mr-2 h-4 w-4" /> 直接发货
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
{order.status === 2 && (
|
||||
<DropdownMenuItem onClick={() => openAction(order, 'ship')}>
|
||||
<Truck className="mr-2 h-4 w-4" /> 发货
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{order.status === 3 && (
|
||||
<DropdownMenuItem onClick={() => openAction(order, 'complete')}>
|
||||
<CheckCircle className="mr-2 h-4 w-4" /> 确认完成
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{(order.status === 1 || order.status === 2) && (
|
||||
<>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive" onClick={() => openAction(order, 'cancel')}>
|
||||
<XCircle className="mr-2 h-4 w-4" /> 取消订单
|
||||
</DropdownMenuItem>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* Pagination */}
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between pt-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
显示第 {(page - 1) * pageSize + 1}-{Math.min(page * pageSize, total)} 条,共 {total} 条
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1}
|
||||
onClick={() => setPage(p => p - 1)}>上一页</Button>
|
||||
<span className="text-sm text-muted-foreground px-2">{page} / {totalPages}</span>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages}
|
||||
onClick={() => setPage(p => p + 1)}>下一页</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Action Dialog */}
|
||||
<Dialog open={actionDialogOpen} onOpenChange={setActionDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[450px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{actionLabels.title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{actionLabels.desc}
|
||||
{selectedOrder && <span className="block mt-1 font-medium text-foreground">商品:{selectedOrder.itemName}</span>}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
{actionType === 'ship' && (
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">快递单号</Label>
|
||||
<Input value={trackingNo} onChange={e => setTrackingNo(e.target.value)}
|
||||
placeholder="请输入快递单号" className="h-10 bg-muted/30 focus:bg-background" />
|
||||
</div>
|
||||
)}
|
||||
<div className="grid gap-2">
|
||||
<Label className="text-xs uppercase text-muted-foreground font-semibold tracking-wider">备注(可选)</Label>
|
||||
<Input value={remark} onChange={e => setRemark(e.target.value)}
|
||||
placeholder="处理备注..." className="h-10 bg-muted/30 focus:bg-background" />
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="gap-2 sm:gap-0">
|
||||
<Button variant="outline" onClick={() => setActionDialogOpen(false)} disabled={submitting}>取消</Button>
|
||||
<Button variant={actionLabels.variant} onClick={handleAction} disabled={submitting || (actionType === 'ship' && !trackingNo)}>
|
||||
{submitting ? <Loader2 className="h-4 w-4 animate-spin" /> : actionLabels.btn}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* Detail Dialog */}
|
||||
<Dialog open={detailDialogOpen} onOpenChange={setDetailDialogOpen}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>订单详情</DialogTitle>
|
||||
</DialogHeader>
|
||||
{detailOrder && (
|
||||
<div className="space-y-4 py-2">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">商品名称</span>
|
||||
<span className="font-medium">{detailOrder.itemName}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">状态</span>
|
||||
{getStatusBadge(detailOrder.status)}
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">消耗阳光</span>
|
||||
<span className="font-mono font-semibold text-amber-600">{detailOrder.costSunlight}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">数量</span>
|
||||
<span className="font-mono">{detailOrder.quantity}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">商品类型</span>
|
||||
<span>{detailOrder.itemType === 'PHYSICAL' ? '实物' : detailOrder.itemType === 'VIRTUAL' ? '虚拟' : '优惠券'}</span>
|
||||
</div>
|
||||
<div>
|
||||
<span className="text-muted-foreground block mb-1">创建时间</span>
|
||||
<span>{formatDate(detailOrder.createdAt)}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{detailOrder.recipientName && (
|
||||
<div className="border rounded-lg p-4 space-y-2 bg-muted/20">
|
||||
<p className="text-xs uppercase text-muted-foreground font-semibold tracking-wider mb-2">收货信息</p>
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<User className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{detailOrder.recipientName}</span>
|
||||
</div>
|
||||
{detailOrder.phone && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<Phone className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{detailOrder.phone}</span>
|
||||
</div>
|
||||
)}
|
||||
{detailOrder.address && (
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<MapPin className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{detailOrder.address}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailOrder.trackingNo && (
|
||||
<div className="flex items-center gap-2 text-sm border rounded-lg p-3 bg-indigo-50/50">
|
||||
<Truck className="h-4 w-4 text-indigo-500" />
|
||||
<span className="text-muted-foreground">快递单号:</span>
|
||||
<span className="font-mono font-medium">{detailOrder.trackingNo}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{detailOrder.remark && (
|
||||
<div className="text-sm">
|
||||
<span className="text-muted-foreground">备注:</span>
|
||||
<span>{detailOrder.remark}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDetailDialogOpen(false)}>关闭</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { Navigate } from 'react-router-dom'
|
||||
|
||||
// Parent route /plant/exchange → redirect to first child
|
||||
export default function ExchangeIndexPage() {
|
||||
return <Navigate to="/plant/exchange/item" replace />
|
||||
}
|
||||
Reference in New Issue
Block a user