feat(admin): 新增后台商品管理与审核视图

This commit is contained in:
yml2213
2026-10-30 13:01:55 +08:00
parent a978ec07f4
commit cd66bca9d5
9 changed files with 1052 additions and 7 deletions
@@ -0,0 +1,328 @@
import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import { Alert, Button, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd'
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
import { fetchAdminListings, fetchAdminPendingListings } from '@/features/admin/api'
import { AdminListingDetailDrawer } from '@/features/admin/AdminListingDetailDrawer'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import { StatusTag } from '@/features/admin/components/StatusTag'
import {
listingReviewStatusMeta,
listingReviewStatusOptions,
listingStatusMeta,
listingStatusOptions,
} from '@/features/listings/labels'
import type { AdminListing, AdminListingQuery } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
type ListingMode = 'all' | 'review'
const emptyFilters: AdminListingQuery = {
keyword: '',
status: '',
review_status: '',
}
function buildColumns(
onOpen: (row: AdminListing) => void,
canView: boolean,
mode: ListingMode
): TableColumnsType<AdminListing> {
return [
{
title: '商品',
dataIndex: 'title',
width: 260,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
{canView ? (
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
{row.title || '未命名商品'}
</button>
) : (
<strong>{row.title || '未命名商品'}</strong>
)}
<span className="admin-mono">{row.listing_no}</span>
<span>
{formatText(row.server_region)} · {formatText(row.login_platform)}
</span>
</div>
),
},
{
title: '号主',
key: 'owner',
width: 170,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.owner_phone)}</span>
<span>
{row.owner_nickname || '未设置昵称'} · ID {row.owner_id}
</span>
</div>
),
},
{
title: '价格',
key: 'price',
width: 140,
align: 'right',
render: (_, row) => (
<div className="admin-amount">
<strong>{formatMoney(row.price_cent)}</strong>
<span>押金 {formatMoney(row.deposit_amount_cent)}</span>
<span>哈夫币 {row.haf_coin_amount.toLocaleString('zh-CN')}</span>
</div>
),
},
{
title: '商品状态',
dataIndex: 'status',
width: 120,
render: value => <StatusTag meta={listingStatusMeta(value)} />,
},
{
title: '审核状态',
dataIndex: 'review_status',
width: 120,
render: value => <StatusTag meta={listingReviewStatusMeta(value)} />,
},
{
title: '审核意见',
dataIndex: 'review_reason',
width: 200,
ellipsis: true,
render: value =>
value ? (
<Typography.Text type="secondary">{value}</Typography.Text>
) : (
<Typography.Text type="secondary">—</Typography.Text>
),
},
{
title: mode === 'review' ? '提交时间' : '更新时间',
dataIndex: mode === 'review' ? 'created_at' : 'updated_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 96,
fixed: 'right',
render: (_, row) =>
canView ? (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onOpen(row)}>
详情
</Button>
) : (
<Typography.Text type="secondary">无权限</Typography.Text>
),
},
]
}
export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
const isReviewMode = mode === 'review'
const [draft, setDraft] = useState<AdminListingQuery>(() => ({
...emptyFilters,
review_status: isReviewMode ? 'pending' : '',
}))
const [applied, setApplied] = useState<AdminListingQuery>(() => ({
...emptyFilters,
review_status: isReviewMode ? 'pending' : '',
}))
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [detailId, setDetailId] = useState<number | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('listing:view'))
const listQuery = useQuery({
queryKey: ['admin-listings', applied, page, pageSize],
queryFn: () => fetchAdminListings({ ...applied, page, page_size: pageSize }),
enabled: !isReviewMode,
placeholderData: keepPreviousData,
})
const pendingQuery = useQuery({
queryKey: ['admin-pending-listings'],
queryFn: fetchAdminPendingListings,
enabled: isReviewMode,
})
const rawItems = isReviewMode ? pendingQuery.data ?? [] : listQuery.data?.items ?? []
const reviewFiltered = useMemo(() => {
if (!isReviewMode) return rawItems
const keyword = (applied.keyword ?? '').toLowerCase()
return rawItems.filter(item => {
if (applied.status && item.status !== applied.status) return false
if (!keyword) return true
return [item.title, item.listing_no, item.owner_phone, item.owner_nickname]
.filter(Boolean)
.some(value => String(value).toLowerCase().includes(keyword))
})
}, [isReviewMode, rawItems, applied.keyword, applied.status])
const total = isReviewMode ? reviewFiltered.length : listQuery.data?.total ?? 0
const items = isReviewMode
? reviewFiltered.slice((page - 1) * pageSize, page * pageSize)
: rawItems
const isFetching = isReviewMode ? pendingQuery.isFetching : listQuery.isFetching
const isPending = isReviewMode ? pendingQuery.isPending : listQuery.isPending
const isError = isReviewMode ? pendingQuery.isError : listQuery.isError
const error = isReviewMode ? pendingQuery.error : listQuery.error
const refetch = () => (isReviewMode ? pendingQuery.refetch() : listQuery.refetch())
const openDetail = (id: number) => {
if (!canView) return
setDetailId(id)
setDetailOpen(true)
}
const columns = useMemo(
() => buildColumns(row => openDetail(row.id), canView, mode),
[canView, mode]
)
const patchFilter = (patch: Partial<AdminListingQuery>) => {
setDraft(current => ({ ...current, ...patch }))
}
const applyFilters = () => {
setApplied({
...draft,
keyword: draft.keyword?.trim() ?? '',
review_status: draft.review_status || (isReviewMode ? 'pending' : ''),
})
setPage(1)
}
const resetFilters = () => {
const next = { ...emptyFilters, review_status: isReviewMode ? 'pending' : '' }
setDraft(next)
setApplied(next)
setPage(1)
}
const hasActiveFilters = Object.entries(draft).some(
([key, value]) =>
!(isReviewMode && key === 'review_status') && value !== '' && value != null
)
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
return (
<div className="admin-page">
<AdminPageHeader
eyebrow={isReviewMode ? 'JIEYOU OPS · 审核' : 'JIEYOU OPS · 商品'}
title={isReviewMode ? '商品审核' : '商品管理'}
description={
isReviewMode
? '按提交顺序处理待审商品,核对账号、价格与截图后再上架。'
: '查看全量商品、号主、价格、状态与审核意见;点击标题查看详情。'
}
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {total} 件</Tag>
{isReviewMode ? <Tag color="gold">待审核队列</Tag> : null}
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Button icon={<ReloadOutlined />} onClick={() => void refetch()} loading={isFetching}>
刷新数据
</Button>
}
/>
<FilterBar
onSearch={applyFilters}
onReset={hasActiveFilters ? resetFilters : undefined}
loading={isFetching}
>
<FilterField label="关键词" width={300}>
<Input
allowClear
value={draft.keyword}
placeholder="商品编号 / 标题 / 号主手机号"
onChange={event => patchFilter({ keyword: event.target.value })}
/>
</FilterField>
<FilterField label="商品状态" width={150}>
<Select
allowClear
value={draft.status || undefined}
placeholder="全部状态"
options={listingStatusOptions}
onChange={value => patchFilter({ status: value ?? '' })}
/>
</FilterField>
{isReviewMode ? null : (
<FilterField label="审核状态" width={150}>
<Select
allowClear
value={draft.review_status || undefined}
placeholder="全部审核"
options={listingReviewStatusOptions}
onChange={value => patchFilter({ review_status: value ?? '' })}
/>
</FilterField>
)}
</FilterBar>
{isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="商品列表没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminListing>
title={<span>{isReviewMode ? '待审核商品' : '商品列表'}</span>}
extra={
<Typography.Text type="secondary">
{isPending ? '加载中' : `第 ${page} 页 · 当前 ${items.length} 条`}
</Typography.Text>
}
rowKey="id"
columns={columns}
dataSource={items}
loading={isFetching}
total={total}
page={page}
pageSize={pageSize}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
}}
onRowClick={canView ? row => openDetail(row.id) : undefined}
emptyDescription={isReviewMode ? '没有待审核商品' : '没有匹配的商品'}
/>
{canView ? (
<AdminListingDetailDrawer
listingId={detailId}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onChanged={() => {
void refetch()
}}
/>
) : null}
</div>
)
}