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,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>
)
}
+58 -1
View File
@@ -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
}
+54
View File
@@ -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
}
+82
View File
@@ -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)
}