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 ? ( ) : ( 无权限 ), }, ] } 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={ } />
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({ review_status: value ?? '' })} />
({ value, label: levelLabel(value), }))} onChange={value => patchFilter({ stamina: 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 ? '没有待审核商品' : '没有匹配的商品'} />
) }