feat(admin): 订单与商品详情页补齐明细与操作
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
Reference in New Issue
Block a user