{
+ return [
+ {
+ title: '商品',
+ dataIndex: 'title',
+ width: 260,
+ fixed: 'left',
+ render: (_, row) => (
+
+ {canView ? (
+
+ ) : (
+ {row.title || '未命名商品'}
+ )}
+ {row.listing_no}
+
+ {formatText(row.server_region)} · {formatText(row.login_platform)}
+
+
+ ),
+ },
+ {
+ title: '号主',
+ key: 'owner',
+ width: 170,
+ render: (_, row) => (
+
+ {formatText(row.owner_phone)}
+
+ {row.owner_nickname || '未设置昵称'} · ID {row.owner_id}
+
+
+ ),
+ },
+ {
+ title: '价格',
+ key: 'price',
+ width: 140,
+ align: 'right',
+ render: (_, row) => (
+
+ {formatMoney(row.price_cent)}
+ 押金 {formatMoney(row.deposit_amount_cent)}
+ 哈夫币 {row.haf_coin_amount.toLocaleString('zh-CN')}
+
+ ),
+ },
+ {
+ 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',
+ width: 96,
+ fixed: 'right',
+ render: (_, row) =>
+ canView ? (
+ } onClick={() => onOpen(row)}>
+ 详情
+
+ ) : (
+ 无权限
+ ),
+ },
+ ]
+}
+
+export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
+ const isReviewMode = mode === 'review'
+ const [draft, setDraft] = useState(() => ({
+ ...emptyFilters,
+ review_status: isReviewMode ? 'pending' : '',
+ }))
+ const [applied, setApplied] = useState(() => ({
+ ...emptyFilters,
+ review_status: isReviewMode ? 'pending' : '',
+ }))
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ 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 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 = 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 (!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])
+
+ 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 openDetail = (id: number) => {
+ if (!canView) return
+ setDetailId(id)
+ setDetailOpen(true)
+ }
+
+ const columns = useMemo(
+ () => buildColumns(row => openDetail(row.id), canView, mode),
+ [canView, mode]
+ )
+
+ 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 hasActiveFilters = Object.entries(draft).some(
+ ([key, value]) =>
+ !(isReviewMode && key === 'review_status') && value !== '' && value != null
+ )
+ const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
+
+ return (
+
+ )
+}
diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts
index 3acb234..bc2da53 100644
--- a/frontend/src/features/admin/api.ts
+++ b/frontend/src/features/admin/api.ts
@@ -2,6 +2,9 @@ import { adminClient } from '@/shared/api/adminClient'
import type { ApiResponse } from '@/shared/api/types'
import type {
AdminCaptcha,
+ AdminListing,
+ AdminListingPage,
+ AdminListingQuery,
AdminLoginData,
AdminOrderDetail,
AdminOrderPage,
@@ -51,7 +54,7 @@ export async function fetchAdminDashboard() {
return data.data
}
-function compactParams(query: AdminOrderQuery) {
+function compactParams(query: T) {
return Object.fromEntries(
Object.entries(query).filter(([, value]) => value !== '' && value != null)
)
@@ -73,3 +76,57 @@ export async function fetchAdminOrder(id: number | string) {
const { data } = await adminClient.get>(`/admin/orders/${id}`)
return data.data
}
+
+export async function fetchAdminListings(query: AdminListingQuery) {
+ const { data } = await adminClient.get>>('/admin/listings', {
+ params: compactParams(query),
+ })
+ return {
+ items: data.data.items ?? [],
+ total: data.data.total ?? 0,
+ page: data.data.page ?? query.page ?? 1,
+ page_size: data.data.page_size ?? query.page_size ?? 20,
+ }
+}
+
+export async function fetchAdminListing(id: number | string) {
+ const { data } = await adminClient.get>(`/admin/listings/${id}`)
+ return data.data
+}
+
+/** 审核队列使用独立接口:权限为 listing:approve,且返回全部待审商品。 */
+export async function fetchAdminPendingListings() {
+ const { data } = await adminClient.get>(
+ '/admin/listings/pending'
+ )
+ return data.data.items ?? []
+}
+
+export async function approveAdminListing(id: number | string) {
+ const { data } = await adminClient.post>(`/admin/listings/${id}/approve`)
+ return data.data
+}
+
+export async function rejectAdminListing(id: number | string, reason: string) {
+ const { data } = await adminClient.post>(
+ `/admin/listings/${id}/reject`,
+ { reason }
+ )
+ return data.data
+}
+
+export async function offlineAdminListing(id: number | string, reason: string) {
+ const { data } = await adminClient.post>(
+ `/admin/listings/${id}/offline`,
+ { reason }
+ )
+ return data.data
+}
+
+export async function markAbnormalAdminListing(id: number | string, reason: string) {
+ const { data } = await adminClient.post>(
+ `/admin/listings/${id}/mark-abnormal`,
+ { reason }
+ )
+ return data.data
+}
diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts
index 2e43250..96ad01f 100644
--- a/frontend/src/features/admin/types.ts
+++ b/frontend/src/features/admin/types.ts
@@ -208,3 +208,57 @@ export interface AdminOrderDetail {
created_at: string
updated_at: string
}
+
+export interface AdminListingQuery {
+ page?: number
+ page_size?: number
+ keyword?: string
+ status?: string
+ review_status?: string
+ owner_id?: number
+}
+
+/** 后台商品列表与详情共用 ListingDTO,字段完整但截图等资源按商品状态脱敏。 */
+export interface AdminListing {
+ id: number
+ listing_no: string
+ account_id: number
+ owner_id: number
+ owner_phone?: string
+ owner_nickname?: string
+ source_channel?: string
+ is_external_upload: boolean
+ title: string
+ description: string
+ game_name: string
+ server_region: string
+ login_platform: string
+ rank_level: string
+ haf_coin_amount: number
+ asset_summary?: Record | null
+ screenshot_urls: string[]
+ cover_url: string
+ price_cent: number
+ deposit_amount_cent: number
+ is_accelerated_sale: boolean
+ in_transaction: boolean
+ status: string
+ review_status: string
+ handoff_mode: string
+ settlement_mode: string
+ review_reason: string
+ published_at?: string | null
+ distribution_site_id?: number | null
+ distribution_site_code?: string
+ distribution_site_name?: string
+ listing_group_conversation_id?: number
+ created_at: string
+ updated_at: string
+}
+
+export interface AdminListingPage {
+ items: AdminListing[]
+ total: number
+ page: number
+ page_size: number
+}
diff --git a/frontend/src/features/listings/labels.ts b/frontend/src/features/listings/labels.ts
new file mode 100644
index 0000000..9a05ecc
--- /dev/null
+++ b/frontend/src/features/listings/labels.ts
@@ -0,0 +1,82 @@
+import type { StatusMeta } from '@/features/admin/labels'
+
+export const listingStatusMap: Record = {
+ draft: { label: '草稿', tone: 'default' },
+ published: { label: '已上架', tone: 'success' },
+ rented: { label: '租用中', tone: 'cyan' },
+ offline: { label: '已下架', tone: 'default' },
+ completed: { label: '已完成', tone: 'success' },
+ abnormal: { label: '异常', tone: 'red' },
+ sealed: { label: '已封存', tone: 'purple' },
+}
+
+export const listingReviewStatusMap: Record = {
+ none: { label: '未提交', tone: 'default' },
+ pending: { label: '待审核', tone: 'gold' },
+ approved: { label: '已通过', tone: 'success' },
+ rejected: { label: '已拒绝', tone: 'red' },
+}
+
+export const listingStatusOptions = Object.entries(listingStatusMap).map(([value, meta]) => ({
+ value,
+ label: meta.label,
+}))
+
+export const listingReviewStatusOptions = Object.entries(listingReviewStatusMap).map(
+ ([value, meta]) => ({ value, label: meta.label })
+)
+
+export function listingStatusMeta(value?: string | null): StatusMeta {
+ if (!value) return { label: '—', tone: 'default' }
+ return listingStatusMap[value] ?? { label: value, tone: 'default' }
+}
+
+export function listingReviewStatusMeta(value?: string | null): StatusMeta {
+ if (!value) return { label: '—', tone: 'default' }
+ return listingReviewStatusMap[value] ?? { label: value, tone: 'default' }
+}
+
+const assetFieldLabels: Record = {
+ season_insurance: '赛季保险',
+ stamina_level: '体力等级',
+ load_level: '负重等级',
+ awm_bullets: 'AWM 子弹',
+ level6_helmets: '六级头盔',
+ level6_armor: '六级护甲',
+ skin_group: '皮肤系列',
+ skin_names: '皮肤名称',
+ online_time: '在线时间',
+ online_time_text: '在线时间',
+ rank_level: '段位',
+ haf_coin_amount: '哈夫币',
+}
+
+export function assetFieldLabel(key: string) {
+ return assetFieldLabels[key] ?? key
+}
+
+function formatOnlineTime(value: unknown) {
+ if (!value || typeof value !== 'object') return ''
+ const item = value as { start?: unknown; end?: unknown }
+ const start = typeof item.start === 'string' ? item.start : ''
+ const end = typeof item.end === 'string' ? item.end : ''
+ if (!start && !end) return ''
+ return `${start || '—'} 至 ${end || '—'}`
+}
+
+export function formatAssetValue(key: string, value: unknown): string {
+ if (value == null || value === '') return '—'
+ if (key === 'online_time') {
+ const text = formatOnlineTime(value)
+ if (text) return text
+ }
+ if (Array.isArray(value)) {
+ return value.map(item => (typeof item === 'object' ? JSON.stringify(item) : String(item))).join('、')
+ }
+ if (typeof value === 'object') {
+ const text = formatOnlineTime(value)
+ if (text) return text
+ return JSON.stringify(value)
+ }
+ return String(value)
+}