feat(admin): 优化后台宽屏布局与商品管理列表

This commit is contained in:
yml2213
2026-11-01 18:38:34 +08:00
parent 067c6116c5
commit 7f6ebddd5e
4 changed files with 915 additions and 86 deletions
+459 -81
View File
@@ -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}