feat(admin): 订单与商品详情页补齐明细与操作

This commit is contained in:
2026-11-12 17:48:52 +08:00
parent 927e6a9978
commit 766369075b
18 changed files with 3793 additions and 37 deletions
+18
View File
@@ -250,11 +250,26 @@ const AdminOrdersPage = lazy(() =>
default: module.AdminOrdersPage,
}))
)
const AdminOrderDetailPage = lazy(() =>
import('@/features/admin/AdminOrderDetailPage').then(module => ({
default: module.AdminOrderDetailPage,
}))
)
const AdminListingsPage = lazy(() =>
import('@/features/admin/AdminListingsPage').then(module => ({
default: module.AdminListingsPage,
}))
)
const AdminListingDetailPage = lazy(() =>
import('@/features/admin/AdminListingDetailPage').then(module => ({
default: module.AdminListingDetailPage,
}))
)
const AdminListingReviewDetailPage = lazy(() =>
import('@/features/admin/AdminListingReviewDetailPage').then(module => ({
default: module.AdminListingReviewDetailPage,
}))
)
const AdminUsersPage = lazy(() =>
import('@/features/admin/AdminUsersPage').then(module => ({
default: module.AdminUsersPage,
@@ -580,9 +595,12 @@ export function AppRouter() {
<Route element={<AdminLayout />}>
<Route index element={<AdminDashboardPage />} />
<Route path="orders" element={<AdminOrdersPage />} />
<Route path="orders/:id" element={<AdminOrderDetailPage />} />
<Route path="orders/refund-review" element={<AdminRefundReviewPage />} />
<Route path="listings" element={<AdminListingsPage />} />
<Route path="listings/review" element={<AdminListingsPage mode="review" />} />
<Route path="listings/review/:id" element={<AdminListingReviewDetailPage />} />
<Route path="listings/:id" element={<AdminListingDetailPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="withdrawals" element={<AdminWithdrawalsPage />} />
<Route path="disputes" element={<AdminDisputesPage />} />
@@ -0,0 +1,685 @@
import { useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Descriptions,
Form,
Image,
Input,
InputNumber,
Modal,
Radio,
Space,
Tag,
Typography,
} from 'antd'
import {
ArrowLeftOutlined,
CopyOutlined,
EditOutlined,
ReloadOutlined,
StopOutlined,
SwapOutlined,
WarningOutlined,
} from '@ant-design/icons'
import { useNavigate, useParams } from 'react-router-dom'
import {
adjustExternalAdminListing,
fetchAdminListing,
fetchAdminUsers,
markAbnormalAdminListing,
offlineAdminListing,
transferAdminListingOwner,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { StatusTag } from '@/features/admin/components/StatusTag'
import { handoffModeMap, readStatusMeta, settlementModeMap } from '@/features/admin/labels'
import {
adminListingAssetRows,
adminListingOwnerRows,
adminListingResources,
adminListingScreenshotGroups,
adminListingSkins,
} from '@/features/admin/listingDetailModel'
import {
listingCodeText,
listingQuantity,
listingRentalDuration,
} from '@/features/admin/listingDisplay'
import {
listingReviewStatusMeta,
listingStatusMeta,
resourceBadgeText,
} from '@/features/listings/labels'
import type { AdminUserItem } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { getAdminPath } from '@/shared/api/adminClient'
import { formatCoinM, formatDateTime, formatText } from '@/shared/utils/format'
type ReasonAction = 'offline' | 'abnormal'
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
const reasonCopy: Record<ReasonAction, { title: string; hint: string; okText: string }> = {
offline: {
title: '强制下架商品',
hint: '下架后商品不再对外展示,号主可修改后重新提交。',
okText: '确认下架',
},
abnormal: {
title: '标记商品异常',
hint: '标记后商品进入异常状态,便于后续人工核查。',
okText: '确认标记',
},
}
export function AdminListingDetailPage() {
const { id } = useParams()
const listingId = Number(id)
const navigate = useNavigate()
const { message } = App.useApp()
const queryClient = useQueryClient()
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const can = (permission: string) =>
Boolean(permissions?.includes('*') || permissions?.includes(permission))
const [busy, setBusy] = useState(false)
const [reasonAction, setReasonAction] = useState<ReasonAction | null>(null)
const [reasonForm] = Form.useForm<{ reason: string }>()
const [externalOpen, setExternalOpen] = useState(false)
const [externalForm] = Form.useForm<{
haf_coin_m?: number
deposit_yuan?: number
awm_bullets?: number
level6_helmets?: number
level6_armor?: number
adjust_mode: 'discount_amount' | 'sale_ratio'
discount_amount_yuan?: number
sale_ratio?: number
reason: string
}>()
const [transferOpen, setTransferOpen] = useState(false)
const [transferForm] = Form.useForm<{
keyword?: string
target_user_id?: number
reason: string
}>()
const [userKeyword, setUserKeyword] = useState('')
const [userSearching, setUserSearching] = useState(false)
const [userOptions, setUserOptions] = useState<AdminUserItem[]>([])
const listingQuery = useQuery({
queryKey: ['admin-listing', listingId],
queryFn: () => fetchAdminListing(listingId),
enabled: Number.isFinite(listingId) && listingId > 0,
})
const listing = listingQuery.data
const refresh = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-listing', listingId] }),
queryClient.invalidateQueries({ queryKey: ['admin-listings'] }),
queryClient.invalidateQueries({ queryKey: ['admin-pending-listings'] }),
])
}
const run = async (action: () => Promise<unknown>, success: string) => {
setBusy(true)
try {
await action()
message.success(success)
await refresh()
return true
} catch (error) {
message.error(errorText(error))
return false
} finally {
setBusy(false)
}
}
const canOperate = Boolean(
listing && listing.status !== 'rented' && !['offline', 'abnormal'].includes(listing.status)
)
const canTransfer = Boolean(listing && listing.status !== 'rented')
const canAdjustExternal = Boolean(listing?.is_external_upload) && can('listing:edit_external')
const externalLocked = Boolean(
!listing ||
listing.in_transaction ||
!['draft', 'published', 'offline', 'abnormal'].includes(listing.status)
)
const copyCode = async () => {
if (!listing) return
try {
await navigator.clipboard.writeText(listingCodeText(listing))
message.success('商品编号已复制')
} catch {
message.warning('当前环境不支持剪贴板,请手动复制')
}
}
const openReason = (action: ReasonAction) => {
reasonForm.resetFields()
setReasonAction(action)
}
const submitReason = async () => {
if (!listing || !reasonAction) return
const values = await reasonForm.validateFields()
const ok = await run(
() =>
reasonAction === 'offline'
? offlineAdminListing(listing.id, values.reason.trim())
: markAbnormalAdminListing(listing.id, values.reason.trim()),
reasonAction === 'offline' ? '商品已下架' : '商品已标记异常'
)
if (ok) setReasonAction(null)
}
const openExternal = () => {
if (!listing || !canAdjustExternal || externalLocked) return
externalForm.setFieldsValue({
haf_coin_m: Number((listing.haf_coin_amount / 1_000_000).toFixed(1)),
deposit_yuan: Number((listing.deposit_amount_cent / 100).toFixed(2)),
awm_bullets: listingQuantity(listing, 'awmAmmo'),
level6_helmets: listingQuantity(listing, 'helmet6'),
level6_armor: listingQuantity(listing, 'armor6'),
adjust_mode: 'discount_amount',
discount_amount_yuan: 0,
sale_ratio: undefined,
reason: '',
})
setExternalOpen(true)
}
const submitExternal = async () => {
if (!listing) return
const values = await externalForm.validateFields()
const ok = await run(
() =>
adjustExternalAdminListing(listing.id, {
haf_coin_amount: Math.round(Number(values.haf_coin_m || 0) * 1_000_000),
awm_bullets: Math.trunc(Number(values.awm_bullets || 0)),
level6_helmets: Math.trunc(Number(values.level6_helmets || 0)),
level6_armor: Math.trunc(Number(values.level6_armor || 0)),
deposit_amount_cent: Math.round(Number(values.deposit_yuan || 0) * 100),
adjust_mode: values.adjust_mode,
discount_amount_cent:
values.adjust_mode === 'discount_amount'
? Math.round(Number(values.discount_amount_yuan || 0) * 100)
: undefined,
sale_ratio:
values.adjust_mode === 'sale_ratio' ? Number(values.sale_ratio || 0) : undefined,
reason: values.reason.trim(),
}),
'外部商品已调整'
)
if (ok) setExternalOpen(false)
}
const searchUsers = async () => {
setUserSearching(true)
try {
const result = await fetchAdminUsers({
keyword: userKeyword.trim(),
status: 'active',
page: 1,
page_size: 8,
})
setUserOptions(result.items.filter(user => user.realname_status === 'verified'))
} catch (error) {
message.error(errorText(error))
} finally {
setUserSearching(false)
}
}
const openTransfer = () => {
if (!listing || !canTransfer) return
transferForm.resetFields()
setUserKeyword('')
setUserOptions([])
setTransferOpen(true)
}
const submitTransfer = async () => {
if (!listing) return
const values = await transferForm.validateFields()
if (!values.target_user_id) {
message.warning('请先选择目标号主')
return
}
const ok = await run(
() =>
transferAdminListingOwner(listing.id, Number(values.target_user_id), values.reason.trim()),
'所有权已转移'
)
if (ok) setTransferOpen(false)
}
if (!Number.isFinite(listingId) || listingId <= 0) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 商品" title="商品详情" />
<Alert type="error" showIcon message="商品 ID 不正确" />
</div>
)
}
if (listingQuery.isError) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 商品" title="商品详情" />
<Alert
type="error"
showIcon
message="商品没有加载出来"
description={errorText(listingQuery.error)}
action={<Button onClick={() => void listingQuery.refetch()}>重试</Button>}
/>
</div>
)
}
const assetRows = listing ? adminListingAssetRows(listing) : []
const ownerRows = listing ? adminListingOwnerRows(listing) : []
const resources = listing ? adminListingResources(listing) : []
const skins = listing ? adminListingSkins(listing) : []
const screenshotGroups = listing ? adminListingScreenshotGroups(listing) : []
const duration = listing ? listingRentalDuration(listing) : { days: '—', dailyLoss: '' }
return (
<div className="admin-page">
<AdminPageHeader
eyebrow={listing ? `商品编号 ${listingCodeText(listing)}` : 'JIEYOU OPS · 商品'}
title={listing?.title || '商品详情'}
description={
listing
? `${formatText(listing.server_region)} · ${formatText(
listing.rank_level
)} · ${formatText(listing.login_platform)} · 账号 ${listing.account_id}`
: '加载中'
}
meta={
listing ? (
<Space size={6} wrap>
<StatusTag meta={listingStatusMeta(listing.status)} />
<StatusTag meta={listingReviewStatusMeta(listing.review_status)} />
{listing.is_external_upload ? (
<Tag color="geekblue">外部 API · {formatText(listing.source_channel)}</Tag>
) : null}
</Space>
) : null
}
extra={
<Space wrap>
<Button icon={<CopyOutlined />} onClick={() => void copyCode()}>
复制编号
</Button>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate(getAdminPath('/listings'))}
>
返回列表
</Button>
<Button
icon={<ReloadOutlined />}
loading={listingQuery.isFetching}
onClick={() => void refresh()}
>
刷新
</Button>
{canAdjustExternal ? (
<Button
type="primary"
icon={<EditOutlined />}
disabled={externalLocked}
onClick={openExternal}
>
调整商品
</Button>
) : null}
<Button
type="primary"
ghost
icon={<SwapOutlined />}
disabled={!canTransfer || !can('listing:approve')}
onClick={openTransfer}
>
转移所有权
</Button>
<Button
icon={<StopOutlined />}
disabled={!canOperate || !can('listing:offline')}
onClick={() => openReason('offline')}
>
强制下架
</Button>
<Button
danger
icon={<WarningOutlined />}
disabled={!canOperate || !can('listing:offline')}
onClick={() => openReason('abnormal')}
>
标记异常
</Button>
</Space>
}
/>
{listing ? (
<div className="admin-listing-summary">
<div>
<span>商品状态</span>
<StatusTag meta={listingStatusMeta(listing.status)} />
</div>
<div>
<span>审核状态</span>
<StatusTag meta={listingReviewStatusMeta(listing.review_status)} />
</div>
<div>
<span>哈夫币</span>
<strong>{formatCoinM(listing.haf_coin_amount)}</strong>
</div>
<div>
<span>押金</span>
<strong>¥{(listing.deposit_amount_cent / 100).toFixed(2)}</strong>
</div>
<div>
<span>商品售价</span>
<strong>¥{(listing.price_cent / 100).toFixed(2)}</strong>
</div>
<div>
<span>预计可租</span>
<strong>{duration.days}</strong>
</div>
</div>
) : null}
<div className="admin-listing-grid">
<Card title="账号属性" size="small" className="admin-detail-card admin-detail-card--wide">
<Descriptions column={3} size="small">
{assetRows.map(row => (
<Descriptions.Item key={row.label} label={row.label}>
{row.value}
</Descriptions.Item>
))}
</Descriptions>
</Card>
<Card title="号主与价格" size="small" className="admin-detail-card">
<Descriptions column={1} size="small">
{ownerRows.map(row => (
<Descriptions.Item key={row.label} label={row.label}>
{row.value}
</Descriptions.Item>
))}
<Descriptions.Item label="交接方式">
<StatusTag meta={readStatusMeta(handoffModeMap, listing?.handoff_mode)} />
</Descriptions.Item>
<Descriptions.Item label="结算方式">
<StatusTag meta={readStatusMeta(settlementModeMap, listing?.settlement_mode)} />
</Descriptions.Item>
</Descriptions>
</Card>
<Card title="说明与时间" size="small" className="admin-detail-card">
<Descriptions column={1} size="small">
<Descriptions.Item label="商品说明">
{formatText(listing?.description) || '暂无商品说明'}
</Descriptions.Item>
<Descriptions.Item label="审核/后台原因">
{formatText(listing?.review_reason)}
</Descriptions.Item>
<Descriptions.Item label="上架时间">
{formatDateTime(listing?.published_at)}
</Descriptions.Item>
<Descriptions.Item label="创建时间">
{formatDateTime(listing?.created_at)}
</Descriptions.Item>
<Descriptions.Item label="更新时间">
{formatDateTime(listing?.updated_at)}
</Descriptions.Item>
</Descriptions>
</Card>
<Card
title="库存资产"
size="small"
className="admin-detail-card admin-detail-card--wide"
extra={
<span>
{resources.length} 项资源 · {skins.length} 个皮肤
</span>
}
>
{resources.length ? (
<div className="admin-resource-grid">
{resources.map(resource => (
<div className={`admin-resource-chip is-${resource.mode}`} key={resource.label}>
<div>
<strong>{resource.label}</strong>
<span>×{resource.quantity}</span>
</div>
<em>{resourceBadgeText(resource, 'compact')}</em>
</div>
))}
</div>
) : (
<Typography.Text type="secondary">暂无库存资源</Typography.Text>
)}
<div className="admin-skin-tags">
{skins.map(skin => (
<Tag key={skin}>{skin}</Tag>
))}
{skins.length === 0 ? (
<Typography.Text type="secondary">暂无皮肤数据</Typography.Text>
) : null}
</div>
</Card>
<Card
title="资产截图"
size="small"
className="admin-detail-card admin-detail-card--wide"
extra={
<span>{screenshotGroups.reduce((sum, group) => sum + group.urls.length, 0)} 张</span>
}
>
{screenshotGroups.length ? (
<div className="admin-screenshot-groups">
{screenshotGroups.map(group => (
<div key={group.key} className="admin-screenshot-group">
<div className="admin-screenshot-group__title">
{group.label}
<span>{group.urls.length} 张</span>
</div>
<Image.PreviewGroup>
<div className="admin-screenshot-grid">
{group.urls.map(url => (
<Image key={url} src={url} alt={group.label} width={150} height={96} />
))}
</div>
</Image.PreviewGroup>
</div>
))}
</div>
) : (
<Typography.Text type="secondary">暂无截图</Typography.Text>
)}
</Card>
</div>
<Modal
open={reasonAction !== null}
title={reasonAction ? reasonCopy[reasonAction].title : ''}
okText={reasonAction ? reasonCopy[reasonAction].okText : '确认'}
okButtonProps={{ danger: reasonAction === 'offline' }}
confirmLoading={busy}
onOk={() => void submitReason()}
onCancel={() => setReasonAction(null)}
destroyOnHidden
>
<p className="admin-modal-hint">{reasonAction ? reasonCopy[reasonAction].hint : ''}</p>
<Form form={reasonForm} layout="vertical">
<Form.Item
name="reason"
label="操作原因"
rules={[{ required: true, whitespace: true, message: '请填写操作原因' }]}
>
<Input.TextArea rows={3} maxLength={255} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
open={externalOpen}
title="调整外部商品"
okText="保存调整"
confirmLoading={busy}
onOk={() => void submitExternal()}
onCancel={() => setExternalOpen(false)}
destroyOnHidden
width={720}
>
<p className="admin-modal-hint">
调整后会重新计算号主价格与租客展示价格;请填写用户申请内容或资产修正原因。
</p>
<Form form={externalForm} layout="vertical">
<div className="admin-form-grid">
<Form.Item
name="haf_coin_m"
label="纯币数量(M)"
rules={[{ required: true, message: '请填写纯币数量' }]}
>
<InputNumber min={0.1} step={1} precision={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item
name="deposit_yuan"
label="押金(元)"
rules={[{ required: true, message: '请填写押金' }]}
>
<InputNumber min={0} step={10} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="awm_bullets" label="AWM 子弹">
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="level6_helmets" label="六级头">
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="level6_armor" label="六级甲">
<InputNumber min={0} precision={0} style={{ width: '100%' }} />
</Form.Item>
</div>
<Form.Item name="adjust_mode" label="调整方式">
<Radio.Group>
<Radio.Button value="discount_amount">按降价金额</Radio.Button>
<Radio.Button value="sale_ratio">按发布比例</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue('adjust_mode') === 'sale_ratio' ? (
<Form.Item
name="sale_ratio"
label="目标发布比例"
rules={[{ required: true, message: '请填写目标比例' }]}
>
<InputNumber min={0} step={0.1} precision={1} style={{ width: 200 }} />
</Form.Item>
) : (
<Form.Item
name="discount_amount_yuan"
label="参考价降价(元)"
rules={[{ required: true, message: '请填写降价金额' }]}
>
<InputNumber min={0} step={1} precision={2} style={{ width: 200 }} />
</Form.Item>
)
}
</Form.Item>
{listing ? (
<div className="admin-price-preview">
<div>
<span>当前哈夫币</span>
<strong>{formatCoinM(listing.haf_coin_amount)}</strong>
</div>
<div>
<span>当前商品售价</span>
<strong>¥{(listing.price_cent / 100).toFixed(2)}</strong>
</div>
<div>
<span>当前押金</span>
<strong>¥{(listing.deposit_amount_cent / 100).toFixed(2)}</strong>
</div>
</div>
) : null}
<Form.Item
name="reason"
label="调整原因"
rules={[{ required: true, whitespace: true, message: '请填写调整原因' }]}
>
<Input.TextArea rows={3} maxLength={255} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
open={transferOpen}
title="转移商品所有权"
okText="确认转移"
confirmLoading={busy}
onOk={() => void submitTransfer()}
onCancel={() => setTransferOpen(false)}
destroyOnHidden
width={620}
>
<p className="admin-modal-hint">
仅支持转移给状态正常且已完成实名的用户;转移后商品归属与后续结算都会切换到新号主。
</p>
<Form form={transferForm} layout="vertical">
<Form.Item label="目标号主">
<Space.Compact style={{ width: '100%' }}>
<Input
value={userKeyword}
placeholder="手机号 / 昵称 / 用户 ID"
onChange={event => setUserKeyword(event.target.value)}
onPressEnter={() => void searchUsers()}
/>
<Button loading={userSearching} onClick={() => void searchUsers()}>
搜索用户
</Button>
</Space.Compact>
</Form.Item>
<Form.Item name="target_user_id" label="选择用户">
<Radio.Group style={{ width: '100%' }}>
<Space direction="vertical" style={{ width: '100%' }}>
{userOptions.map(user => (
<Radio key={user.id} value={user.id}>
{formatText(user.phone)} · {formatText(user.nickname)} · ID {user.id}
</Radio>
))}
</Space>
</Radio.Group>
</Form.Item>
<Form.Item
name="reason"
label="转移原因"
rules={[{ required: true, whitespace: true, message: '请填写转移原因' }]}
>
<Input.TextArea rows={3} maxLength={255} showCount />
</Form.Item>
</Form>
</Modal>
</div>
)
}
@@ -0,0 +1,606 @@
import { useMemo, useState } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
Alert,
App,
Button,
Card,
Descriptions,
Form,
Image,
Input,
InputNumber,
Modal,
Radio,
Space,
Tag,
Typography,
} from 'antd'
import {
ArrowLeftOutlined,
CheckCircleOutlined,
CloseCircleOutlined,
CloseOutlined,
EditOutlined,
ExportOutlined,
ReloadOutlined,
} from '@ant-design/icons'
import { useNavigate, useParams } from 'react-router-dom'
import {
adjustAdminListingPrice,
approveAdminListing,
fetchAdminListing,
rejectAdminListing,
} from '@/features/admin/api'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { StatusTag } from '@/features/admin/components/StatusTag'
import {
listingReviewStatusMeta,
listingStatusMeta,
resourceBadgeText,
} from '@/features/listings/labels'
import {
adminListingAssetRows,
adminListingPricePipeline,
adminListingResources,
adminListingScreenshotGroups,
adminListingSkins,
} from '@/features/admin/listingDetailModel'
import { listingCodeText } from '@/features/admin/listingDisplay'
import { useAdminSessionStore } from '@/stores/adminSession'
import { getAdminPath } from '@/shared/api/adminClient'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const rejectReasonOptions = [
'默认截图,需补充真实截图',
'账号资产信息不完整',
'价格或押金异常',
'封禁记录需补充说明',
'联系方式异常',
'截图与所填信息不符',
'缺少安全中心实机截图',
]
function errorText(error: unknown) {
const response = (error as { response?: { data?: { message?: string } } })?.response
if (response?.data?.message) return response.data.message
return error instanceof Error ? error.message : '请求失败,请稍后重试'
}
function ratioText(value: number) {
return value > 0 ? `1:${Math.round(value)}` : '—'
}
export function AdminListingReviewDetailPage() {
const { id } = useParams()
const listingId = Number(id)
const navigate = useNavigate()
const { message, modal } = App.useApp()
const queryClient = useQueryClient()
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const can = (permission: string) =>
Boolean(permissions?.includes('*') || permissions?.includes(permission))
const [busy, setBusy] = useState(false)
const [rejectOpen, setRejectOpen] = useState(false)
const [rejectReason, setRejectReason] = useState('')
const [priceOpen, setPriceOpen] = useState(false)
const [priceForm] = Form.useForm<{
mode: 'ratio' | 'price'
buyer_ratio?: number
buyer_total_price?: number
reason: string
}>()
const priceMode = Form.useWatch('mode', priceForm) ?? 'ratio'
const watchedRatio = Form.useWatch('buyer_ratio', priceForm)
const watchedPrice = Form.useWatch('buyer_total_price', priceForm)
const listingQuery = useQuery({
queryKey: ['admin-listing', listingId],
queryFn: () => fetchAdminListing(listingId),
enabled: Number.isFinite(listingId) && listingId > 0,
})
const listing = listingQuery.data
const pipeline = useMemo(
() =>
listing
? adminListingPricePipeline(listing)
: {
sellerTotalCent: 0,
sellerCoinBaseCent: 0,
consumableCent: 0,
sellerRatio: 0,
platformMarkupCent: 0,
buyerTotalCent: 0,
buyerCoinBaseCent: 0,
buyerRatio: 0,
depositCent: 0,
dailyLoss: '',
rentalDays: '—',
},
[listing]
)
const refresh = async () => {
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['admin-listing', listingId] }),
queryClient.invalidateQueries({ queryKey: ['admin-listings'] }),
queryClient.invalidateQueries({ queryKey: ['admin-pending-listings'] }),
])
}
const run = async (action: () => Promise<unknown>, success: string) => {
setBusy(true)
try {
await action()
message.success(success)
await refresh()
return true
} catch (error) {
message.error(errorText(error))
return false
} finally {
setBusy(false)
}
}
const approve = () => {
if (!listing) return
modal.confirm({
title: '通过审核并上架?',
content: `商品「${listing.title}」通过后会按当前状态对外展示。`,
okText: '通过审核',
onOk: async () => {
const ok = await run(() => approveAdminListing(listing.id), '审核已通过')
if (ok) navigate(getAdminPath('/listings/review'))
},
})
}
const toggleRejectReason = (reason: string) => {
const parts = rejectReason
.split(';')
.map(part => part.trim())
.filter(Boolean)
const index = parts.indexOf(reason)
if (index >= 0) parts.splice(index, 1)
else parts.push(reason)
setRejectReason(parts.join(';'))
}
const openReject = () => {
setRejectReason(listing?.review_reason || '')
setRejectOpen(true)
}
const submitReject = async () => {
if (!listing) return
const reason = rejectReason.trim()
if (!reason) {
message.warning('请填写拒绝原因')
return
}
const ok = await run(() => rejectAdminListing(listing.id, reason), '已拒绝审核')
if (ok) {
setRejectOpen(false)
navigate(getAdminPath('/listings/review'))
}
}
const openPrice = () => {
if (!listing) return
priceForm.setFieldsValue({
mode: 'ratio',
buyer_ratio: pipeline.buyerRatio ? Number(pipeline.buyerRatio.toFixed(1)) : undefined,
buyer_total_price: pipeline.buyerTotalCent
? Number((pipeline.buyerTotalCent / 100).toFixed(2))
: undefined,
reason: '',
})
setPriceOpen(true)
}
const submitPrice = async () => {
if (!listing) return
const values = await priceForm.validateFields()
const payload =
values.mode === 'ratio'
? { buyer_ratio: Number(values.buyer_ratio || 0), reason: values.reason.trim() }
: {
buyer_total_price_cent: Math.round(Number(values.buyer_total_price || 0) * 100),
reason: values.reason.trim(),
}
if ((payload.buyer_ratio ?? 0) <= 0 && (payload.buyer_total_price_cent ?? 0) <= 0) {
message.warning('请填写有效的比例或价格')
return
}
const ok = await run(() => adjustAdminListingPrice(listing.id, payload), '审核价格已调整')
if (ok) setPriceOpen(false)
}
if (!Number.isFinite(listingId) || listingId <= 0) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 审核" title="商品审核详情" />
<Alert type="error" showIcon message="商品 ID 不正确" />
</div>
)
}
if (listingQuery.isError) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 审核" title="商品审核详情" />
<Alert
type="error"
showIcon
message="商品没有加载出来"
description={errorText(listingQuery.error)}
action={<Button onClick={() => void listingQuery.refetch()}>重试</Button>}
/>
</div>
)
}
const assetRows = listing ? adminListingAssetRows(listing) : []
const resources = listing ? adminListingResources(listing) : []
const skins = listing ? adminListingSkins(listing) : []
const screenshotGroups = listing ? adminListingScreenshotGroups(listing) : []
const groupCount = new Map(screenshotGroups.map(group => [group.key, group.urls.length]))
const checklist = [
{ key: 'coin', label: '纯币截图' },
{ key: 'gameId', label: '游戏 ID' },
{ key: 'skin', label: '皮肤外观' },
{ key: 'tencentSecurity', label: '腾讯安全' },
{ key: 'totalAsset', label: '总资产' },
].map(item => ({ ...item, count: groupCount.get(item.key) ?? 0 }))
const pricePreview = (() => {
if (!listing) return { ratio: 0, coinPrice: 0, totalPrice: 0 }
const coinWan = listing.haf_coin_amount / 10000
const consumableYuan = pipeline.consumableCent / 100
if (priceMode === 'price') {
const total = Number(watchedPrice || 0)
const coinPrice = Math.max(0, total - consumableYuan)
return {
ratio: coinPrice > 0 ? coinWan / coinPrice : 0,
coinPrice,
totalPrice: total,
}
}
const ratio = Number(watchedRatio || 0)
const coinPrice = ratio > 0 ? coinWan / ratio : 0
return { ratio, coinPrice, totalPrice: coinPrice + consumableYuan }
})()
return (
<div className="admin-page">
<AdminPageHeader
eyebrow={listing ? `商品编号 ${listingCodeText(listing)}` : 'JIEYOU OPS · 审核'}
title={listing?.title || '商品审核详情'}
description={
listing
? `${formatText(listing.server_region)} · ${formatText(
listing.rank_level
)} · ${formatText(listing.login_platform)} · 提交于 ${formatDateTime(
listing.updated_at
)}`
: '加载中'
}
meta={
listing ? (
<Space size={6} wrap>
{listing.review_status === 'pending' ? <Tag color="gold">待审核</Tag> : null}
<StatusTag meta={listingStatusMeta(listing.status)} />
<StatusTag meta={listingReviewStatusMeta(listing.review_status)} />
</Space>
) : null
}
extra={
<Space wrap>
<Button
icon={<ArrowLeftOutlined />}
onClick={() => navigate(getAdminPath('/listings/review'))}
>
返回审核列表
</Button>
<Button
icon={<ReloadOutlined />}
loading={listingQuery.isFetching}
onClick={() => void refresh()}
>
刷新
</Button>
<Button
icon={<ExportOutlined />}
onClick={() => navigate(getAdminPath(`/listings/${listingId}`))}
>
商品详情页
</Button>
<Button icon={<EditOutlined />} disabled={!can('listing:approve')} onClick={openPrice}>
调整价格
</Button>
{listing?.review_status === 'pending' ? (
<>
<Button
danger
icon={<CloseOutlined />}
disabled={!can('listing:reject')}
onClick={openReject}
>
拒绝
</Button>
<Button
type="primary"
icon={<CheckCircleOutlined />}
loading={busy}
disabled={!can('listing:approve')}
onClick={approve}
>
通过并上架
</Button>
</>
) : null}
</Space>
}
/>
{listing ? (
<>
<Card
title="价格核算与加价决策流"
size="small"
className="admin-detail-card admin-detail-card--wide"
>
<div className="admin-price-pipeline">
<div className="admin-price-card is-seller">
<span>卖家发布价</span>
<strong>{formatMoney(pipeline.sellerTotalCent)}</strong>
<small>发布比例 {ratioText(pipeline.sellerRatio)}</small>
<div>
纯币 {formatMoney(pipeline.sellerCoinBaseCent)} · 物品{' '}
{formatMoney(pipeline.consumableCent)}
</div>
</div>
<div className="admin-price-arrow">平台加价 →</div>
<div className="admin-price-card is-rule">
<span>平台加价</span>
<strong>+{formatMoney(pipeline.platformMarkupCent)}</strong>
<small>按平台规则计算</small>
<div>号主合计 {formatMoney(pipeline.sellerTotalCent)}</div>
</div>
<div className="admin-price-arrow">最终展示 →</div>
<div className="admin-price-card is-buyer">
<span>买家端展示售价</span>
<strong>{formatMoney(pipeline.buyerTotalCent)}</strong>
<small>展示比例 {ratioText(pipeline.buyerRatio)}</small>
<div>
纯币 {formatMoney(pipeline.buyerCoinBaseCent)} · 押金{' '}
{formatMoney(pipeline.depositCent)}
</div>
</div>
</div>
</Card>
<div className="admin-listing-grid">
<Card
title="账号核心战备属性"
size="small"
className="admin-detail-card admin-detail-card--wide"
>
<Descriptions column={3} size="small">
{assetRows.map(row => (
<Descriptions.Item key={row.label} label={row.label}>
{row.value}
</Descriptions.Item>
))}
</Descriptions>
</Card>
<Card title="来源溯源与号主信息" size="small" className="admin-detail-card">
<Descriptions column={1} size="small">
<Descriptions.Item label="号主">
{formatText(listing.owner_nickname || listing.owner_phone)} · ID{' '}
{listing.owner_id}
</Descriptions.Item>
<Descriptions.Item label="联系电话">
{formatText(listing.owner_phone)}
</Descriptions.Item>
<Descriptions.Item label="来源渠道">
{formatText(listing.source_channel)}
</Descriptions.Item>
<Descriptions.Item label="外部上传">
{listing.is_external_upload ? '是' : '否'}
</Descriptions.Item>
<Descriptions.Item label="账号 ID">{listing.account_id}</Descriptions.Item>
<Descriptions.Item label="提交时间">
{formatDateTime(listing.updated_at)}
</Descriptions.Item>
</Descriptions>
</Card>
<Card
title="物资装备与外观资产"
size="small"
className="admin-detail-card"
extra={<span>额外物资 {formatMoney(pipeline.consumableCent)}</span>}
>
{resources.length ? (
<div className="admin-resource-grid">
{resources.map(resource => (
<div className={`admin-resource-chip is-${resource.mode}`} key={resource.label}>
<div>
<strong>{resource.label}</strong>
<span>×{resource.quantity}</span>
</div>
<em>{resourceBadgeText(resource, 'compact')}</em>
</div>
))}
</div>
) : (
<Typography.Text type="secondary">暂无库存资源</Typography.Text>
)}
<div className="admin-skin-tags">
{skins.map(skin => (
<Tag key={skin}>{skin}</Tag>
))}
{skins.length === 0 ? (
<Typography.Text type="secondary">暂无皮肤数据</Typography.Text>
) : null}
</div>
</Card>
<Card
title="账号截图凭证审核"
size="small"
className="admin-detail-card admin-detail-card--wide"
extra={
<span>
{screenshotGroups.reduce((sum, group) => sum + group.urls.length, 0)} 张
</span>
}
>
<div className="admin-review-checklist">
{checklist.map(item => (
<div
className={`admin-review-check${item.count > 0 ? ' is-provided' : ' is-missing'}`}
key={item.key}
>
{item.count > 0 ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
<span>{item.label}</span>
<em>{item.count > 0 ? `已提供 (${item.count})` : '未提供'}</em>
</div>
))}
</div>
{screenshotGroups.length ? (
<div className="admin-screenshot-groups">
{screenshotGroups.map(group => (
<div key={group.key} className="admin-screenshot-group">
<div className="admin-screenshot-group__title">
{group.label}
<span>{group.urls.length} 张</span>
</div>
<Image.PreviewGroup>
<div className="admin-screenshot-grid">
{group.urls.map(url => (
<Image key={url} src={url} alt={group.label} width={150} height={96} />
))}
</div>
</Image.PreviewGroup>
</div>
))}
</div>
) : (
<Alert
type="warning"
showIcon
message="该商品未上传任何截图凭证,请谨慎审核或直接拒绝。"
/>
)}
</Card>
</div>
</>
) : null}
<Modal
open={rejectOpen}
title="拒绝发布商品"
okText="确认拒绝"
okButtonProps={{ danger: true }}
confirmLoading={busy}
onOk={() => void submitReject()}
onCancel={() => setRejectOpen(false)}
destroyOnHidden
width={620}
>
<p className="admin-modal-hint">点击下方原因可追加或取消,最终会合并写入审核意见。</p>
<div className="admin-reason-pills">
{rejectReasonOptions.map(reason => {
const selected = rejectReason.includes(reason)
return (
<button
key={reason}
type="button"
className={`admin-reason-pill${selected ? ' is-selected' : ''}`}
onClick={() => toggleRejectReason(reason)}
>
{reason}
</button>
)
})}
</div>
<Input.TextArea
rows={3}
value={rejectReason}
onChange={event => setRejectReason(event.target.value)}
maxLength={500}
showCount
placeholder="填写拒绝原因"
/>
</Modal>
<Modal
open={priceOpen}
title="调整审核价格"
okText="保存调价"
confirmLoading={busy}
onOk={() => void submitPrice()}
onCancel={() => setPriceOpen(false)}
destroyOnHidden
width={620}
>
<Form form={priceForm} layout="vertical">
<Form.Item name="mode" label="调整方式">
<Radio.Group>
<Radio.Button value="ratio">按买家比例</Radio.Button>
<Radio.Button value="price">按买家总价</Radio.Button>
</Radio.Group>
</Form.Item>
<Form.Item noStyle shouldUpdate>
{({ getFieldValue }) =>
getFieldValue('mode') === 'ratio' ? (
<Form.Item
name="buyer_ratio"
label="买家展示比例"
rules={[{ required: true, message: '请填写买家比例' }]}
>
<InputNumber min={0.1} step={0.1} precision={1} style={{ width: '100%' }} />
</Form.Item>
) : (
<Form.Item
name="buyer_total_price"
label="买家总价(元)"
rules={[{ required: true, message: '请填写买家总价' }]}
>
<InputNumber min={0.01} step={1} precision={2} style={{ width: '100%' }} />
</Form.Item>
)
}
</Form.Item>
<div className="admin-price-preview">
<div>
<span>调整后比例</span>
<strong>{ratioText(pricePreview.ratio)}</strong>
</div>
<div>
<span>调整后纯币价</span>
<strong>{formatMoney(Math.round(pricePreview.coinPrice * 100))}</strong>
</div>
<div>
<span>调整后买家价</span>
<strong>{formatMoney(Math.round(pricePreview.totalPrice * 100))}</strong>
</div>
</div>
<Form.Item
name="reason"
label="调价原因"
rules={[{ required: true, whitespace: true, message: '请填写调价原因' }]}
>
<Input.TextArea rows={3} maxLength={255} showCount />
</Form.Item>
</Form>
</Modal>
</div>
)
}
@@ -12,10 +12,9 @@ import {
type TableColumnsType,
} from 'antd'
import { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
import { useSearchParams } from 'react-router-dom'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { fetchAdminListings, fetchAdminPendingListings } from '@/features/admin/api'
import { AdminListingDetailDrawer } from '@/features/admin/AdminListingDetailDrawer'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
@@ -46,6 +45,7 @@ import {
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'
@@ -344,8 +344,7 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
}))
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(10)
const [detailId, setDetailId] = useState<number | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const navigate = useNavigate()
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('listing:view'))
@@ -418,8 +417,7 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
const openDetail = (id: number) => {
if (!canView) return
setDetailId(id)
setDetailOpen(true)
navigate(getAdminPath(isReviewMode ? `/listings/review/${id}` : `/listings/${id}`))
}
const columns = buildColumns(row => openDetail(row.id), canView)
@@ -685,17 +683,6 @@ export function AdminListingsPage({ mode = 'all' }: { mode?: ListingMode }) {
onRowClick={canView ? row => openDetail(row.id) : undefined}
emptyDescription={isReviewMode ? '没有待审核商品' : '没有匹配的商品'}
/>
{canView ? (
<AdminListingDetailDrawer
listingId={detailId}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onChanged={() => {
void refetch()
}}
/>
) : null}
</div>
)
}
File diff suppressed because it is too large Load Diff
@@ -2,10 +2,9 @@ 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 { EyeOutlined, ReloadOutlined } from '@ant-design/icons'
import { useSearchParams } from 'react-router-dom'
import { useSearchParams, useNavigate } from 'react-router-dom'
import { fetchAdminOrders } from '@/features/admin/api'
import { AdminOrderDetailDrawer } from '@/features/admin/AdminOrderDetailDrawer'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
@@ -23,6 +22,7 @@ import {
} from '@/features/admin/labels'
import type { AdminOrderListItem, AdminOrderQuery } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { getAdminPath } from '@/shared/api/adminClient'
import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
const emptyFilters: AdminOrderQuery = {
@@ -147,6 +147,7 @@ function buildColumns(
export function AdminOrdersPage() {
const [searchParams] = useSearchParams()
const navigate = useNavigate()
const initialKeyword = searchParams.get('keyword')?.trim() ?? ''
const initialUserId = Number(searchParams.get('user_id')) || undefined
@@ -162,8 +163,6 @@ export function AdminOrdersPage() {
}))
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [detailId, setDetailId] = useState<number | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canViewDetail = Boolean(permissions?.includes('*') || permissions?.includes('order:view'))
@@ -176,10 +175,9 @@ export function AdminOrdersPage() {
const columns = useMemo(
() =>
buildColumns(row => {
setDetailId(row.id)
setDetailOpen(true)
navigate(getAdminPath(`/orders/${row.id}`))
}, canViewDetail),
[canViewDetail]
[canViewDetail, navigate]
)
const patchFilter = (patch: Partial<AdminOrderQuery>) => {
@@ -317,21 +315,12 @@ export function AdminOrdersPage() {
onRowClick={
canViewDetail
? row => {
setDetailId(row.id)
setDetailOpen(true)
navigate(getAdminPath(`/orders/${row.id}`))
}
: undefined
}
emptyDescription="没有匹配的订单,换个筛选条件试试"
/>
{canViewDetail ? (
<AdminOrderDetailDrawer
orderId={detailId}
open={detailOpen}
onClose={() => setDetailOpen(false)}
/>
) : null}
</div>
)
}
+163
View File
@@ -40,11 +40,16 @@ import type {
AdminSiteAnnouncementQuery,
AdminListing,
AdminListingPage,
AdminListingPriceAdjustPayload,
AdminListingQuery,
AdminExternalListingAdjustPayload,
AdminLoginData,
AdminOrderDetail,
AdminOrderHandoffRecord,
AdminOrderPage,
AdminOrderProcessEvent,
AdminOrderQuery,
AdminOrderRefundStatus,
AdminPaymentChannel,
AdminPaymentConfig,
AdminPaymentConfigOption,
@@ -216,6 +221,130 @@ export async function fetchAdminOrder(id: number | string) {
return data.data
}
export async function fetchAdminOrderHandoffRecords(id: number | string) {
const { data } = await adminClient.get<ApiResponse<{ items?: AdminOrderHandoffRecord[] }>>(
`/admin/orders/${id}/handoff-records`
)
return data.data.items ?? []
}
export async function fetchAdminOrderProcessEvents(id: number | string) {
const { data } = await adminClient.get<ApiResponse<{ items?: AdminOrderProcessEvent[] }>>(
`/admin/orders/${id}/process-events`
)
return data.data.items ?? []
}
export async function fetchAdminOrderRefundStatus(id: number | string) {
const { data } = await adminClient.get<ApiResponse<AdminOrderRefundStatus>>(
`/admin/orders/${id}/refund-status`
)
return data.data
}
export interface AdminOrderCounterCheckoutPayload {
consumable_amount_cent: number
coin_consumed_m: number
other_amount_cent?: number
deposit_deduct_amount_cent: number
reason: string
evidence_urls?: string[]
}
export interface AdminOrderDisputePayload {
type: string
description: string
target_role: 'owner' | 'renter'
evidence_urls?: string[]
}
export async function closeAdminOrder(
id: number | string,
reason: string,
confirmZeroOwnerIncome = false
) {
await adminClient.post(`/admin/orders/${id}/close`, {
reason,
confirm_zero_owner_income: confirmZeroOwnerIncome,
})
}
export async function sealAdminOrder(id: number | string, reason: string) {
await adminClient.post(`/admin/orders/${id}/seal`, { reason })
}
export async function markAbnormalAdminOrder(id: number | string, reason: string) {
await adminClient.post(`/admin/orders/${id}/mark-abnormal`, { reason })
}
export async function resetAdminOrderHandoff(id: number | string, reason: string) {
await adminClient.post(`/admin/orders/${id}/reset-handoff`, { reason })
}
export async function platformHandoffAdminOrder(
id: number | string,
content: string,
reason: string
) {
await adminClient.post(`/admin/orders/${id}/platform-handoff`, { content, reason })
}
export async function forceHandoffAdminOrder(id: number | string, content: string, reason: string) {
await adminClient.post(`/admin/orders/${id}/force-handoff`, { content, reason })
}
export async function confirmAdminOrderCheckout(
id: number | string,
reason: string,
confirmZeroOwnerIncome = false
) {
await adminClient.post(`/admin/orders/${id}/platform-checkout/confirm`, {
reason,
confirm_zero_owner_income: confirmZeroOwnerIncome,
})
}
export async function counterAdminOrderCheckout(
id: number | string,
payload: AdminOrderCounterCheckoutPayload
) {
await adminClient.post(`/admin/orders/${id}/platform-checkout/counter`, payload)
}
export async function disputeAdminOrderCheckout(id: number | string, reason: string) {
await adminClient.post(`/admin/orders/${id}/platform-checkout/dispute`, { reason })
}
export async function markAdminOrderOfflineSettlement(id: number | string, remark: string) {
await adminClient.post(`/admin/orders/${id}/offline-settlement`, { remark })
}
export async function refundAdminOrder(id: number | string) {
const { data } = await adminClient.post<ApiResponse<AdminOrderRefundStatus>>(
`/admin/orders/${id}/refund`
)
return data.data
}
export async function holdAdminOrderDeposit(id: number | string, reason: string) {
await adminClient.post(`/admin/orders/${id}/deposit-hold`, { reason })
}
export async function releaseAdminOrderDeposit(id: number | string, reason: string) {
await adminClient.post(`/admin/orders/${id}/deposit-release`, { reason })
}
export async function createAdminOrderDispute(
id: number | string,
payload: AdminOrderDisputePayload
) {
const { data } = await adminClient.post<ApiResponse<{ id: number }>>(
`/admin/orders/${id}/dispute`,
payload
)
return data.data
}
export async function fetchAdminListings(query: AdminListingQuery) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminListingPage>>>(
'/admin/listings',
@@ -274,6 +403,40 @@ export async function markAbnormalAdminListing(id: number | string, reason: stri
return data.data
}
export async function adjustAdminListingPrice(
id: number | string,
payload: AdminListingPriceAdjustPayload
) {
const { data } = await adminClient.post<ApiResponse<AdminListing>>(
`/admin/listings/${id}/adjust-price`,
payload
)
return data.data
}
export async function adjustExternalAdminListing(
id: number | string,
payload: AdminExternalListingAdjustPayload
) {
const { data } = await adminClient.put<ApiResponse<AdminListing>>(
`/admin/listings/${id}/external-adjustment`,
payload
)
return data.data
}
export async function transferAdminListingOwner(
id: number | string,
targetUserId: number,
reason: string
) {
const { data } = await adminClient.post<ApiResponse<AdminListing>>(
`/admin/listings/${id}/transfer-owner`,
{ target_user_id: targetUserId, reason }
)
return data.data
}
export async function fetchAdminUsers(query: AdminUserQuery) {
const params: Record<string, unknown> = { ...compactParams(query) }
if (query.has_deposit_free_quota) {
+19
View File
@@ -29,6 +29,16 @@ function toOptions(map: Record<string, StatusMeta>): StatusOption[] {
return Object.entries(map).map(([value, meta]) => ({ value, label: meta.label }))
}
const accountSourceLabels: Record<string, string> = {
internal: '站内上传',
external: '外部上传',
}
export function accountSourceLabel(value?: string | null) {
if (!value) return '—'
return accountSourceLabels[value] ?? value
}
export const orderStatusMap: Record<string, StatusMeta> = {
pending_payment: { label: '待支付', tone: 'gold' },
pending_handoff: { label: '待交接', tone: 'blue' },
@@ -109,6 +119,15 @@ export const refundStatusMap: Record<string, StatusMeta> = {
failed: { label: '退款失败', tone: 'red' },
}
export const paymentStatusMap: Record<string, StatusMeta> = {
created: { label: '已创建', tone: 'default' },
paying: { label: '支付中', tone: 'processing' },
paid: { label: '已支付', tone: 'success' },
failed: { label: '支付失败', tone: 'red' },
closed: { label: '已关闭', tone: 'default' },
refunded: { label: '已退款', tone: 'blue' },
}
export function readStatusMeta(map: Record<string, StatusMeta>, value?: string | null): StatusMeta {
if (!value) return { label: '—', tone: 'default' }
return map[value] ?? { label: value, tone: 'default' }
@@ -0,0 +1,152 @@
import type { AdminListing } from '@/features/admin/types'
import { listingResources, type ListingResourceRow } from '@/features/listings/labels'
import { formatCoinM, formatMoney } from '@/shared/utils/format'
import {
listingAssetText,
listingBuyerCoinBasePriceCent,
listingBuyerRatio,
listingBuyerTotalPriceCent,
listingConsumablePriceCent,
listingPlatformMarkupCent,
listingRentalDuration,
listingSellerCoinBasePriceCent,
listingSellerRatio,
listingSellerTotalPriceCent,
listingSkinNames,
} from '@/features/admin/listingDisplay'
function recordOf(value: unknown): Record<string, unknown> {
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
export type ListingRow = { label: string; value: string }
export type ListingScreenshotGroup = { key: string; label: string; urls: string[] }
const screenshotGroupLabels: Record<string, string> = {
coin: '纯币截图',
gameId: '游戏 ID',
skin: '皮肤外观',
tencentSecurity: '腾讯安全',
totalAsset: '总资产',
}
export function adminListingAssetRows(listing: AdminListing): ListingRow[] {
const duration = listingRentalDuration(listing)
return [
{ label: '商品编号', value: listing.listing_no || `#${listing.id}` },
{ label: '账号 ID', value: String(listing.account_id) },
{ label: '游戏', value: listing.game_name || '—' },
{ label: '区服', value: listing.server_region || '—' },
{ label: '登录平台', value: listing.login_platform || '—' },
{ label: '段位', value: listing.rank_level || '—' },
{ label: '烽火等级', value: listingAssetText(listing, 'fire_level') },
{ label: '保险格数', value: listingAssetText(listing, 'season_insurance') },
{
label: '体力 / 负重',
value: `${listingAssetText(listing, 'stamina_level')} / ${listingAssetText(
listing,
'load_level'
)}`,
},
{ label: '绝密 KD', value: listingAssetText(listing, 'secret_kd') },
{ label: '常用地区', value: listingAssetText(listing, 'common_regions') },
{
label: '在线时间',
value: formatOnlineTime(recordOf(listing.asset_summary?.online_time)),
},
{ label: '可改游戏名', value: listingAssetText(listing, 'can_change_game_name') },
{
label: '全英雄',
value:
listingAssetText(listing, 'all_hero') !== '—'
? listingAssetText(listing, 'all_hero')
: listingAssetText(listing, 'all_heroes'),
},
{ label: '解锁德穆兰', value: listingAssetText(listing, 'unlock_demulan') },
{ label: '凌晨可响应', value: listingAssetText(listing, 'early_morning_response') },
{ label: '封禁记录', value: listingAssetText(listing, 'ban_record') },
{ label: '人脸情况', value: listingAssetText(listing, 'face_owner') },
{ label: '预计可租', value: duration.days },
]
}
function formatOnlineTime(value: Record<string, unknown>) {
const start = String(value.start ?? '').trim()
const end = String(value.end ?? '').trim()
if (!start && !end) return '—'
return `${start || '—'} 至 ${end || '—'}`
}
export function adminListingOwnerRows(listing: AdminListing): ListingRow[] {
const duration = listingRentalDuration(listing)
return [
{
label: '号主',
value: listing.owner_phone || listing.owner_nickname || `ID ${listing.owner_id}`,
},
{ label: '号主 ID', value: String(listing.owner_id) },
{ label: '租客租金', value: formatMoney(listingBuyerTotalPriceCent(listing)) },
{ label: '号主纯币价格', value: formatMoney(listingSellerCoinBasePriceCent(listing)) },
{ label: '号主损耗', value: formatMoney(listingConsumablePriceCent(listing)) },
{ label: '号主租金合计', value: formatMoney(listingSellerTotalPriceCent(listing)) },
{ label: '平台费用', value: formatMoney(listingPlatformMarkupCent(listing)) },
{ label: '押金', value: formatMoney(listing.deposit_amount_cent) },
{ label: '哈夫币', value: formatCoinM(listing.haf_coin_amount) },
{ label: '每日损耗', value: duration.dailyLoss || '—' },
{ label: '预计可租', value: duration.days },
{
label: '商品状态',
value:
listing.status === 'published'
? '已上架'
: listing.status === 'rented'
? '租用中'
: listing.status,
},
]
}
export function adminListingResources(listing: AdminListing): ListingResourceRow[] {
return listingResources(listing.asset_summary)
}
export function adminListingSkins(listing: AdminListing): string[] {
return listingSkinNames(listing)
}
export function adminListingScreenshotGroups(listing: AdminListing): ListingScreenshotGroup[] {
const groups = recordOf(listing.asset_summary?.screenshot_groups)
const result = Object.entries(groups)
.map(([key, value]) => ({
key,
label: screenshotGroupLabels[key] ?? key,
urls: Array.isArray(value)
? value.filter((url): url is string => typeof url === 'string' && Boolean(url))
: [],
}))
.filter(group => group.urls.length > 0)
if (result.length > 0) return result
if (listing.screenshot_urls?.length) {
return [{ key: 'all', label: '资产截图', urls: listing.screenshot_urls }]
}
return []
}
export function adminListingPricePipeline(listing: AdminListing) {
const duration = listingRentalDuration(listing)
return {
sellerTotalCent: listingSellerTotalPriceCent(listing),
sellerCoinBaseCent: listingSellerCoinBasePriceCent(listing),
consumableCent: listingConsumablePriceCent(listing),
sellerRatio: listingSellerRatio(listing),
platformMarkupCent: listingPlatformMarkupCent(listing),
buyerTotalCent: listingBuyerTotalPriceCent(listing),
buyerCoinBaseCent: listingBuyerCoinBasePriceCent(listing),
buyerRatio: listingBuyerRatio(listing),
depositCent: listing.deposit_amount_cent,
dailyLoss: duration.dailyLoss,
rentalDays: duration.days,
}
}
@@ -156,3 +156,74 @@ export function listingAccountLevel(row: AdminListing) {
if (fireLevel !== '—') return fireLevel
return row.rank_level || '—'
}
function breakdownCent(row: AdminListing, key: string) {
const breakdown = recordOf(row.asset_summary?.price_breakdown)
const cent = numberOf(breakdown[`${key}_cent`])
if (cent > 0) return Math.round(cent)
const yuan = numberOf(breakdown[key])
return yuan > 0 ? Math.round(yuan * 100) : 0
}
export function listingConsumablePriceCent(row: AdminListing) {
return breakdownCent(row, 'consumable_price')
}
export function listingSellerCoinBasePriceCent(row: AdminListing) {
const stored = breakdownCent(row, 'seller_coin_base_price')
if (stored > 0) return stored
const fallback = row.price_cent - listingConsumablePriceCent(row)
return Math.max(0, Math.round(fallback))
}
export function listingSellerTotalPriceCent(row: AdminListing) {
const stored = breakdownCent(row, 'seller_total_price')
if (stored > 0) return stored
return listingSellerCoinBasePriceCent(row) + listingConsumablePriceCent(row)
}
export function listingBuyerCoinBasePriceCent(row: AdminListing) {
return listingPureCoinPrice(row)
}
export function listingBuyerTotalPriceCent(row: AdminListing) {
const stored = breakdownCent(row, 'buyer_total_price')
if (stored > 0) return stored
return listingBuyerCoinBasePriceCent(row) + listingConsumablePriceCent(row)
}
export function listingPlatformMarkupCent(row: AdminListing) {
const stored = breakdownCent(row, 'platform_markup_amount')
if (stored > 0) return stored
return Math.max(0, listingBuyerCoinBasePriceCent(row) - listingSellerCoinBasePriceCent(row))
}
export function listingSellerRatio(row: AdminListing) {
const breakdown = recordOf(row.asset_summary?.price_breakdown)
const stored = numberOf(breakdown.seller_ratio) || numberOf(breakdown.seller_reference_ratio)
if (stored > 0) return stored
const baseYuan = listingSellerCoinBasePriceCent(row) / 100
const coinWan = numberOf(row.haf_coin_amount) / 10000
return baseYuan > 0 ? coinWan / baseYuan : 0
}
export function listingBuyerRatio(row: AdminListing) {
const breakdown = recordOf(row.asset_summary?.price_breakdown)
const stored = numberOf(breakdown.buyer_ratio)
if (stored > 0) return stored
const baseYuan = listingBuyerCoinBasePriceCent(row) / 100
const coinWan = numberOf(row.haf_coin_amount) / 10000
return baseYuan > 0 ? coinWan / baseYuan : 0
}
export function listingSkinNames(row: AdminListing): string[] {
const groups = recordOf(row.asset_summary?.skin_groups)
const names: string[] = []
Object.values(groups).forEach(value => {
if (!Array.isArray(value)) return
value.forEach(name => {
if (typeof name === 'string' && name.trim()) names.push(name.trim())
})
})
return Array.from(new Set(names))
}
@@ -0,0 +1,146 @@
import type { AdminOrderDetail } from '@/features/admin/types'
export type OrderFundRow = { label: string; amountCent: number }
export type SnapshotResource = {
key: string
label: string
quantity: number
mode: string
price: string
}
function recordOf(value: unknown): Record<string, unknown> {
if (typeof value === 'string' && value.trim()) {
try {
const parsed = JSON.parse(value) as unknown
return recordOf(parsed)
} catch {
return {}
}
}
return typeof value === 'object' && value !== null ? (value as Record<string, unknown>) : {}
}
function numberOf(value: unknown) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : 0
}
export function orderTotalCent(order: AdminOrderDetail) {
return numberOf(order.rent_amount_cent) + numberOf(order.deposit_amount_cent)
}
export function orderSnapshot(order: AdminOrderDetail) {
return recordOf(order.account_snapshot)
}
function snapshotAssetSummary(order: AdminOrderDetail) {
return recordOf(orderSnapshot(order).asset_summary)
}
function snapshotBreakdownCent(order: AdminOrderDetail, key: string) {
const breakdown = recordOf(snapshotAssetSummary(order).price_breakdown)
const cent = numberOf(breakdown[`${key}_cent`])
if (cent > 0) return Math.round(cent)
const yuan = numberOf(breakdown[key])
return yuan > 0 ? Math.round(yuan * 100) : 0
}
export function orderSnapshotResources(order: AdminOrderDetail): SnapshotResource[] {
const raw = snapshotAssetSummary(order).resources
if (!Array.isArray(raw)) return []
return raw
.map(entry => {
const row = recordOf(entry)
const quantity = Math.trunc(numberOf(row.quantity))
return {
key: String(row.key ?? ''),
label: String(row.label ?? row.key ?? '').trim(),
quantity: quantity > 0 ? quantity : 0,
mode: String(row.mode ?? '').trim(),
price: String(row.price ?? '').trim(),
}
})
.filter(row => row.label && row.quantity > 0)
.slice(0, 8)
}
export function orderSnapshotRatio(order: AdminOrderDetail) {
const assetSummary = snapshotAssetSummary(order)
const breakdown = recordOf(assetSummary.price_breakdown)
const sellerRatio = numberOf(breakdown.seller_ratio) || numberOf(assetSummary.publish_ratio)
const buyerRatio = numberOf(breakdown.buyer_ratio)
return { sellerRatio, buyerRatio }
}
export function orderFundRows(order: AdminOrderDetail): OrderFundRow[] {
const rows: OrderFundRow[] = [
{
label: '预收纯币原价',
amountCent: numberOf(order.pure_coin_original_amount_cent),
},
{
label: '预收额外物品',
amountCent: numberOf(order.extra_item_original_amount_cent),
},
]
if (numberOf(order.rent_discount_amount_cent) > 0) {
rows.push(
{ label: '原始租金', amountCent: numberOf(order.rent_original_amount_cent) },
{
label: `${order.renter_growth_level_name || '成长等级'}优惠`,
amountCent: -numberOf(order.rent_discount_amount_cent),
}
)
}
if (order.status === 'completed') {
rows.push(
{
label: '最终实际纯币原价',
amountCent: numberOf(order.actual_pure_coin_amount_cent),
},
{
label: '最终实际纯币优惠',
amountCent: -numberOf(order.actual_pure_coin_discount_cent),
},
{
label: '积分计算纯币基数',
amountCent: numberOf(order.growth_points_basis_cent),
}
)
}
rows.push({ label: '租客租金', amountCent: numberOf(order.rent_amount_cent) })
const ownerCoinBaseCent = snapshotBreakdownCent(order, 'seller_coin_base_price')
if (ownerCoinBaseCent > 0) {
const ownerLossCent = snapshotBreakdownCent(order, 'consumable_price')
rows.push(
{ label: '号主纯币价格', amountCent: ownerCoinBaseCent },
{ label: '号主损耗', amountCent: ownerLossCent },
{ label: '号主租金合计', amountCent: numberOf(order.owner_rent_amount_cent) }
)
} else {
rows.push({ label: '号主租金', amountCent: numberOf(order.owner_rent_amount_cent) })
}
rows.push(
{ label: '平台费用', amountCent: numberOf(order.platform_fee_cent) },
{ label: '实付押金', amountCent: numberOf(order.deposit_amount_cent) },
{ label: '原始押金', amountCent: numberOf(order.deposit_original_amount_cent) },
{ label: '免押额度', amountCent: numberOf(order.deposit_waived_amount_cent) }
)
if (numberOf(order.refund_amount_cent) > 0) {
rows.push({ label: '退款金额', amountCent: numberOf(order.refund_amount_cent) })
}
return rows
}
/** 预计结束时间:优先用后端字段,缺失时按起租时间 + 租期推算。 */
export function orderEstimatedEndAt(order: AdminOrderDetail): string | null {
if (order.estimated_end_at) return order.estimated_end_at
if (!order.rented_at || !order.estimated_duration_hours) return null
const start = new Date(order.rented_at).getTime()
if (!Number.isFinite(start)) return null
return new Date(start + order.estimated_duration_hours * 3600_000).toISOString()
}
+100 -1
View File
@@ -159,8 +159,14 @@ export interface AdminCheckoutInfo {
initiated_by: number
status: string
round_count: number
turn: string
turn?: string
proposed_by?: number
max_rounds: number
price_role?: string
display_amount_cent?: number
pure_coin_amount_cent?: number
pure_coin_discount_cent?: number
pure_coin_payable_cent?: number
content: string
evidence_urls: string[]
rent_amount_cent?: number | null
@@ -254,10 +260,78 @@ export interface AdminOrderDetail {
payment_deadline_at?: string | null
distribution_site_id?: number | null
source_track_code?: string
price_role?: string
base_price_cent?: number
shelf_markup_cent?: number
markup_site_id?: number
shelf_ratio?: number
shelf_ratio_delta?: number
rent_original_amount_cent?: number
rent_discount_amount_cent?: number
pure_coin_original_amount_cent?: number
extra_item_original_amount_cent?: number
actual_coin_consumed_m?: number
actual_pure_coin_amount_cent?: number
actual_pure_coin_discount_cent?: number
actual_extra_item_amount_cent?: number
renter_growth_level?: string
renter_growth_level_name?: string
renter_discount_bps?: number
growth_points_basis_cent?: number
growth_points_per_yuan?: number
growth_points_awarded?: number
growth_points_awarded_at?: string | null
managed_admin_id?: number | null
offline_settled_by?: number | null
source_channel?: string
created_at: string
updated_at: string
}
/** 后台交接记录:一次交接/归还/结账提交的不可变留痕。 */
export interface AdminOrderHandoffRecord {
id: number
order_id: number
from_user_id: number
to_user_id: number
type: string
content: string
confirmed_by_renter_at?: string | null
confirmed_by_owner_at?: string | null
created_at: string
}
/** 后台交易时间线:提交、修改、确认、结算等过程记录。 */
export interface AdminOrderProcessEvent {
id: number
business_type: string
business_id: number
stage: string
action: string
actor_type: string
actor_id: number
actor_name: string
target_type: string
target_id?: number | null
target_name: string
content: string
reason: string
payload?: Record<string, unknown> | null
attachment_urls?: string[]
state_before?: Record<string, unknown> | null
state_after?: Record<string, unknown> | null
created_at: string
}
export interface AdminOrderRefundStatus {
order_id: number
order_no: string
refund_status: string
refund_amount_cent: number
refunded_at?: string | null
total_amount_cent: number
}
export interface AdminListingQuery {
page?: number
page_size?: number
@@ -321,6 +395,31 @@ export interface AdminListingPage {
page_size: number
}
export interface AdminListingPriceAdjustPayload {
buyer_ratio?: number
buyer_total_price_cent?: number
reason?: string
}
export type AdminExternalListingAdjustMode = 'discount_amount' | 'sale_ratio'
export interface AdminExternalListingAdjustPayload {
haf_coin_amount: number
awm_bullets: number
level6_helmets: number
level6_armor: number
deposit_amount_cent: number
adjust_mode: AdminExternalListingAdjustMode
discount_amount_cent?: number
sale_ratio?: number
reason: string
}
export interface AdminListingTransferOwnerPayload {
target_user_id: number
reason: string
}
export interface AdminUserQuery {
page?: number
page_size?: number
+19 -2
View File
@@ -6,6 +6,7 @@ import {
Breadcrumb,
Button,
Dropdown,
Grid,
Layout,
Menu,
Tag,
@@ -462,6 +463,15 @@ const MENU_ALIASES: Record<string, string> = {
'/distribution/qrcodes': '/distribution/qrcode-pool',
}
function resolveMenuKey(path: string) {
const alias = MENU_ALIASES[path]
if (alias) return alias
if (/^\/orders\/[^/]+$/.test(path)) return '/orders'
if (/^\/listings\/review\/[^/]+$/.test(path)) return '/listings/review'
if (/^\/listings\/[^/]+$/.test(path)) return '/listings'
return path
}
const SUPPORT_STATUS_LABELS: Record<string, string> = {
online: '在线',
busy: '忙碌',
@@ -475,6 +485,12 @@ export function AdminLayout() {
const logout = useAdminSessionStore(state => state.logout)
const { message } = App.useApp()
const [collapsed, setCollapsed] = useState(false)
const screens = Grid.useBreakpoint()
const isMobile = screens.md === false
useEffect(() => {
if (typeof screens.md === 'boolean') setCollapsed(!screens.md)
}, [screens.md])
const [openKeys, setOpenKeys] = useState<string[]>([])
const [supportStatus, setSupportStatus] = useState<'online' | 'offline' | 'busy'>(
admin?.support_status ?? 'offline'
@@ -500,7 +516,7 @@ export function AdminLayout() {
location.pathname.startsWith(basePath) && location.pathname !== basePath
? location.pathname.slice(basePath.length)
: '/'
const selectedKey = MENU_ALIASES[relativePath] ?? relativePath
const selectedKey = resolveMenuKey(relativePath)
const can = (permission?: string) => {
if (!permission) return true
@@ -564,7 +580,7 @@ export function AdminLayout() {
className="admin-sider"
theme="light"
width={240}
collapsedWidth={72}
collapsedWidth={isMobile ? 0 : 72}
collapsed={collapsed}
trigger={null}
>
@@ -588,6 +604,7 @@ export function AdminLayout() {
onClick={({ key }) => {
if (key.startsWith('/')) {
navigate(key === '/' ? basePath : `${basePath}${key}`)
if (isMobile) setCollapsed(true)
}
}}
/>
+5
View File
@@ -14,6 +14,11 @@ export function getAdminHomePath() {
return getAdminBasePath()
}
export function getAdminPath(path: string) {
const suffix = path.startsWith('/') ? path : `/${path}`
return `${getAdminBasePath()}${suffix}`
}
function isAdminLoginPage() {
return window.location.pathname === getAdminLoginPath()
}
@@ -0,0 +1,291 @@
.admin-listing-summary {
display: grid;
grid-template-columns: repeat(6, minmax(0, 1fr));
gap: 1px;
margin-bottom: 16px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 12px;
background: var(--line);
}
.admin-listing-summary > div {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
background: #fff;
}
.admin-listing-summary span {
color: var(--ink-muted);
font-size: 12px;
}
.admin-listing-summary strong {
color: var(--ink);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 18px;
}
.admin-resource-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px;
}
.admin-resource-chip {
display: flex;
flex-direction: column;
gap: 8px;
padding: 10px 12px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fff;
}
.admin-resource-chip > div {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 8px;
}
.admin-resource-chip strong {
color: var(--ink);
font-size: 13px;
}
.admin-resource-chip span {
color: var(--ink);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 15px;
font-weight: 700;
}
.admin-resource-chip em {
align-self: flex-start;
padding: 2px 8px;
border-radius: 4px;
background: #e8f7ee;
color: #1f9d55;
font-size: 11px;
font-style: normal;
line-height: 18px;
}
.admin-resource-chip.is-paid em {
background: #fff0e6;
color: #e8590c;
}
.admin-resource-chip.is-unknown em {
background: #f1f0e9;
color: var(--ink-muted);
}
.admin-skin-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 14px;
}
.admin-screenshot-groups {
display: flex;
flex-direction: column;
gap: 16px;
}
.admin-screenshot-group__title {
display: flex;
align-items: baseline;
gap: 8px;
margin-bottom: 8px;
color: var(--ink);
font-size: 13px;
font-weight: 600;
}
.admin-screenshot-group__title span {
color: var(--ink-muted);
font-size: 12px;
font-weight: 400;
}
.admin-screenshot-grid {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.admin-screenshot-grid .ant-image,
.admin-screenshot-grid img {
border-radius: 8px;
overflow: hidden;
}
.admin-price-pipeline {
display: grid;
grid-template-columns: minmax(0, 1fr) auto minmax(0, 1fr) auto minmax(0, 1fr);
align-items: center;
gap: 12px;
}
.admin-price-card {
display: flex;
flex-direction: column;
gap: 6px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fbfaf6;
}
.admin-price-card > span {
color: var(--ink-muted);
font-size: 12px;
}
.admin-price-card > strong {
color: var(--ink);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 22px;
}
.admin-price-card > small {
color: var(--ink-secondary);
font-size: 12px;
}
.admin-price-card > div {
color: var(--ink-muted);
font-size: 12px;
}
.admin-price-card.is-buyer {
border-color: rgba(166, 67, 50, 0.35);
background: rgba(166, 67, 50, 0.04);
}
.admin-price-card.is-buyer > strong {
color: var(--brand-seal);
}
.admin-price-arrow {
color: var(--ink-muted);
font-size: 12px;
white-space: nowrap;
}
.admin-price-preview {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 12px;
margin: 0 0 16px;
padding: 12px 14px;
border-radius: 10px;
background: #f7f5ef;
}
.admin-price-preview > div {
display: flex;
flex-direction: column;
gap: 4px;
}
.admin-price-preview span {
color: var(--ink-muted);
font-size: 12px;
}
.admin-price-preview strong {
color: var(--ink);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 16px;
}
.admin-review-checklist {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
gap: 8px;
margin-bottom: 16px;
}
.admin-review-check {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-radius: 8px;
font-size: 12px;
}
.admin-review-check span {
flex: 1;
color: var(--ink);
}
.admin-review-check em {
color: inherit;
font-size: 11px;
font-style: normal;
}
.admin-review-check.is-provided {
background: #e8f7ee;
color: #1f9d55;
}
.admin-review-check.is-missing {
background: #f7f5ef;
color: var(--ink-muted);
}
.admin-reason-pills {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.admin-reason-pill {
padding: 5px 10px;
border: 1px solid var(--line);
border-radius: 999px;
background: #fff;
color: var(--ink-secondary);
cursor: pointer;
font-size: 12px;
}
.admin-reason-pill.is-selected {
border-color: var(--brand-seal);
background: rgba(166, 67, 50, 0.08);
color: var(--brand-seal);
}
@media (max-width: 1100px) {
.admin-listing-summary {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.admin-price-pipeline {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-price-arrow {
display: none;
}
}
@media (max-width: 720px) {
.admin-listing-summary {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.admin-price-pipeline,
.admin-price-preview {
grid-template-columns: minmax(0, 1fr);
}
}
+254
View File
@@ -0,0 +1,254 @@
.admin-detail-toolbar {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 16px;
}
.admin-order-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(176px, 1fr));
gap: 12px;
margin-bottom: 16px;
}
.admin-order-metric {
display: flex;
flex-direction: column;
gap: 4px;
padding: 14px 16px;
border: 1px solid var(--line);
border-radius: 12px;
background: #fff;
}
.admin-order-metric > span {
color: var(--ink-muted);
font-size: 12px;
}
.admin-order-metric > strong {
color: var(--ink);
font-size: 18px;
line-height: 1.3;
}
.admin-order-metric > small {
color: var(--ink-secondary);
font-size: 12px;
}
.admin-order-grid,
.admin-listing-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 16px;
}
.admin-order-grid > .admin-detail-card,
.admin-listing-grid > .admin-detail-card {
margin: 0;
}
.admin-detail-card--wide {
grid-column: 1 / -1;
}
.admin-order-grid + .admin-detail-card,
.admin-listing-grid + .admin-detail-card {
margin-top: 16px;
}
.admin-money-list {
display: flex;
flex-direction: column;
gap: 2px;
}
.admin-money-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 8px 0;
border-bottom: 1px dashed var(--line);
font-size: 13px;
color: var(--ink-secondary);
}
.admin-money-row strong {
color: var(--ink);
font-family: 'Inter', 'DIN Alternate', sans-serif;
font-size: 14px;
}
.admin-money-row.is-total {
border-bottom: none;
border-top: 1px solid var(--line);
margin-top: 4px;
padding-top: 12px;
color: var(--ink);
font-weight: 600;
}
.admin-money-row.is-total strong {
color: var(--brand-seal);
font-size: 18px;
}
.admin-timeline-item {
padding-bottom: 4px;
}
.admin-timeline-item__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
color: var(--ink);
}
.admin-timeline-item__head span {
color: var(--ink-muted);
font-size: 12px;
}
.admin-timeline-item p {
margin: 4px 0 0;
color: var(--ink-secondary);
font-size: 13px;
line-height: 1.6;
}
.admin-timeline-item__meta {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
margin-top: 4px;
color: var(--ink-muted);
font-size: 12px;
}
.admin-record-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.admin-record-item {
padding: 12px 14px;
border: 1px solid var(--line);
border-radius: 10px;
background: #fbfaf6;
}
.admin-record-item__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 12px;
}
.admin-record-item__head span {
color: var(--ink-muted);
font-size: 12px;
}
.admin-record-item__content {
margin: 8px 0;
color: var(--ink-secondary);
font-size: 13px;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.admin-record-item__meta {
display: flex;
flex-wrap: wrap;
gap: 4px 12px;
color: var(--ink-muted);
font-size: 12px;
}
.admin-snapshot-resources {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 12px;
}
.admin-snapshot-resources span {
padding: 4px 10px;
border-radius: 8px;
background: #f1f0e9;
color: var(--ink-secondary);
font-size: 12px;
}
.admin-snapshot-json {
margin-top: 12px;
}
.admin-snapshot-json summary {
cursor: pointer;
color: var(--ink-muted);
font-size: 12px;
}
.admin-snapshot-json pre {
max-height: 320px;
margin: 8px 0 0;
padding: 12px;
overflow: auto;
border-radius: 8px;
background: #f7f5ef;
color: var(--ink-secondary);
font-size: 12px;
line-height: 1.6;
}
.admin-form-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0 16px;
}
.admin-modal-hint {
margin: 0 0 16px;
padding: 10px 12px;
border-radius: 8px;
background: #f7f5ef;
color: var(--ink-secondary);
font-size: 13px;
line-height: 1.6;
}
.admin-detail-pre {
margin-top: 12px;
padding: 12px;
border-radius: 8px;
background: #f7f5ef;
color: var(--ink-secondary);
font-size: 12px;
line-height: 1.7;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.admin-detail-evidence {
margin-top: 12px;
}
@media (max-width: 1100px) {
.admin-order-grid,
.admin-listing-grid {
grid-template-columns: minmax(0, 1fr);
}
}
@media (max-width: 720px) {
.admin-form-grid {
grid-template-columns: minmax(0, 1fr);
}
}
+31
View File
@@ -2162,3 +2162,34 @@
grid-template-columns: 1fr;
}
}
@media (max-width: 767px) {
.admin-sider:not(.ant-layout-sider-collapsed) {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 40;
height: 100vh;
box-shadow: 0 18px 48px rgba(15, 23, 42, 0.18);
}
.admin-header {
padding: 0 14px;
}
.admin-header__left {
min-width: 0;
gap: 8px;
}
.admin-header .ant-breadcrumb {
min-width: 0;
overflow: hidden;
white-space: nowrap;
}
.admin-content {
padding: 14px 12px 28px;
}
}
+2
View File
@@ -11,6 +11,8 @@
@import './wallet.css';
@import './auth.css';
@import './admin-misc.css';
@import './admin-order-detail.css';
@import './admin-listing-detail.css';
@import './admin-login.css';
@import './admin-shell.css';
@import './admin-distribution.css';