feat(admin): 优化后台宽屏布局与商品管理列表
This commit is contained in:
@@ -1,6 +1,16 @@
|
||||
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 {
|
||||
Alert,
|
||||
Button,
|
||||
Input,
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Typography,
|
||||
type TableColumnsType,
|
||||
} from 'antd'
|
||||
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
|
||||
@@ -10,6 +20,23 @@ 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 {
|
||||
listingAccountLevel,
|
||||
listingAllHeroText,
|
||||
listingAssetText,
|
||||
listingCharacterSkinText,
|
||||
listingCodeText,
|
||||
listingCoinText,
|
||||
listingLevelText,
|
||||
listingOwnerRemark,
|
||||
listingPureCoinPriceText,
|
||||
listingRatioText,
|
||||
listingRegionText,
|
||||
listingRentalDuration,
|
||||
listingRentDepositText,
|
||||
listingResourceText,
|
||||
listingSkinGroupText,
|
||||
} from '@/features/admin/listingDisplay'
|
||||
import {
|
||||
listingReviewStatusMeta,
|
||||
listingReviewStatusOptions,
|
||||
@@ -17,108 +44,287 @@ import {
|
||||
listingStatusOptions,
|
||||
} from '@/features/listings/labels'
|
||||
import type { AdminListing, AdminListingQuery } from '@/features/admin/types'
|
||||
import {
|
||||
emptyListingPublishOptions,
|
||||
fetchListingPublishOptions,
|
||||
} from '@/features/seller/options'
|
||||
import { useAdminSessionStore } from '@/stores/adminSession'
|
||||
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
|
||||
import { formatText } from '@/shared/utils/format'
|
||||
|
||||
type ListingMode = 'all' | 'review'
|
||||
|
||||
const emptyFilters: AdminListingQuery = {
|
||||
keyword: '',
|
||||
owner_id: undefined,
|
||||
status: '',
|
||||
review_status: '',
|
||||
insurance: '',
|
||||
stamina: '',
|
||||
load: '',
|
||||
skin_group: '',
|
||||
skin_name: '',
|
||||
min_coin: undefined,
|
||||
max_coin: undefined,
|
||||
min_deposit: undefined,
|
||||
max_deposit: undefined,
|
||||
}
|
||||
|
||||
const fallbackInsuranceOptions = ['2*1', '2*2', '2*3', '3*3']
|
||||
const fallbackLevelOptions = ['1级', '2级', '3级', '4级', '5级', '6级', '7级']
|
||||
|
||||
function insuranceLabel(value: string) {
|
||||
const parts = value.split('*').map(Number)
|
||||
if (parts.length === 2 && parts.every(part => Number.isFinite(part) && part > 0)) {
|
||||
return `${parts[0] * parts[1]}格`
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
function levelLabel(value: string) {
|
||||
return value.replace(/级$/, '')
|
||||
}
|
||||
|
||||
function EllipsisCell({ value }: { value: string }) {
|
||||
return (
|
||||
<span className="admin-listings-ellipsis" title={value}>
|
||||
{value}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function RangeFilter({
|
||||
min,
|
||||
max,
|
||||
minPlaceholder,
|
||||
maxPlaceholder,
|
||||
step,
|
||||
precision,
|
||||
onMinChange,
|
||||
onMaxChange,
|
||||
}: {
|
||||
min?: number
|
||||
max?: number
|
||||
minPlaceholder: string
|
||||
maxPlaceholder: string
|
||||
step: number
|
||||
precision: number
|
||||
onMinChange: (value?: number) => void
|
||||
onMaxChange: (value?: number) => void
|
||||
}) {
|
||||
return (
|
||||
<div className="admin-range-filter">
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={step}
|
||||
precision={precision}
|
||||
controls={false}
|
||||
placeholder={minPlaceholder}
|
||||
value={min}
|
||||
onChange={value => onMinChange(typeof value === 'number' ? value : undefined)}
|
||||
/>
|
||||
<span className="admin-range-filter__sep">-</span>
|
||||
<InputNumber
|
||||
min={0}
|
||||
step={step}
|
||||
precision={precision}
|
||||
controls={false}
|
||||
placeholder={maxPlaceholder}
|
||||
value={max}
|
||||
onChange={value => onMaxChange(typeof value === 'number' ? value : undefined)}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function buildColumns(
|
||||
onOpen: (row: AdminListing) => void,
|
||||
canView: boolean,
|
||||
mode: ListingMode
|
||||
canView: boolean
|
||||
): TableColumnsType<AdminListing> {
|
||||
return [
|
||||
{
|
||||
title: '商品',
|
||||
dataIndex: 'title',
|
||||
width: 260,
|
||||
title: '商品编码',
|
||||
key: 'listing_no',
|
||||
width: 118,
|
||||
fixed: 'left',
|
||||
render: (_, row) => (
|
||||
<div className="admin-stack">
|
||||
<div className="admin-listing-code">
|
||||
{canView ? (
|
||||
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
|
||||
{row.title || '未命名商品'}
|
||||
{listingCodeText(row)}
|
||||
</button>
|
||||
) : (
|
||||
<strong>{row.title || '未命名商品'}</strong>
|
||||
<strong>{listingCodeText(row)}</strong>
|
||||
)}
|
||||
<span className="admin-mono">{row.listing_no}</span>
|
||||
<span>
|
||||
{formatText(row.server_region)} · {formatText(row.login_platform)}
|
||||
<span className="admin-listing-code__tags">
|
||||
<StatusTag meta={listingStatusMeta(row.status)} />
|
||||
{row.review_status !== 'approved' ? (
|
||||
<StatusTag meta={listingReviewStatusMeta(row.review_status)} />
|
||||
) : null}
|
||||
</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: 'region',
|
||||
width: 92,
|
||||
render: (_, row) => <EllipsisCell value={listingRegionText(row)} />,
|
||||
},
|
||||
{
|
||||
title: '价格',
|
||||
key: 'price',
|
||||
width: 140,
|
||||
title: '上号方式',
|
||||
dataIndex: 'login_platform',
|
||||
width: 82,
|
||||
render: value => <EllipsisCell value={formatText(value)} />,
|
||||
},
|
||||
{
|
||||
title: '哈夫币(M)',
|
||||
key: 'coin',
|
||||
width: 88,
|
||||
align: 'right',
|
||||
render: (_, row) => <strong className="admin-listings-number">{listingCoinText(row)}</strong>,
|
||||
},
|
||||
{
|
||||
title: '保险',
|
||||
key: 'insurance',
|
||||
width: 62,
|
||||
align: 'center',
|
||||
render: (_, row) => listingAssetText(row, 'season_insurance'),
|
||||
},
|
||||
{
|
||||
title: '体力(级)',
|
||||
key: 'stamina',
|
||||
width: 74,
|
||||
align: 'center',
|
||||
render: (_, row) => listingLevelText(row, 'stamina_level'),
|
||||
},
|
||||
{
|
||||
title: '负重(级)',
|
||||
key: 'load',
|
||||
width: 74,
|
||||
align: 'center',
|
||||
render: (_, row) => listingLevelText(row, 'load_level'),
|
||||
},
|
||||
{
|
||||
title: '账号等级',
|
||||
key: 'account_level',
|
||||
width: 78,
|
||||
align: 'center',
|
||||
render: (_, row) => listingAccountLevel(row),
|
||||
},
|
||||
{
|
||||
title: '绝密KD',
|
||||
key: 'secret_kd',
|
||||
width: 72,
|
||||
align: 'center',
|
||||
render: (_, row) => listingAssetText(row, 'secret_kd'),
|
||||
},
|
||||
{
|
||||
title: 'awm子弹',
|
||||
key: 'awm',
|
||||
width: 76,
|
||||
align: 'center',
|
||||
render: (_, row) => listingResourceText(row, 'awmAmmo'),
|
||||
},
|
||||
{
|
||||
title: '六头',
|
||||
key: 'helmet6',
|
||||
width: 54,
|
||||
align: 'center',
|
||||
render: (_, row) => listingResourceText(row, 'helmet6'),
|
||||
},
|
||||
{
|
||||
title: '六甲',
|
||||
key: 'armor6',
|
||||
width: 54,
|
||||
align: 'center',
|
||||
render: (_, row) => listingResourceText(row, 'armor6'),
|
||||
},
|
||||
{
|
||||
title: '特殊刀皮',
|
||||
key: 'melee',
|
||||
width: 126,
|
||||
render: (_, row) => <EllipsisCell value={listingSkinGroupText(row, 'melee')} />,
|
||||
},
|
||||
{
|
||||
title: '人物红皮/人物金皮/武器皮肤',
|
||||
key: 'skins',
|
||||
width: 300,
|
||||
render: (_, row) => <EllipsisCell value={listingCharacterSkinText(row)} />,
|
||||
},
|
||||
{
|
||||
title: '全英雄',
|
||||
key: 'all_hero',
|
||||
width: 72,
|
||||
align: 'center',
|
||||
render: (_, row) => listingAllHeroText(row),
|
||||
},
|
||||
{
|
||||
title: '号主备注',
|
||||
key: 'description',
|
||||
width: 190,
|
||||
render: (_, row) => <EllipsisCell value={listingOwnerRemark(row)} />,
|
||||
},
|
||||
{
|
||||
title: '租客纯币价格',
|
||||
key: 'pure_coin_price',
|
||||
width: 118,
|
||||
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>
|
||||
<span className="admin-money">{listingPureCoinPriceText(row)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
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',
|
||||
title: '租期',
|
||||
key: 'rental_duration',
|
||||
width: 96,
|
||||
align: 'center',
|
||||
render: (_, row) => {
|
||||
const duration = listingRentalDuration(row)
|
||||
return (
|
||||
<div className="admin-listing-duration">
|
||||
<strong>{duration.days}</strong>
|
||||
{duration.dailyLoss ? <small>{duration.dailyLoss}</small> : null}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '租金/押金',
|
||||
key: 'rent_deposit',
|
||||
width: 142,
|
||||
align: 'right',
|
||||
render: (_, row) => (
|
||||
<span className="admin-listing-money-pair">{listingRentDepositText(row)}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '比例',
|
||||
key: 'ratio',
|
||||
width: 70,
|
||||
align: 'center',
|
||||
render: (_, row) => (
|
||||
<span className="admin-listings-ratio">
|
||||
{listingRatioText(row)}
|
||||
</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '详情',
|
||||
key: 'actions',
|
||||
width: 74,
|
||||
fixed: 'right',
|
||||
align: 'center',
|
||||
render: (_, row) =>
|
||||
canView ? (
|
||||
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onOpen(row)}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={event => {
|
||||
event.stopPropagation()
|
||||
onOpen(row)
|
||||
}}
|
||||
>
|
||||
详情
|
||||
</Button>
|
||||
) : (
|
||||
@@ -146,12 +352,32 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
|
||||
review_status: isReviewMode ? 'pending' : '',
|
||||
}))
|
||||
const [page, setPage] = useState(1)
|
||||
const [pageSize, setPageSize] = useState(20)
|
||||
const [pageSize, setPageSize] = useState(10)
|
||||
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 optionsQuery = useQuery({
|
||||
queryKey: ['listing-publish-options'],
|
||||
queryFn: fetchListingPublishOptions,
|
||||
staleTime: 5 * 60_000,
|
||||
})
|
||||
const publishOptions = optionsQuery.data ?? emptyListingPublishOptions
|
||||
const insuranceOptions = publishOptions.insurance_options.length
|
||||
? publishOptions.insurance_options
|
||||
: fallbackInsuranceOptions
|
||||
const levelOptions = publishOptions.level_options.length
|
||||
? publishOptions.level_options
|
||||
: fallbackLevelOptions
|
||||
const skinOptions = publishOptions.skin_groups.map(group => ({
|
||||
label: group.title,
|
||||
options: group.options.map(name => ({ value: name, label: name })),
|
||||
}))
|
||||
const selectedSkinNames = draft.skin_name
|
||||
? draft.skin_name.split(',').map(name => name.trim()).filter(Boolean)
|
||||
: []
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: ['admin-listings', applied, page, pageSize],
|
||||
queryFn: () => fetchAdminListings({ ...applied, page, page_size: pageSize }),
|
||||
@@ -171,12 +397,19 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
|
||||
const keyword = (applied.keyword ?? '').toLowerCase()
|
||||
return rawItems.filter(item => {
|
||||
if (applied.status && item.status !== applied.status) return false
|
||||
if (applied.owner_id && item.owner_id !== applied.owner_id) 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])
|
||||
}, [
|
||||
isReviewMode,
|
||||
rawItems,
|
||||
applied.keyword,
|
||||
applied.owner_id,
|
||||
applied.status,
|
||||
])
|
||||
|
||||
const total = isReviewMode ? reviewFiltered.length : listQuery.data?.total ?? 0
|
||||
const items = isReviewMode
|
||||
@@ -188,6 +421,10 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
|
||||
const error = isReviewMode ? pendingQuery.error : listQuery.error
|
||||
const refetch = () => (isReviewMode ? pendingQuery.refetch() : listQuery.refetch())
|
||||
|
||||
const publishedCount = items.filter(item => item.status === 'published').length
|
||||
const rentedCount = items.filter(item => item.status === 'rented').length
|
||||
const pendingReviewCount = items.filter(item => item.review_status === 'pending').length
|
||||
|
||||
const openDetail = (id: number) => {
|
||||
if (!canView) return
|
||||
setDetailId(id)
|
||||
@@ -195,7 +432,7 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
|
||||
}
|
||||
|
||||
const columns = useMemo(
|
||||
() => buildColumns(row => openDetail(row.id), canView, mode),
|
||||
() => buildColumns(row => openDetail(row.id), canView),
|
||||
[canView, mode]
|
||||
)
|
||||
|
||||
@@ -219,21 +456,43 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const setStatusFilter = (status: string) => {
|
||||
const nextStatus = applied.status === status ? '' : status
|
||||
const next = { ...applied, status: nextStatus, review_status: '' }
|
||||
setDraft(next)
|
||||
setApplied(next)
|
||||
setPage(1)
|
||||
}
|
||||
|
||||
const setReviewStatusFilter = (reviewStatus: string) => {
|
||||
const nextReviewStatus = isReviewMode
|
||||
? 'pending'
|
||||
: applied.review_status === reviewStatus
|
||||
? ''
|
||||
: reviewStatus
|
||||
const next = { ...applied, status: '', review_status: nextReviewStatus }
|
||||
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 : '请稍后重试或重新登录后台。'
|
||||
const allActive = !applied.status && !applied.review_status
|
||||
const reviewModeActive = applied.review_status === 'pending' && !applied.status
|
||||
|
||||
return (
|
||||
<div className="admin-page">
|
||||
<div className="admin-page admin-listings-page">
|
||||
<AdminPageHeader
|
||||
eyebrow={isReviewMode ? 'JIEYOU OPS · 审核' : 'JIEYOU OPS · 商品'}
|
||||
title={isReviewMode ? '商品审核' : '商品管理'}
|
||||
description={
|
||||
isReviewMode
|
||||
? '按提交顺序处理待审商品,核对账号、价格与截图后再上架。'
|
||||
: '查看全量商品、号主、价格、状态与审核意见;点击标题查看详情。'
|
||||
: '查看商品编码、地区、上号方式、账号资产、号主备注、租金押金、比例和预计租期。'
|
||||
}
|
||||
meta={
|
||||
<Space size={8} wrap>
|
||||
@@ -249,20 +508,95 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
|
||||
}
|
||||
/>
|
||||
|
||||
<section className="admin-listing-overview">
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-listing-stat${allActive ? ' is-active' : ''}`}
|
||||
onClick={resetFilters}
|
||||
>
|
||||
<span>全部商品</span>
|
||||
<strong>{total}</strong>
|
||||
<small>当前筛选结果</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-listing-stat is-published${applied.status === 'published' ? ' is-active' : ''}`}
|
||||
onClick={() => setStatusFilter('published')}
|
||||
>
|
||||
<span>已上架</span>
|
||||
<strong>{publishedCount}</strong>
|
||||
<small>当前页</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-listing-stat is-rented${applied.status === 'rented' ? ' is-active' : ''}`}
|
||||
onClick={() => setStatusFilter('rented')}
|
||||
>
|
||||
<span>已锁定</span>
|
||||
<strong>{rentedCount}</strong>
|
||||
<small>当前页</small>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-listing-stat is-review${reviewModeActive ? ' is-active' : ''}`}
|
||||
onClick={() => setReviewStatusFilter('pending')}
|
||||
>
|
||||
<span>待审核</span>
|
||||
<strong>{pendingReviewCount}</strong>
|
||||
<small>当前页</small>
|
||||
</button>
|
||||
</section>
|
||||
|
||||
<FilterBar
|
||||
onSearch={applyFilters}
|
||||
onReset={hasActiveFilters ? resetFilters : undefined}
|
||||
loading={isFetching}
|
||||
>
|
||||
<FilterField label="关键词" width={300}>
|
||||
<FilterField label="编号" width={240}>
|
||||
<Input
|
||||
allowClear
|
||||
value={draft.keyword}
|
||||
placeholder="商品编号 / 标题 / 号主手机号"
|
||||
placeholder="商品编号 / 订单编号"
|
||||
onChange={event => patchFilter({ keyword: event.target.value })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="商品状态" width={150}>
|
||||
<FilterField label="号主 ID" width={130}>
|
||||
<Input
|
||||
allowClear
|
||||
inputMode="numeric"
|
||||
value={draft.owner_id ?? ''}
|
||||
placeholder="筛选号主"
|
||||
onChange={event => {
|
||||
const value = event.target.value.replace(/\D/g, '')
|
||||
patchFilter({ owner_id: value ? Number(value) : undefined })
|
||||
}}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="纯币(M)" width={260}>
|
||||
<RangeFilter
|
||||
min={draft.min_coin}
|
||||
max={draft.max_coin}
|
||||
minPlaceholder="最低哈夫币"
|
||||
maxPlaceholder="最高哈夫币"
|
||||
step={10}
|
||||
precision={1}
|
||||
onMinChange={value => patchFilter({ min_coin: value })}
|
||||
onMaxChange={value => patchFilter({ max_coin: value })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="押金(元)" width={260}>
|
||||
<RangeFilter
|
||||
min={draft.min_deposit}
|
||||
max={draft.max_deposit}
|
||||
minPlaceholder="最低押金"
|
||||
maxPlaceholder="最高押金"
|
||||
step={100}
|
||||
precision={1}
|
||||
onMinChange={value => patchFilter({ min_deposit: value })}
|
||||
onMaxChange={value => patchFilter({ max_deposit: value })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="商品状态" width={142}>
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.status || undefined}
|
||||
@@ -271,17 +605,60 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
|
||||
onChange={value => patchFilter({ status: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
{isReviewMode ? null : (
|
||||
<FilterField label="审核状态" width={150}>
|
||||
<FilterField label="审核状态" width={142}>
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.review_status || undefined}
|
||||
placeholder="全部审核"
|
||||
options={listingReviewStatusOptions}
|
||||
onChange={value => patchFilter({ review_status: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
<FilterField label="资产" width={360}>
|
||||
<div className="admin-triple-filter">
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.review_status || undefined}
|
||||
placeholder="全部审核"
|
||||
options={listingReviewStatusOptions}
|
||||
onChange={value => patchFilter({ review_status: value ?? '' })}
|
||||
value={draft.insurance || undefined}
|
||||
placeholder="保险"
|
||||
options={insuranceOptions.map(value => ({
|
||||
value,
|
||||
label: insuranceLabel(value),
|
||||
}))}
|
||||
onChange={value => patchFilter({ insurance: value ?? '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
)}
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.stamina || undefined}
|
||||
placeholder="体力"
|
||||
options={levelOptions.map(value => ({
|
||||
value,
|
||||
label: levelLabel(value),
|
||||
}))}
|
||||
onChange={value => patchFilter({ stamina: value ?? '' })}
|
||||
/>
|
||||
<Select
|
||||
allowClear
|
||||
value={draft.load || undefined}
|
||||
placeholder="负重"
|
||||
options={levelOptions.map(value => ({
|
||||
value,
|
||||
label: levelLabel(value),
|
||||
}))}
|
||||
onChange={value => patchFilter({ load: value ?? '' })}
|
||||
/>
|
||||
</div>
|
||||
</FilterField>
|
||||
<FilterField label="皮肤" width={230}>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
maxTagCount="responsive"
|
||||
value={selectedSkinNames}
|
||||
placeholder="全部皮肤"
|
||||
options={skinOptions}
|
||||
onChange={values => patchFilter({ skin_name: values.join(','), skin_group: '' })}
|
||||
/>
|
||||
</FilterField>
|
||||
</FilterBar>
|
||||
|
||||
{isError ? (
|
||||
@@ -306,6 +683,7 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
|
||||
{isPending ? '加载中' : `第 ${page} 页 · 当前 ${items.length} 条`}
|
||||
</Typography.Text>
|
||||
}
|
||||
className="admin-listings-table"
|
||||
rowKey="id"
|
||||
columns={columns}
|
||||
dataSource={items}
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import type { AdminListing } from '@/features/admin/types'
|
||||
import { formatCoinM, formatMoney } from '@/shared/utils/format'
|
||||
|
||||
type JsonRecord = Record<string, unknown>
|
||||
|
||||
function recordOf(value: unknown): JsonRecord {
|
||||
return typeof value === 'object' && value !== null ? (value as JsonRecord) : {}
|
||||
}
|
||||
|
||||
function textOf(value: unknown) {
|
||||
return value == null || value === '' ? '' : String(value)
|
||||
}
|
||||
|
||||
function numberOf(value: unknown) {
|
||||
const parsed = Number(value)
|
||||
return Number.isFinite(parsed) ? parsed : 0
|
||||
}
|
||||
|
||||
function compactNumber(value: number) {
|
||||
const rounded = Math.round(value * 10) / 10
|
||||
return Number.isInteger(rounded) ? String(rounded) : rounded.toFixed(1)
|
||||
}
|
||||
|
||||
export function listingCodeText(row: AdminListing) {
|
||||
return row.listing_no || `SP${String(row.id).padStart(6, '0')}`
|
||||
}
|
||||
|
||||
export function listingCoinText(row: AdminListing) {
|
||||
return formatCoinM(row.haf_coin_amount)
|
||||
}
|
||||
|
||||
export function listingRegionText(row: AdminListing) {
|
||||
const regions = row.asset_summary?.common_regions
|
||||
if (Array.isArray(regions)) {
|
||||
const names = regions.filter(
|
||||
(region): region is string => typeof region === 'string' && Boolean(region.trim())
|
||||
)
|
||||
if (names.length > 0) return names.join('、')
|
||||
}
|
||||
return row.server_region || '—'
|
||||
}
|
||||
|
||||
export function listingAssetText(row: AdminListing, key: string) {
|
||||
const value = row.asset_summary?.[key]
|
||||
if (value == null || value === '') return '—'
|
||||
if (Array.isArray(value)) {
|
||||
const names = value.filter(
|
||||
(item): item is string => typeof item === 'string' && Boolean(item.trim())
|
||||
)
|
||||
return names.length > 0 ? names.join('、') : '—'
|
||||
}
|
||||
if (typeof value === 'object') return '—'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
export function listingLevelText(row: AdminListing, key: string) {
|
||||
const value = listingAssetText(row, key)
|
||||
return value === '—' ? value : value.replace(/级$/, '')
|
||||
}
|
||||
|
||||
export function listingQuantity(row: AdminListing, key: string) {
|
||||
const resources = row.asset_summary?.resources
|
||||
if (!Array.isArray(resources)) return 0
|
||||
const match = resources.find(item => {
|
||||
const resource = recordOf(item)
|
||||
return textOf(resource.key) === key
|
||||
})
|
||||
return numberOf(recordOf(match).quantity)
|
||||
}
|
||||
|
||||
export function listingResourceText(row: AdminListing, key: string) {
|
||||
const quantity = listingQuantity(row, key)
|
||||
return quantity > 0 ? compactNumber(quantity) : '—'
|
||||
}
|
||||
|
||||
export function listingSkinGroupText(row: AdminListing, groupKey: string) {
|
||||
const groups = recordOf(row.asset_summary?.skin_groups)
|
||||
const names = groups[groupKey]
|
||||
if (!Array.isArray(names)) return '—'
|
||||
const values = names.filter(
|
||||
(name): name is string => typeof name === 'string' && Boolean(name.trim())
|
||||
)
|
||||
return values.length > 0 ? values.join('、') : '—'
|
||||
}
|
||||
|
||||
export function listingCharacterSkinText(row: AdminListing) {
|
||||
const groups = [
|
||||
{ label: '红皮', key: 'operatorRed' },
|
||||
{ label: '金皮', key: 'operatorGold' },
|
||||
{ label: '武器', key: 'weapon' },
|
||||
]
|
||||
const parts = groups
|
||||
.map(group => {
|
||||
const names = listingSkinGroupText(row, group.key)
|
||||
return names === '—' ? '' : `${group.label}:${names}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
return parts.length > 0 ? parts.join(' / ') : '—'
|
||||
}
|
||||
|
||||
export function listingAllHeroText(row: AdminListing) {
|
||||
const value =
|
||||
listingAssetText(row, 'all_hero') !== '—'
|
||||
? listingAssetText(row, 'all_hero')
|
||||
: listingAssetText(row, 'all_heroes')
|
||||
return value === '是' || value === '否' ? value : '—'
|
||||
}
|
||||
|
||||
export function listingOwnerRemark(row: AdminListing) {
|
||||
return row.description?.trim() || '—'
|
||||
}
|
||||
|
||||
export function listingPureCoinPrice(row: AdminListing) {
|
||||
const summary = row.asset_summary ?? {}
|
||||
const breakdown = recordOf(summary.price_breakdown)
|
||||
const buyerCent = numberOf(breakdown.buyer_coin_base_price_cent)
|
||||
if (buyerCent > 0) return Math.round(buyerCent)
|
||||
|
||||
const buyerYuan = numberOf(breakdown.buyer_coin_base_price)
|
||||
if (buyerYuan > 0) return Math.round(buyerYuan * 100)
|
||||
|
||||
const consumableCent =
|
||||
numberOf(breakdown.consumable_price_cent) || numberOf(breakdown.consumable_price) * 100
|
||||
return Math.max(0, Math.round(row.price_cent - consumableCent))
|
||||
}
|
||||
|
||||
export function listingPureCoinPriceText(row: AdminListing) {
|
||||
const cents = listingPureCoinPrice(row)
|
||||
return cents > 0 ? formatMoney(cents) : '—'
|
||||
}
|
||||
|
||||
export function listingRentalDuration(row: AdminListing) {
|
||||
const coinM = numberOf(row.haf_coin_amount) / 1_000_000
|
||||
const dailyLoss = numberOf(row.asset_summary?.daily_loss_m)
|
||||
if (coinM <= 0 || dailyLoss <= 0) {
|
||||
return { days: '—', dailyLoss: '' }
|
||||
}
|
||||
return {
|
||||
days: `${Math.max(1, Math.ceil(coinM / dailyLoss))}天`,
|
||||
dailyLoss: `日耗 ${compactNumber(dailyLoss)}M`,
|
||||
}
|
||||
}
|
||||
|
||||
export function listingRentDepositText(row: AdminListing) {
|
||||
return `${formatMoney(row.price_cent)} / ${formatMoney(row.deposit_amount_cent)}`
|
||||
}
|
||||
|
||||
export function listingRatioText(row: AdminListing) {
|
||||
const ratio = numberOf(row.asset_summary?.publish_ratio)
|
||||
if (ratio <= 0) return '—'
|
||||
return `1:${compactNumber(ratio)}`
|
||||
}
|
||||
|
||||
export function listingAccountLevel(row: AdminListing) {
|
||||
const fireLevel = listingAssetText(row, 'fire_level')
|
||||
if (fireLevel !== '—') return fireLevel
|
||||
return row.rank_level || '—'
|
||||
}
|
||||
@@ -265,6 +265,15 @@ export interface AdminListingQuery {
|
||||
status?: string
|
||||
review_status?: string
|
||||
owner_id?: number
|
||||
insurance?: string
|
||||
stamina?: string
|
||||
load?: string
|
||||
skin_group?: string
|
||||
skin_name?: string
|
||||
min_coin?: number
|
||||
max_coin?: number
|
||||
min_deposit?: number
|
||||
max_deposit?: number
|
||||
}
|
||||
|
||||
/** 后台商品列表与详情共用 ListingDTO,字段完整但截图等资源按商品状态脱敏。 */
|
||||
|
||||
@@ -221,12 +221,14 @@
|
||||
}
|
||||
|
||||
.admin-content {
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
padding: 20px 22px 32px;
|
||||
}
|
||||
|
||||
.admin-page {
|
||||
max-width: 1600px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.admin-page__head {
|
||||
@@ -234,7 +236,7 @@
|
||||
align-items: flex-end;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
margin-bottom: 24px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.admin-page__head-main {
|
||||
@@ -1177,7 +1179,7 @@
|
||||
|
||||
/* 工作台 */
|
||||
.dash-page {
|
||||
max-width: 1320px;
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.dash-head {
|
||||
@@ -1879,3 +1881,285 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* 商品管理 */
|
||||
.admin-listings-page {
|
||||
max-width: none;
|
||||
}
|
||||
|
||||
.admin-listing-overview {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.admin-listing-stat {
|
||||
position: relative;
|
||||
display: flex;
|
||||
min-height: 104px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
overflow: hidden;
|
||||
padding: 15px 17px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 10px;
|
||||
background: var(--paper-warm);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
transform 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.admin-listing-stat::before {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
width: 3px;
|
||||
background: var(--brand-ink);
|
||||
content: '';
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.admin-listing-stat:hover {
|
||||
border-color: var(--brand-ink);
|
||||
box-shadow: 0 8px 20px rgba(30, 77, 66, 0.07);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.admin-listing-stat > span {
|
||||
color: var(--ink-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-listing-stat > strong {
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 30px;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.admin-listing-stat > small {
|
||||
color: var(--ink-muted);
|
||||
font-size: 10px;
|
||||
}
|
||||
|
||||
.admin-listing-stat.is-published::before {
|
||||
background: #0f7b6c;
|
||||
}
|
||||
|
||||
.admin-listing-stat.is-rented::before {
|
||||
background: #b45309;
|
||||
}
|
||||
|
||||
.admin-listing-stat.is-review::before {
|
||||
background: #b91c1c;
|
||||
}
|
||||
|
||||
.admin-listing-stat.is-active {
|
||||
border-color: var(--brand-ink);
|
||||
background: var(--brand-ink);
|
||||
box-shadow: 0 10px 24px rgba(30, 77, 66, 0.16);
|
||||
}
|
||||
|
||||
.admin-listing-stat.is-active::before {
|
||||
width: 0;
|
||||
}
|
||||
|
||||
.admin-listing-stat.is-active > span,
|
||||
.admin-listing-stat.is-active > strong,
|
||||
.admin-listing-stat.is-active > small {
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.admin-listing-stat.is-active > span,
|
||||
.admin-listing-stat.is-active > small {
|
||||
opacity: 0.78;
|
||||
}
|
||||
|
||||
.admin-listings-page .admin-filter-bar__fields {
|
||||
align-items: flex-end;
|
||||
}
|
||||
|
||||
.admin-range-filter {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr);
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-range-filter .ant-input-number {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-range-filter__sep {
|
||||
color: var(--ink-muted);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-triple-filter {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 6px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-triple-filter .ant-select {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.admin-listings-table .ant-card-body {
|
||||
padding-top: 10px;
|
||||
}
|
||||
|
||||
.admin-listings-table .ant-table {
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-listings-table .ant-table-thead > tr > th {
|
||||
padding: 8px 6px !important;
|
||||
background: #f7f3ea !important;
|
||||
color: var(--ink-secondary);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-listings-table .ant-table-tbody > tr > td {
|
||||
padding: 8px 6px !important;
|
||||
vertical-align: top;
|
||||
}
|
||||
|
||||
.admin-listings-table .ant-table-tbody > tr:nth-child(even) > td {
|
||||
background: #fcfbf7;
|
||||
}
|
||||
|
||||
.admin-listings-table .ant-table-tbody > tr:hover > td,
|
||||
.admin-listings-table .ant-table-tbody > tr.ant-table-row.admin-table__row--clickable:hover > td {
|
||||
background: #f1f6f2 !important;
|
||||
}
|
||||
|
||||
.admin-listings-table .ant-table-cell-fix-left,
|
||||
.admin-listings-table .ant-table-cell-fix-right {
|
||||
background: var(--paper-warm);
|
||||
}
|
||||
|
||||
.admin-listings-table .ant-table-thead .ant-table-cell-fix-left,
|
||||
.admin-listings-table .ant-table-thead .ant-table-cell-fix-right {
|
||||
background: #f7f3ea !important;
|
||||
}
|
||||
|
||||
.admin-listing-code {
|
||||
display: flex;
|
||||
min-width: 96px;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.admin-listing-code .admin-order-link {
|
||||
max-width: 108px;
|
||||
overflow: hidden;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-listing-code__tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.admin-listing-code__tags .admin-status-tag {
|
||||
min-height: 17px;
|
||||
margin: 0;
|
||||
padding-inline: 5px;
|
||||
font-size: 9px;
|
||||
line-height: 15px;
|
||||
}
|
||||
|
||||
.admin-listings-ellipsis {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
color: var(--ink-secondary);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-listings-number {
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', 'DIN Alternate', sans-serif;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.admin-listing-duration {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.admin-listing-duration strong {
|
||||
color: var(--brand-ink);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.admin-listing-duration small {
|
||||
color: var(--ink-muted);
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-listing-money-pair {
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-listings-ratio {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 1px 5px;
|
||||
border-radius: 999px;
|
||||
background: #eef2ec;
|
||||
color: var(--brand-ink);
|
||||
font-family: 'Inter', monospace;
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.admin-listings-time {
|
||||
color: var(--ink-muted);
|
||||
font-size: 9px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 1100px) {
|
||||
.admin-listing-overview {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.admin-listing-overview {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.admin-triple-filter {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user