diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index ce0e128..c0601fd 100644 --- a/docs/Phase1-验收报告.md +++ b/docs/Phase1-验收报告.md @@ -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. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。 --- diff --git a/docs/Phase2-商品管理与审核验收.md b/docs/Phase2-商品管理与审核验收.md new file mode 100644 index 0000000..6eed1f8 --- /dev/null +++ b/docs/Phase2-商品管理与审核验收.md @@ -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. 移动端下单、支付与交接/归还/结账。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 6b0356d..e752e33 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -395,6 +395,8 @@ hfb_jysh/ ### Phase 4:管理后台核心(4–5 周) +> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、商品列表/详情与商品审核;退款、提现、用户、申诉与系统配置待迁移。 + - 后台登录、工作台、订单列表、订单详情。 - 商品列表、商品详情、审核。 - 用户列表、用户详情、实名信息。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 29b484b..ec3a7bf 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { }> } /> } /> + } /> + } /> {adminSections.map(section => ( void + onChanged?: () => void +} + +type ReasonAction = 'reject' | 'offline' | 'abnormal' + +const actionCopy: Record = { + 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: {listing.listing_no} }, + { key: 'status', label: '商品状态', value: }, + { + key: 'review_status', + label: '审核状态', + value: , + }, + { + key: 'in_transaction', + label: '交易中', + value: listing.in_transaction ? 是 : '否', + }, + { 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:
{listing.description || emptyText}
, + 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 ? 是 : '否', + }, + ], + }, + { + 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: , + }, + { + key: 'settlement_mode', + label: '结算方式', + value: , + }, + ], + }, + { + key: 'owner', + title: '号主与来源', + fields: [ + { + key: 'owner', + label: '号主', + value: ( + + {formatText(listing.owner_phone)} + ID {listing.owner_id} + + ), + }, + { 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 ? 是 : '否', + }, + { + key: 'conversation', + label: '商品群会话', + value: listing.listing_group_conversation_id + ? `#${listing.listing_group_conversation_id}` + : emptyText, + }, + ], + }, + { + key: 'review', + title: '审核意见', + fields: [ + { + key: 'reason', + label: '审核意见', + value: ( +
{listing.review_reason || '暂无审核意见'}
+ ), + span: 2, + }, + { + key: 'review_status', + label: '审核状态', + value: , + }, + ], + }, + ] + + 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: ( + + + {images.map((url, index) => ( + {`商品截图 + ))} + + + ), + }, + ], + }) + } + + 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(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 ( + <> + void query.refetch()} + title={listing?.title || '商品详情'} + subtitle={ + listing ? ( + + {listing.listing_no} · 号主{' '} + {formatText(listing.owner_phone)} + + ) : null + } + tags={ + listing ? ( + + + + {listing.in_transaction ? 交易中 : null} + + ) : null + } + sections={listing ? buildSections(listing) : []} + footer={ + + {listing && isPendingReview && can('listing:approve') ? ( + + ) : null} + {listing && isPendingReview && can('listing:reject') ? ( + + ) : null} + {canOffline ? ( + + ) : null} + {canMarkAbnormal ? ( + + ) : null} + + + + } + /> + + void submitReason()} + onCancel={() => { + if (submitting) return + setReasonAction(null) + setReason('') + }} + > + {actionCopyItem?.hint} + setReason(event.target.value)} + /> + + + ) +} diff --git a/frontend/src/features/admin/AdminListingsPage.tsx b/frontend/src/features/admin/AdminListingsPage.tsx new file mode 100644 index 0000000..976f69f --- /dev/null +++ b/frontend/src/features/admin/AdminListingsPage.tsx @@ -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 { + return [ + { + title: '商品', + dataIndex: 'title', + width: 260, + fixed: 'left', + render: (_, row) => ( +
+ {canView ? ( + + ) : ( + {row.title || '未命名商品'} + )} + {row.listing_no} + + {formatText(row.server_region)} · {formatText(row.login_platform)} + +
+ ), + }, + { + title: '号主', + key: 'owner', + width: 170, + render: (_, row) => ( +
+ {formatText(row.owner_phone)} + + {row.owner_nickname || '未设置昵称'} · ID {row.owner_id} + +
+ ), + }, + { + title: '价格', + key: 'price', + width: 140, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.price_cent)} + 押金 {formatMoney(row.deposit_amount_cent)} + 哈夫币 {row.haf_coin_amount.toLocaleString('zh-CN')} +
+ ), + }, + { + title: '商品状态', + dataIndex: 'status', + width: 120, + render: value => , + }, + { + title: '审核状态', + dataIndex: 'review_status', + width: 120, + render: value => , + }, + { + title: '审核意见', + dataIndex: 'review_reason', + width: 200, + ellipsis: true, + render: value => + value ? ( + {value} + ) : ( + — + ), + }, + { + 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 ? ( + + ) : ( + 无权限 + ), + }, + ] +} + +export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) { + const isReviewMode = mode === 'review' + const [draft, setDraft] = useState(() => ({ + ...emptyFilters, + review_status: isReviewMode ? 'pending' : '', + })) + const [applied, setApplied] = useState(() => ({ + ...emptyFilters, + review_status: isReviewMode ? 'pending' : '', + })) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [detailId, setDetailId] = useState(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) => { + 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 ( +
+ + 共 {total} 件 + {isReviewMode ? 待审核队列 : null} + {isFetching && !isPending ? 正在更新 : null} + + } + extra={ + + } + /> + + + + patchFilter({ keyword: event.target.value })} + /> + + + patchFilter({ review_status: value ?? '' })} + /> + + )} + + + {isError ? ( + void refetch()}> + 重试 + + } + /> + ) : null} + + + title={{isReviewMode ? '待审核商品' : '商品列表'}} + extra={ + + {isPending ? '加载中' : `第 ${page} 页 · 当前 ${items.length} 条`} + + } + 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 ? ( + setDetailOpen(false)} + onChanged={() => { + void refetch() + }} + /> + ) : null} +
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index 3acb234..bc2da53 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -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(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>(`/admin/orders/${id}`) return data.data } + +export async function fetchAdminListings(query: AdminListingQuery) { + const { data } = await adminClient.get>>('/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>(`/admin/listings/${id}`) + return data.data +} + +/** 审核队列使用独立接口:权限为 listing:approve,且返回全部待审商品。 */ +export async function fetchAdminPendingListings() { + const { data } = await adminClient.get>( + '/admin/listings/pending' + ) + return data.data.items ?? [] +} + +export async function approveAdminListing(id: number | string) { + const { data } = await adminClient.post>(`/admin/listings/${id}/approve`) + return data.data +} + +export async function rejectAdminListing(id: number | string, reason: string) { + const { data } = await adminClient.post>( + `/admin/listings/${id}/reject`, + { reason } + ) + return data.data +} + +export async function offlineAdminListing(id: number | string, reason: string) { + const { data } = await adminClient.post>( + `/admin/listings/${id}/offline`, + { reason } + ) + return data.data +} + +export async function markAbnormalAdminListing(id: number | string, reason: string) { + const { data } = await adminClient.post>( + `/admin/listings/${id}/mark-abnormal`, + { reason } + ) + return data.data +} diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index 2e43250..96ad01f 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -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 | 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 +} diff --git a/frontend/src/features/listings/labels.ts b/frontend/src/features/listings/labels.ts new file mode 100644 index 0000000..9a05ecc --- /dev/null +++ b/frontend/src/features/listings/labels.ts @@ -0,0 +1,82 @@ +import type { StatusMeta } from '@/features/admin/labels' + +export const listingStatusMap: Record = { + 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 = { + 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 = { + 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) +}