606 lines
21 KiB
TypeScript
606 lines
21 KiB
TypeScript
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>
|
||
<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={{ xs: 1, sm: 2, md: 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>
|
||
)
|
||
}
|