diff --git a/frontend/src/features/admin/AdminListingsPage.tsx b/frontend/src/features/admin/AdminListingsPage.tsx index d2282ac..9c00321 100644 --- a/frontend/src/features/admin/AdminListingsPage.tsx +++ b/frontend/src/features/admin/AdminListingsPage.tsx @@ -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 ( + + {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, - mode: ListingMode + canView: boolean ): TableColumnsType { return [ { - title: '商品', - dataIndex: 'title', - width: 260, + title: '商品编码', + key: 'listing_no', + width: 118, fixed: 'left', render: (_, row) => ( -
+
{canView ? ( ) : ( - {row.title || '未命名商品'} + {listingCodeText(row)} )} - {row.listing_no} - - {formatText(row.server_region)} · {formatText(row.login_platform)} + + + {row.review_status !== 'approved' ? ( + + ) : null}
), }, { - title: '号主', - key: 'owner', - width: 170, - render: (_, row) => ( -
- {formatText(row.owner_phone)} - - {row.owner_nickname || '未设置昵称'} · ID {row.owner_id} - -
- ), + title: '地区', + key: 'region', + width: 92, + render: (_, row) => , }, { - title: '价格', - key: 'price', - width: 140, + 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) => ( -
- {formatMoney(row.price_cent)} - 押金 {formatMoney(row.deposit_amount_cent)} - 哈夫币 {row.haf_coin_amount.toLocaleString('zh-CN')} -
+ {listingPureCoinPriceText(row)} ), }, { - title: '商品状态', - dataIndex: 'status', - width: 120, - render: value => , - }, - { - title: '审核状态', - dataIndex: 'review_status', - width: 120, - render: value => , - }, - { - title: '审核意见', - dataIndex: 'review_reason', - width: 200, - ellipsis: true, - render: value => - value ? ( - {value} - ) : ( - — - ), - }, - { - 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 ( +
+ {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 ? ( - ) : ( @@ -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(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 ( -
+
@@ -249,20 +508,95 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) { } /> +
+ + + + +
+ - + 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 ? ( @@ -306,6 +683,7 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) { {isPending ? '加载中' : `第 ${page} 页 · 当前 ${items.length} 条`} } + className="admin-listings-table" rowKey="id" columns={columns} dataSource={items} diff --git a/frontend/src/features/admin/listingDisplay.ts b/frontend/src/features/admin/listingDisplay.ts new file mode 100644 index 0000000..46397ad --- /dev/null +++ b/frontend/src/features/admin/listingDisplay.ts @@ -0,0 +1,158 @@ +import type { AdminListing } from '@/features/admin/types' +import { formatCoinM, formatMoney } from '@/shared/utils/format' + +type JsonRecord = Record + +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 || '—' +} diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index ac9b885..a77d686 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -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,字段完整但截图等资源按商品状态脱敏。 */ diff --git a/frontend/src/styles/admin-shell.css b/frontend/src/styles/admin-shell.css index a70d4d5..6ff9dd7 100644 --- a/frontend/src/styles/admin-shell.css +++ b/frontend/src/styles/admin-shell.css @@ -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; + } +} +