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}
@@ -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 || '—'
}
+9
View File
@@ -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,字段完整但截图等资源按商品状态脱敏。 */
+289 -5
View File
@@ -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;
}
}