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) {
|
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 })
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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