import { useMemo, useState } from 'react'
import { keepPreviousData, useQuery } from '@tanstack/react-query'
import {
Alert,
Button,
Input,
InputNumber,
Select,
Space,
Tag,
Typography,
type TableColumnsType,
} from 'antd'
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { fetchAdminListings, fetchAdminPendingListings } from '@/features/admin/api'
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,
listingStatusMeta,
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 { getAdminPath } from '@/shared/api/adminClient'
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 (
{value}
)
}
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 (
onMinChange(typeof value === 'number' ? value : undefined)}
/>
-
onMaxChange(typeof value === 'number' ? value : undefined)}
/>
)
}
function buildColumns(
onOpen: (row: AdminListing) => void,
canView: boolean
): TableColumnsType {
return [
{
title: '商品编码',
key: 'listing_no',
width: 118,
fixed: 'left',
render: (_, row) => (
{canView ? (
) : (
{listingCodeText(row)}
)}
{row.review_status !== 'approved' ? (
) : null}
),
},
{
title: '地区',
key: 'region',
width: 92,
render: (_, row) => ,
},
{
title: '上号方式',
dataIndex: 'login_platform',
width: 82,
render: value => ,
},
{
title: '哈夫币(M)',
key: 'coin',
width: 88,
align: 'right',
render: (_, row) => {listingCoinText(row)},
},
{
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) => ,
},
{
title: '人物红皮/人物金皮/武器皮肤',
key: 'skins',
width: 300,
render: (_, row) => ,
},
{
title: '全英雄',
key: 'all_hero',
width: 72,
align: 'center',
render: (_, row) => listingAllHeroText(row),
},
{
title: '号主备注',
key: 'description',
width: 190,
render: (_, row) => ,
},
{
title: '租客纯币价格',
key: 'pure_coin_price',
width: 118,
align: 'right',
render: (_, row) => {listingPureCoinPriceText(row)},
},
{
title: '租期',
key: 'rental_duration',
width: 96,
align: 'center',
render: (_, row) => {
const duration = listingRentalDuration(row)
return (
{duration.days}
{duration.dailyLoss ? {duration.dailyLoss} : null}
)
},
},
{
title: '租金/押金',
key: 'rent_deposit',
width: 142,
align: 'right',
render: (_, row) => (
{listingRentDepositText(row)}
),
},
{
title: '比例',
key: 'ratio',
width: 70,
align: 'center',
render: (_, row) => {listingRatioText(row)},
},
{
title: '详情',
key: 'actions',
width: 74,
fixed: 'right',
align: 'center',
render: (_, row) =>
canView ? (
}
onClick={event => {
event.stopPropagation()
onOpen(row)
}}
>
详情
) : (
无权限
),
},
]
}
export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
const isReviewMode = mode === 'review'
const [searchParams] = useSearchParams()
const initialOwnerId = Number(searchParams.get('owner_id')) || undefined
const initialKeyword = searchParams.get('keyword')?.trim() ?? ''
const [draft, setDraft] = useState(() => ({
...emptyFilters,
keyword: initialKeyword,
owner_id: initialOwnerId,
review_status: isReviewMode ? 'pending' : '',
}))
const [applied, setApplied] = useState(() => ({
...emptyFilters,
keyword: initialKeyword,
owner_id: initialOwnerId,
review_status: isReviewMode ? 'pending' : '',
}))
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const navigate = useNavigate()
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 }),
enabled: !isReviewMode,
placeholderData: keepPreviousData,
})
const pendingQuery = useQuery({
queryKey: ['admin-pending-listings'],
queryFn: fetchAdminPendingListings,
enabled: isReviewMode,
})
const rawItems = useMemo(
() => (isReviewMode ? (pendingQuery.data ?? []) : (listQuery.data?.items ?? [])),
[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 (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.owner_id, 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 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
navigate(getAdminPath(isReviewMode ? `/listings/review/${id}` : `/listings/${id}`))
}
const columns = buildColumns(row => openDetail(row.id), canView)
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 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 (
共 {total} 件
{isReviewMode ? 待审核队列 : null}
{isFetching && !isPending ? 正在更新 : null}
}
extra={
} onClick={() => void refetch()} loading={isFetching}>
刷新数据
}
/>
patchFilter({ keyword: event.target.value })}
/>
{
const value = event.target.value.replace(/\D/g, '')
patchFilter({ owner_id: value ? Number(value) : undefined })
}}
/>
patchFilter({ min_coin: value })}
onMaxChange={value => patchFilter({ max_coin: value })}
/>
patchFilter({ min_deposit: value })}
onMaxChange={value => patchFilter({ max_deposit: value })}
/>
patchFilter({ skin_name: values.join(','), skin_group: '' })}
/>
{isError ? (
void refetch()}>
重试
}
/>
) : null}
title={{isReviewMode ? '待审核商品' : '商品列表'}}
extra={
{isPending ? '加载中' : `第 ${page} 页 · 当前 ${items.length} 条`}
}
className="admin-listings-table"
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 ? '没有待审核商品' : '没有匹配的商品'}
/>
)
}