Files
hfb_jysh/frontend/src/features/admin/AdminListingReviewDetailPage.tsx
T

606 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
)
}