feat: 兑换中心

This commit is contained in:
Blizzard
2026-02-25 14:10:12 +08:00
parent 7d52705e05
commit 7252738ef0
6 changed files with 1260 additions and 14 deletions
+104
View File
@@ -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)
}
+5
View File
@@ -66,3 +66,8 @@ export function likePost(id: string, type: 1 | 2) {
export function commentPost(data: CreateCommentParams) { export function commentPost(data: CreateCommentParams) {
return post<{ msg: string }>('/post/comment', data) return post<{ msg: string }>('/post/comment', data)
} }
// 删除帖子(支持批量)
export function deletePost(ids: string[]) {
return post<{ msg: string }>('/post/delete', { ids })
}
+84 -14
View File
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react' 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 { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input' import { Input } from '@/components/ui/input'
import { Badge } from '@/components/ui/badge' import { Badge } from '@/components/ui/badge'
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar' import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
import { Checkbox } from '@/components/ui/checkbox'
import { import {
Table, Table,
TableBody, TableBody,
@@ -33,6 +34,7 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
// import { Separator } from '@/components/ui/separator' // import { Separator } from '@/components/ui/separator'
import { import {
getPostPage, getPostPage,
deletePost,
type Post, type Post,
type PostPageParams, type PostPageParams,
} from '@/api/post' } from '@/api/post'
@@ -52,6 +54,8 @@ export default function PostsPage() {
const [previewDialogOpen, setPreviewDialogOpen] = useState(false) const [previewDialogOpen, setPreviewDialogOpen] = useState(false)
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false) const [deleteDialogOpen, setDeleteDialogOpen] = useState(false)
const [selectedPost, setSelectedPost] = useState<Post | null>(null) const [selectedPost, setSelectedPost] = useState<Post | null>(null)
const [selectedIds, setSelectedIds] = useState<string[]>([])
const [deleting, setDeleting] = useState(false)
// 获取帖子列表 // 获取帖子列表
const fetchPosts = async () => { const fetchPosts = async () => {
@@ -85,16 +89,53 @@ export default function PostsPage() {
setPreviewDialogOpen(true) setPreviewDialogOpen(true)
} }
// 处理删除确认 // 处理删除确认(单个)
const handleDeleteConfirm = (post: Post) => { const handleDeleteConfirm = (post: Post) => {
setSelectedPost(post) setSelectedPost(post)
setDeleteDialogOpen(true) setDeleteDialogOpen(true)
} }
// 执行删除 (Mock) // 处理批量删除确认
const handleDelete = () => { const handleBatchDeleteConfirm = () => {
alert('删除功能开发中') setSelectedPost(null) // null means batch mode
setDeleteDialogOpen(false) 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) // 审核 (Mock)
@@ -164,6 +205,18 @@ export default function PostsPage() {
</Button> </Button>
</div> </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> </CardContent>
</Card> </Card>
@@ -186,7 +239,13 @@ export default function PostsPage() {
<Table> <Table>
<TableHeader> <TableHeader>
<TableRow className="bg-muted/30"> <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> <TableHead></TableHead>
<TableHead></TableHead> <TableHead></TableHead>
@@ -197,7 +256,7 @@ export default function PostsPage() {
<TableBody> <TableBody>
{posts.length === 0 ? ( {posts.length === 0 ? (
<TableRow> <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="flex flex-col items-center justify-center text-muted-foreground gap-3">
<div className="bg-muted p-4 rounded-full"> <div className="bg-muted p-4 rounded-full">
<Search className="h-8 w-8 opacity-40" /> <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) || '-' const timeDisplay = post.createdAtStr || post.createdAt?.replace('T', ' ').slice(0, 16) || '-'
return ( return (
<TableRow key={post.id} className="hover:bg-muted/10"> <TableRow key={post.id} className={`hover:bg-muted/10 ${selectedIds.includes(post.id) ? 'bg-primary/5' : ''}`}>
<TableCell className="pl-6 align-top py-4"> <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="space-y-2">
<div className="font-medium line-clamp-1 text-base">{post.title}</div> <div className="font-medium line-clamp-1 text-base">{post.title}</div>
<div className="text-sm text-muted-foreground line-clamp-2 leading-relaxed"> <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}> <Dialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle></DialogTitle> <DialogTitle></DialogTitle>
<DialogDescription> <DialogDescription>
"{selectedPost?.title}" {selectedPost
? <>{selectedPost.title}</>
: <> <span className="font-semibold text-foreground">{selectedIds.length}</span> </>
}
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<DialogFooter> <DialogFooter>
<Button variant="outline" onClick={() => setDeleteDialogOpen(false)}></Button> <Button variant="outline" onClick={() => setDeleteDialogOpen(false)} disabled={deleting}></Button>
<Button variant="destructive" onClick={handleDelete}></Button> <Button variant="destructive" onClick={handleDelete} disabled={deleting} className="min-w-[90px]">
{deleting ? <Loader2 className="h-4 w-4 animate-spin" /> : '确认删除'}
</Button>
</DialogFooter> </DialogFooter>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
+581
View File
@@ -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>
)
}
+480
View File
@@ -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>
)
}
+6
View File
@@ -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 />
}