feat(admin): 新增后台商品管理与审核视图
This commit is contained in:
+4
-4
@@ -194,12 +194,12 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
|
||||
## 5. 已知限制
|
||||
|
||||
1. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。
|
||||
2. 后台除仪表盘、订单列表与订单详情外,其余页面仍为统一占位。
|
||||
2. 后台已完成仪表盘、订单列表/详情、商品列表/详情与商品审核,其余页面仍为统一占位。
|
||||
3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。
|
||||
4. 后台菜单权限码基于旧项目推断:订单页已核对为 `order:list`(列表)与 `order:view`(详情),其余页面仍需逐一核对。
|
||||
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`;其余页面仍需核对。
|
||||
5. SVG 中文尚未转曲。
|
||||
6. 首屏包体仍偏大。
|
||||
7. 移动端 `ef01486`、后台订单 `17b985a`、移动订单与消息 `e8a20eb` 已提交;分销后台轻改在后续批次提交。
|
||||
6. 入口包已降至 495 kB(gzip 161 kB);AntD 共享基础设施仍在入口,后续可拆独立入口。
|
||||
7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# Phase 2 验收报告(后台商品管理与审核)
|
||||
|
||||
> 日期:2026-10-30
|
||||
> 项目:解忧商行(hfb_jysh)
|
||||
> 范围:`/console-jy8k3m/listings`、`/console-jy8k3m/listings/review`
|
||||
> 结论:**本切片通过,商品列表、详情与审核动作已接真实接口**
|
||||
|
||||
---
|
||||
|
||||
## 1. 接口与权限
|
||||
|
||||
| 能力 | 接口 | 权限码 |
|
||||
| --- | --- | --- |
|
||||
| 商品列表 | `GET /api/admin/listings` | `listing:view` |
|
||||
| 商品详情 | `GET /api/admin/listings/:id` | `listing:view` |
|
||||
| 待审核队列 | `GET /api/admin/listings/pending` | `listing:approve` |
|
||||
| 通过审核 | `POST /api/admin/listings/:id/approve` | `listing:approve` |
|
||||
| 拒绝审核 | `POST /api/admin/listings/:id/reject` | `listing:reject` |
|
||||
| 下架 | `POST /api/admin/listings/:id/offline` | `listing:offline` |
|
||||
| 标记异常 | `POST /api/admin/listings/:id/mark-abnormal` | `listing:offline` |
|
||||
|
||||
前端菜单权限已核对:商品管理 `listing:view`、商品审核 `listing:approve`,与后端一致。
|
||||
|
||||
## 2. 商品列表
|
||||
|
||||
`frontend/src/features/admin/AdminListingsPage.tsx`
|
||||
|
||||
- 筛选:关键词(商品编号 / 标题 / 号主手机号)、商品状态、审核状态
|
||||
- 列:商品(标题/编号/区服/平台)、号主、价格与押金、商品状态、审核状态、审核意见、更新时间、详情
|
||||
- 分页 10 / 20 / 50,翻页保留上页数据
|
||||
- 点击标题、行或「详情」打开详情抽屉
|
||||
- 无 `listing:view` 时隐藏详情入口与行点击
|
||||
|
||||
## 3. 商品审核队列
|
||||
|
||||
同一页面的 `mode="review"`:
|
||||
|
||||
- 使用 `GET /api/admin/listings/pending`,只取待审核商品
|
||||
- 关键词与商品状态在本地过滤,分页在前端切片
|
||||
- 审核通过 / 拒绝后同时失效列表与待审核缓存
|
||||
|
||||
## 4. 商品详情抽屉
|
||||
|
||||
`frontend/src/features/admin/AdminListingDetailDrawer.tsx`
|
||||
|
||||
- 标题区:商品标题、编号、号主手机号、商品/审核/交易中标签
|
||||
- 分段:商品概览、商品信息、价格与押金、号主与来源、审核意见、资产摘要、商品截图
|
||||
- 资产摘要按已知字段映射中文名,`online_time` 渲染为「开始 至 结束」
|
||||
- 截图支持 AntD 图片预览
|
||||
- 操作按钮按权限与状态显示:
|
||||
- 待审核 + `listing:approve`:通过审核
|
||||
- 待审核 + `listing:reject`:拒绝(必填原因)
|
||||
- 非下架/完成/封存 + `listing:offline`:下架、标记异常(必填原因)
|
||||
- 操作成功后刷新详情、商品列表、待审核队列
|
||||
|
||||
## 5. 验证结果
|
||||
|
||||
```text
|
||||
npx tsc --noEmit 通过
|
||||
npm run build 通过(入口 495.21 kB,gzip 160.95 kB,无体积警告)
|
||||
GET /api/admin/listings?page=1&page_size=5 ok,返回商品 DTO 与资产摘要
|
||||
GET /api/admin/listings/1 ok,返回完整详情
|
||||
GET /api/admin/listings/pending ok,{"items":[]}
|
||||
GET /console-jy8k3m/listings 200
|
||||
GET /console-jy8k3m/listings/review 200
|
||||
```
|
||||
|
||||
开发库当前 1 个已上架商品、0 个待审核商品,因此审核动作未做真实写入验证,避免污染开发数据;审核接口契约与按钮权限已按后端路由核对。
|
||||
|
||||
## 6. 已知限制
|
||||
|
||||
1. 审核调价、转移号主、修改截图、外链商品调价尚未接入。
|
||||
2. 未做批量通过/批量拒绝。
|
||||
3. 资产摘要未知字段仍回退英文 key。
|
||||
4. 主包体积已达标,但 AntD 共享基础设施仍在入口包内。
|
||||
|
||||
## 7. 下一步建议
|
||||
|
||||
1. 后台用户管理列表与用户详情,接通订单/商品深链。
|
||||
2. 商品审核调价与转移号主。
|
||||
3. 后台退款审核、申诉仲裁。
|
||||
4. 移动端下单、支付与交接/归还/结账。
|
||||
@@ -395,6 +395,8 @@ hfb_jysh/
|
||||
|
||||
### Phase 4:管理后台核心(4–5 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、商品列表/详情与商品审核;退款、提现、用户、申诉与系统配置待迁移。
|
||||
|
||||
- 后台登录、工作台、订单列表、订单详情。
|
||||
- 商品列表、商品详情、审核。
|
||||
- 用户列表、用户详情、实名信息。
|
||||
|
||||
@@ -68,6 +68,11 @@ const AdminOrdersPage = lazy(() =>
|
||||
default: module.AdminOrdersPage,
|
||||
}))
|
||||
)
|
||||
const AdminListingsPage = lazy(() =>
|
||||
import('@/features/admin/AdminListingsPage').then(module => ({
|
||||
default: module.AdminListingsPage,
|
||||
}))
|
||||
)
|
||||
const AdminLayout = lazy(() =>
|
||||
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
|
||||
)
|
||||
@@ -78,8 +83,6 @@ const AdminSectionPlaceholder = lazy(() =>
|
||||
const adminSections: Array<{ path: string; title: string; description: string }> = [
|
||||
{ path: 'orders/refund-review', title: '退款审核', description: '审核退款申请并核对资金影响。' },
|
||||
{ path: 'disputes', title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' },
|
||||
{ path: 'listings', title: '商品管理', description: '查看商品、账号资产与上下架状态。' },
|
||||
{ path: 'listings/review', title: '商品审核', description: '审核待上架商品与账号截图。' },
|
||||
{ path: 'users', title: '用户管理', description: '查看用户、实名状态与交易记录。' },
|
||||
{ path: 'finance', title: '财务看板', description: '查看收入、退款、结算与资金趋势。' },
|
||||
{ path: 'withdrawals', title: '提现审核', description: '审核提现申请与打款确认。' },
|
||||
@@ -119,6 +122,8 @@ export function AppRouter() {
|
||||
<Route element={<AdminLayout />}>
|
||||
<Route index element={<AdminDashboardPage />} />
|
||||
<Route path="orders" element={<AdminOrdersPage />} />
|
||||
<Route path="listings" element={<AdminListingsPage />} />
|
||||
<Route path="listings/review" element={<AdminListingsPage mode="review" />} />
|
||||
{adminSections.map(section => (
|
||||
<Route
|
||||
key={section.path}
|
||||
|
||||
@@ -0,0 +1,435 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import axios from 'axios'
|
||||
import {
|
||||
App,
|
||||
Button,
|
||||
Image,
|
||||
Input,
|
||||
Modal,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { CopyOutlined, DownOutlined, StopOutlined, WarningOutlined } from '@ant-design/icons'
|
||||
|
||||
import {
|
||||
approveAdminListing,
|
||||
fetchAdminListing,
|
||||
markAbnormalAdminListing,
|
||||
offlineAdminListing,
|
||||
rejectAdminListing,
|
||||
} from '@/features/admin/api'
|
||||
import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer'
|
||||
import { StatusTag } from '@/features/admin/components/StatusTag'
|
||||
import { handoffModeMap, readStatusMeta, settlementModeMap } from '@/features/admin/labels'
|
||||
import {
|
||||
assetFieldLabel,
|
||||
formatAssetValue,
|
||||
listingReviewStatusMeta,
|
||||
listingStatusMeta,
|
||||
} from '@/features/listings/labels'
|
||||
import type { AdminListing } from '@/features/admin/types'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { emptyText, formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
|
||||
interface AdminListingDetailDrawerProps {
|
||||
listingId: number | null
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
onChanged?: () => void
|
||||
}
|
||||
|
||||
type ReasonAction = 'reject' | 'offline' | 'abnormal'
|
||||
|
||||
const actionCopy: Record<ReasonAction, { title: string; hint: string; okText: string }> = {
|
||||
reject: {
|
||||
title: '拒绝商品审核',
|
||||
hint: '拒绝后商品不会上架,号主会看到审核意见并可修改后重新提交。',
|
||||
okText: '确认拒绝',
|
||||
},
|
||||
offline: {
|
||||
title: '下架商品',
|
||||
hint: '下架后前台不再展示该商品,已有订单不受影响。',
|
||||
okText: '确认下架',
|
||||
},
|
||||
abnormal: {
|
||||
title: '标记商品异常',
|
||||
hint: '用于风控或人工介入场景,请写清原因,便于后续复核。',
|
||||
okText: '标记异常',
|
||||
},
|
||||
}
|
||||
|
||||
function readError(error: unknown) {
|
||||
if (axios.isAxiosError(error)) {
|
||||
const payload = error.response?.data as { message?: string } | undefined
|
||||
return payload?.message || error.message
|
||||
}
|
||||
return error instanceof Error ? error.message : '操作失败,请稍后再试'
|
||||
}
|
||||
|
||||
function buildSections(listing: AdminListing): DetailSection[] {
|
||||
const assets = Object.entries(listing.asset_summary ?? {}).filter(
|
||||
([, value]) => value !== '' && value != null
|
||||
)
|
||||
|
||||
const sections: DetailSection[] = [
|
||||
{
|
||||
key: 'overview',
|
||||
title: '商品概览',
|
||||
fields: [
|
||||
{ key: 'listing_no', label: '商品编号', value: <span className="admin-mono">{listing.listing_no}</span> },
|
||||
{ key: 'status', label: '商品状态', value: <StatusTag meta={listingStatusMeta(listing.status)} /> },
|
||||
{
|
||||
key: 'review_status',
|
||||
label: '审核状态',
|
||||
value: <StatusTag meta={listingReviewStatusMeta(listing.review_status)} />,
|
||||
},
|
||||
{
|
||||
key: 'in_transaction',
|
||||
label: '交易中',
|
||||
value: listing.in_transaction ? <Tag color="cyan">是</Tag> : '否',
|
||||
},
|
||||
{ key: 'published_at', label: '上架时间', value: formatDateTime(listing.published_at) },
|
||||
{ key: 'created_at', label: '创建时间', value: formatDateTime(listing.created_at) },
|
||||
{ key: 'updated_at', label: '更新时间', value: formatDateTime(listing.updated_at) },
|
||||
{
|
||||
key: 'site',
|
||||
label: '来源分站',
|
||||
value: listing.distribution_site_name
|
||||
? `${listing.distribution_site_name}${listing.distribution_site_code ? ` · ${listing.distribution_site_code}` : ''}`
|
||||
: '主站',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'content',
|
||||
title: '商品信息',
|
||||
fields: [
|
||||
{ key: 'title', label: '标题', value: formatText(listing.title), span: 2 },
|
||||
{
|
||||
key: 'description',
|
||||
label: '账号说明',
|
||||
value: <div className="admin-detail__pre">{listing.description || emptyText}</div>,
|
||||
span: 2,
|
||||
},
|
||||
{ key: 'game', label: '游戏', value: formatText(listing.game_name) },
|
||||
{ key: 'server', label: '区服', value: formatText(listing.server_region) },
|
||||
{ key: 'platform', label: '登录平台', value: formatText(listing.login_platform) },
|
||||
{ key: 'rank', label: '段位', value: formatText(listing.rank_level) },
|
||||
{
|
||||
key: 'coin',
|
||||
label: '哈夫币',
|
||||
value: listing.haf_coin_amount.toLocaleString('zh-CN'),
|
||||
},
|
||||
{
|
||||
key: 'accelerated',
|
||||
label: '加速出售',
|
||||
value: listing.is_accelerated_sale ? <Tag color="gold">是</Tag> : '否',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'price',
|
||||
title: '价格与押金',
|
||||
fields: [
|
||||
{ key: 'price', label: '售价', value: formatMoney(listing.price_cent) },
|
||||
{ key: 'deposit', label: '押金', value: formatMoney(listing.deposit_amount_cent) },
|
||||
{
|
||||
key: 'handoff_mode',
|
||||
label: '交接方式',
|
||||
value: <StatusTag meta={readStatusMeta(handoffModeMap, listing.handoff_mode)} />,
|
||||
},
|
||||
{
|
||||
key: 'settlement_mode',
|
||||
label: '结算方式',
|
||||
value: <StatusTag meta={readStatusMeta(settlementModeMap, listing.settlement_mode)} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'owner',
|
||||
title: '号主与来源',
|
||||
fields: [
|
||||
{
|
||||
key: 'owner',
|
||||
label: '号主',
|
||||
value: (
|
||||
<Space size={6} wrap>
|
||||
<span>{formatText(listing.owner_phone)}</span>
|
||||
<Typography.Text type="secondary">ID {listing.owner_id}</Typography.Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{ key: 'nickname', label: '昵称', value: formatText(listing.owner_nickname) },
|
||||
{ key: 'account_id', label: '账号 ID', value: formatText(listing.account_id) },
|
||||
{ key: 'source', label: '来源渠道', value: formatText(listing.source_channel) },
|
||||
{
|
||||
key: 'external',
|
||||
label: '外链上传',
|
||||
value: listing.is_external_upload ? <Tag color="geekblue">是</Tag> : '否',
|
||||
},
|
||||
{
|
||||
key: 'conversation',
|
||||
label: '商品群会话',
|
||||
value: listing.listing_group_conversation_id
|
||||
? `#${listing.listing_group_conversation_id}`
|
||||
: emptyText,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
title: '审核意见',
|
||||
fields: [
|
||||
{
|
||||
key: 'reason',
|
||||
label: '审核意见',
|
||||
value: (
|
||||
<div className="admin-detail__pre">{listing.review_reason || '暂无审核意见'}</div>
|
||||
),
|
||||
span: 2,
|
||||
},
|
||||
{
|
||||
key: 'review_status',
|
||||
label: '审核状态',
|
||||
value: <StatusTag meta={listingReviewStatusMeta(listing.review_status)} />,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
if (assets.length > 0) {
|
||||
sections.push({
|
||||
key: 'assets',
|
||||
title: '资产摘要',
|
||||
fields: assets.map(([key, value]) => ({
|
||||
key,
|
||||
label: assetFieldLabel(key),
|
||||
value: formatAssetValue(key, value),
|
||||
})),
|
||||
})
|
||||
}
|
||||
|
||||
if (listing.screenshot_urls.length > 0 || listing.cover_url) {
|
||||
const images = listing.screenshot_urls.length > 0 ? listing.screenshot_urls : [listing.cover_url]
|
||||
sections.push({
|
||||
key: 'screenshots',
|
||||
title: '商品截图',
|
||||
fields: [
|
||||
{
|
||||
key: 'images',
|
||||
label: '截图',
|
||||
span: 2,
|
||||
value: (
|
||||
<Image.PreviewGroup>
|
||||
<Space size={8} wrap>
|
||||
{images.map((url, index) => (
|
||||
<Image
|
||||
key={url}
|
||||
src={url}
|
||||
alt={`商品截图 ${index + 1}`}
|
||||
width={112}
|
||||
height={84}
|
||||
style={{ objectFit: 'cover', borderRadius: 6 }}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Image.PreviewGroup>
|
||||
),
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
return sections
|
||||
}
|
||||
|
||||
export function AdminListingDetailDrawer({
|
||||
listingId,
|
||||
open,
|
||||
onClose,
|
||||
onChanged,
|
||||
}: AdminListingDetailDrawerProps) {
|
||||
const { message, modal } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const permissions = useAdminSessionStore(state => state.admin?.permissions)
|
||||
const can = (code: string) => Boolean(permissions?.includes('*') || permissions?.includes(code))
|
||||
const [reasonAction, setReasonAction] = useState<ReasonAction | null>(null)
|
||||
const [reason, setReason] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
|
||||
const query = useQuery({
|
||||
queryKey: ['admin-listing', listingId],
|
||||
queryFn: () => fetchAdminListing(listingId as number),
|
||||
enabled: open && Boolean(listingId),
|
||||
staleTime: 10_000,
|
||||
})
|
||||
|
||||
const listing = query.data
|
||||
const errorMessage = query.error ? readError(query.error) : null
|
||||
|
||||
const refreshAfterChange = async () => {
|
||||
await query.refetch()
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-listings'] })
|
||||
await queryClient.invalidateQueries({ queryKey: ['admin-pending-listings'] })
|
||||
onChanged?.()
|
||||
}
|
||||
|
||||
const copyListingNo = async () => {
|
||||
if (!listing) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(listing.listing_no)
|
||||
message.success('商品编号已复制')
|
||||
} catch {
|
||||
message.warning('当前环境不支持剪贴板,请手动复制')
|
||||
}
|
||||
}
|
||||
|
||||
const approve = () => {
|
||||
if (!listing) return
|
||||
modal.confirm({
|
||||
title: '通过审核并上架?',
|
||||
content: `商品「${listing.title}」通过审核后会按商品状态对外展示。`,
|
||||
okText: '通过审核',
|
||||
cancelText: '再看看',
|
||||
onOk: async () => {
|
||||
try {
|
||||
await approveAdminListing(listing.id)
|
||||
message.success('审核已通过')
|
||||
await refreshAfterChange()
|
||||
} catch (error) {
|
||||
message.error(readError(error))
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const openReasonModal = (action: ReasonAction) => {
|
||||
setReason('')
|
||||
setReasonAction(action)
|
||||
}
|
||||
|
||||
const submitReason = async () => {
|
||||
if (!listing || !reasonAction) return
|
||||
const text = reason.trim()
|
||||
if (!text) {
|
||||
message.warning('请填写原因')
|
||||
return
|
||||
}
|
||||
setSubmitting(true)
|
||||
try {
|
||||
if (reasonAction === 'reject') {
|
||||
await rejectAdminListing(listing.id, text)
|
||||
message.success('已拒绝该商品')
|
||||
} else if (reasonAction === 'offline') {
|
||||
await offlineAdminListing(listing.id, text)
|
||||
message.success('商品已下架')
|
||||
} else {
|
||||
await markAbnormalAdminListing(listing.id, text)
|
||||
message.success('已标记商品异常')
|
||||
}
|
||||
setReasonAction(null)
|
||||
setReason('')
|
||||
await refreshAfterChange()
|
||||
} catch (error) {
|
||||
message.error(readError(error))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const isPendingReview = listing?.review_status === 'pending'
|
||||
const canOffline =
|
||||
listing != null &&
|
||||
!['offline', 'completed', 'sealed'].includes(listing.status) &&
|
||||
can('listing:offline')
|
||||
const canMarkAbnormal =
|
||||
listing != null && !['abnormal', 'completed', 'sealed'].includes(listing.status) && can('listing:offline')
|
||||
|
||||
const actionCopyItem = reasonAction ? actionCopy[reasonAction] : null
|
||||
|
||||
return (
|
||||
<>
|
||||
<DetailDrawer
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
loading={query.isPending && Boolean(listingId)}
|
||||
error={query.isError ? errorMessage : null}
|
||||
onRetry={() => void query.refetch()}
|
||||
title={listing?.title || '商品详情'}
|
||||
subtitle={
|
||||
listing ? (
|
||||
<span>
|
||||
<span className="admin-mono">{listing.listing_no}</span> · 号主{' '}
|
||||
{formatText(listing.owner_phone)}
|
||||
</span>
|
||||
) : null
|
||||
}
|
||||
tags={
|
||||
listing ? (
|
||||
<Space size={6} wrap>
|
||||
<StatusTag meta={listingStatusMeta(listing.status)} />
|
||||
<StatusTag meta={listingReviewStatusMeta(listing.review_status)} />
|
||||
{listing.in_transaction ? <Tag color="cyan">交易中</Tag> : null}
|
||||
</Space>
|
||||
) : null
|
||||
}
|
||||
sections={listing ? buildSections(listing) : []}
|
||||
footer={
|
||||
<Space wrap>
|
||||
{listing && isPendingReview && can('listing:approve') ? (
|
||||
<Button type="primary" onClick={approve}>
|
||||
通过审核
|
||||
</Button>
|
||||
) : null}
|
||||
{listing && isPendingReview && can('listing:reject') ? (
|
||||
<Button danger icon={<DownOutlined />} onClick={() => openReasonModal('reject')}>
|
||||
拒绝
|
||||
</Button>
|
||||
) : null}
|
||||
{canOffline ? (
|
||||
<Button icon={<StopOutlined />} onClick={() => openReasonModal('offline')}>
|
||||
下架
|
||||
</Button>
|
||||
) : null}
|
||||
{canMarkAbnormal ? (
|
||||
<Button icon={<WarningOutlined />} onClick={() => openReasonModal('abnormal')}>
|
||||
标记异常
|
||||
</Button>
|
||||
) : null}
|
||||
<Button icon={<CopyOutlined />} onClick={() => void copyListingNo()} disabled={!listing}>
|
||||
复制编号
|
||||
</Button>
|
||||
<Button onClick={onClose}>关闭</Button>
|
||||
</Space>
|
||||
}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
open={reasonAction !== null}
|
||||
title={actionCopyItem?.title}
|
||||
okText={actionCopyItem?.okText}
|
||||
cancelText="取消"
|
||||
confirmLoading={submitting}
|
||||
okButtonProps={{ danger: reasonAction !== 'offline' }}
|
||||
onOk={() => void submitReason()}
|
||||
onCancel={() => {
|
||||
if (submitting) return
|
||||
setReasonAction(null)
|
||||
setReason('')
|
||||
}}
|
||||
>
|
||||
<Typography.Paragraph type="secondary">{actionCopyItem?.hint}</Typography.Paragraph>
|
||||
<Input.TextArea
|
||||
rows={3}
|
||||
value={reason}
|
||||
maxLength={200}
|
||||
showCount
|
||||
placeholder="请填写原因,至少 2 个字"
|
||||
onChange={event => setReason(event.target.value)}
|
||||
/>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -2,6 +2,9 @@ import { adminClient } from '@/shared/api/adminClient'
|
||||
import type { ApiResponse } from '@/shared/api/types'
|
||||
import type {
|
||||
AdminCaptcha,
|
||||
AdminListing,
|
||||
AdminListingPage,
|
||||
AdminListingQuery,
|
||||
AdminLoginData,
|
||||
AdminOrderDetail,
|
||||
AdminOrderPage,
|
||||
@@ -51,7 +54,7 @@ export async function fetchAdminDashboard() {
|
||||
return data.data
|
||||
}
|
||||
|
||||
function compactParams(query: AdminOrderQuery) {
|
||||
function compactParams<T extends object>(query: T) {
|
||||
return Object.fromEntries(
|
||||
Object.entries(query).filter(([, value]) => value !== '' && value != null)
|
||||
)
|
||||
@@ -73,3 +76,57 @@ export async function fetchAdminOrder(id: number | string) {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminOrderDetail>>(`/admin/orders/${id}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function fetchAdminListings(query: AdminListingQuery) {
|
||||
const { data } = await adminClient.get<ApiResponse<Partial<AdminListingPage>>>('/admin/listings', {
|
||||
params: compactParams(query),
|
||||
})
|
||||
return {
|
||||
items: data.data.items ?? [],
|
||||
total: data.data.total ?? 0,
|
||||
page: data.data.page ?? query.page ?? 1,
|
||||
page_size: data.data.page_size ?? query.page_size ?? 20,
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchAdminListing(id: number | string) {
|
||||
const { data } = await adminClient.get<ApiResponse<AdminListing>>(`/admin/listings/${id}`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
/** 审核队列使用独立接口:权限为 listing:approve,且返回全部待审商品。 */
|
||||
export async function fetchAdminPendingListings() {
|
||||
const { data } = await adminClient.get<ApiResponse<{ items: AdminListing[] }>>(
|
||||
'/admin/listings/pending'
|
||||
)
|
||||
return data.data.items ?? []
|
||||
}
|
||||
|
||||
export async function approveAdminListing(id: number | string) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminListing>>(`/admin/listings/${id}/approve`)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function rejectAdminListing(id: number | string, reason: string) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminListing>>(
|
||||
`/admin/listings/${id}/reject`,
|
||||
{ reason }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function offlineAdminListing(id: number | string, reason: string) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminListing>>(
|
||||
`/admin/listings/${id}/offline`,
|
||||
{ reason }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
export async function markAbnormalAdminListing(id: number | string, reason: string) {
|
||||
const { data } = await adminClient.post<ApiResponse<AdminListing>>(
|
||||
`/admin/listings/${id}/mark-abnormal`,
|
||||
{ reason }
|
||||
)
|
||||
return data.data
|
||||
}
|
||||
|
||||
@@ -208,3 +208,57 @@ export interface AdminOrderDetail {
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminListingQuery {
|
||||
page?: number
|
||||
page_size?: number
|
||||
keyword?: string
|
||||
status?: string
|
||||
review_status?: string
|
||||
owner_id?: number
|
||||
}
|
||||
|
||||
/** 后台商品列表与详情共用 ListingDTO,字段完整但截图等资源按商品状态脱敏。 */
|
||||
export interface AdminListing {
|
||||
id: number
|
||||
listing_no: string
|
||||
account_id: number
|
||||
owner_id: number
|
||||
owner_phone?: string
|
||||
owner_nickname?: string
|
||||
source_channel?: string
|
||||
is_external_upload: boolean
|
||||
title: string
|
||||
description: string
|
||||
game_name: string
|
||||
server_region: string
|
||||
login_platform: string
|
||||
rank_level: string
|
||||
haf_coin_amount: number
|
||||
asset_summary?: Record<string, unknown> | null
|
||||
screenshot_urls: string[]
|
||||
cover_url: string
|
||||
price_cent: number
|
||||
deposit_amount_cent: number
|
||||
is_accelerated_sale: boolean
|
||||
in_transaction: boolean
|
||||
status: string
|
||||
review_status: string
|
||||
handoff_mode: string
|
||||
settlement_mode: string
|
||||
review_reason: string
|
||||
published_at?: string | null
|
||||
distribution_site_id?: number | null
|
||||
distribution_site_code?: string
|
||||
distribution_site_name?: string
|
||||
listing_group_conversation_id?: number
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export interface AdminListingPage {
|
||||
items: AdminListing[]
|
||||
total: number
|
||||
page: number
|
||||
page_size: number
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import type { StatusMeta } from '@/features/admin/labels'
|
||||
|
||||
export const listingStatusMap: Record<string, StatusMeta> = {
|
||||
draft: { label: '草稿', tone: 'default' },
|
||||
published: { label: '已上架', tone: 'success' },
|
||||
rented: { label: '租用中', tone: 'cyan' },
|
||||
offline: { label: '已下架', tone: 'default' },
|
||||
completed: { label: '已完成', tone: 'success' },
|
||||
abnormal: { label: '异常', tone: 'red' },
|
||||
sealed: { label: '已封存', tone: 'purple' },
|
||||
}
|
||||
|
||||
export const listingReviewStatusMap: Record<string, StatusMeta> = {
|
||||
none: { label: '未提交', tone: 'default' },
|
||||
pending: { label: '待审核', tone: 'gold' },
|
||||
approved: { label: '已通过', tone: 'success' },
|
||||
rejected: { label: '已拒绝', tone: 'red' },
|
||||
}
|
||||
|
||||
export const listingStatusOptions = Object.entries(listingStatusMap).map(([value, meta]) => ({
|
||||
value,
|
||||
label: meta.label,
|
||||
}))
|
||||
|
||||
export const listingReviewStatusOptions = Object.entries(listingReviewStatusMap).map(
|
||||
([value, meta]) => ({ value, label: meta.label })
|
||||
)
|
||||
|
||||
export function listingStatusMeta(value?: string | null): StatusMeta {
|
||||
if (!value) return { label: '—', tone: 'default' }
|
||||
return listingStatusMap[value] ?? { label: value, tone: 'default' }
|
||||
}
|
||||
|
||||
export function listingReviewStatusMeta(value?: string | null): StatusMeta {
|
||||
if (!value) return { label: '—', tone: 'default' }
|
||||
return listingReviewStatusMap[value] ?? { label: value, tone: 'default' }
|
||||
}
|
||||
|
||||
const assetFieldLabels: Record<string, string> = {
|
||||
season_insurance: '赛季保险',
|
||||
stamina_level: '体力等级',
|
||||
load_level: '负重等级',
|
||||
awm_bullets: 'AWM 子弹',
|
||||
level6_helmets: '六级头盔',
|
||||
level6_armor: '六级护甲',
|
||||
skin_group: '皮肤系列',
|
||||
skin_names: '皮肤名称',
|
||||
online_time: '在线时间',
|
||||
online_time_text: '在线时间',
|
||||
rank_level: '段位',
|
||||
haf_coin_amount: '哈夫币',
|
||||
}
|
||||
|
||||
export function assetFieldLabel(key: string) {
|
||||
return assetFieldLabels[key] ?? key
|
||||
}
|
||||
|
||||
function formatOnlineTime(value: unknown) {
|
||||
if (!value || typeof value !== 'object') return ''
|
||||
const item = value as { start?: unknown; end?: unknown }
|
||||
const start = typeof item.start === 'string' ? item.start : ''
|
||||
const end = typeof item.end === 'string' ? item.end : ''
|
||||
if (!start && !end) return ''
|
||||
return `${start || '—'} 至 ${end || '—'}`
|
||||
}
|
||||
|
||||
export function formatAssetValue(key: string, value: unknown): string {
|
||||
if (value == null || value === '') return '—'
|
||||
if (key === 'online_time') {
|
||||
const text = formatOnlineTime(value)
|
||||
if (text) return text
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(item => (typeof item === 'object' ? JSON.stringify(item) : String(item))).join('、')
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const text = formatOnlineTime(value)
|
||||
if (text) return text
|
||||
return JSON.stringify(value)
|
||||
}
|
||||
return String(value)
|
||||
}
|
||||
Reference in New Issue
Block a user