diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 34c9861..482351f 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -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() { }> } /> } /> + } /> } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/admin/AdminListingDetailPage.tsx b/frontend/src/features/admin/AdminListingDetailPage.tsx new file mode 100644 index 0000000..8b28be5 --- /dev/null +++ b/frontend/src/features/admin/AdminListingDetailPage.tsx @@ -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 = { + 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(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([]) + + 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, 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 ( +
+ + +
+ ) + } + + if (listingQuery.isError) { + return ( +
+ + void listingQuery.refetch()}>重试} + /> +
+ ) + } + + 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 ( +
+ + + + {listing.is_external_upload ? ( + 外部 API · {formatText(listing.source_channel)} + ) : null} + + ) : null + } + extra={ + + + + + {canAdjustExternal ? ( + + ) : null} + + + + + } + /> + + {listing ? ( +
+
+ 商品状态 + +
+
+ 审核状态 + +
+
+ 哈夫币 + {formatCoinM(listing.haf_coin_amount)} +
+
+ 押金 + ¥{(listing.deposit_amount_cent / 100).toFixed(2)} +
+
+ 商品售价 + ¥{(listing.price_cent / 100).toFixed(2)} +
+
+ 预计可租 + {duration.days} +
+
+ ) : null} + +
+ + + {assetRows.map(row => ( + + {row.value} + + ))} + + + + + + {ownerRows.map(row => ( + + {row.value} + + ))} + + + + + + + + + + + + + {formatText(listing?.description) || '暂无商品说明'} + + + {formatText(listing?.review_reason)} + + + {formatDateTime(listing?.published_at)} + + + {formatDateTime(listing?.created_at)} + + + {formatDateTime(listing?.updated_at)} + + + + + + {resources.length} 项资源 · {skins.length} 个皮肤 + + } + > + {resources.length ? ( +
+ {resources.map(resource => ( +
+
+ {resource.label} + ×{resource.quantity} +
+ {resourceBadgeText(resource, 'compact')} +
+ ))} +
+ ) : ( + 暂无库存资源 + )} +
+ {skins.map(skin => ( + {skin} + ))} + {skins.length === 0 ? ( + 暂无皮肤数据 + ) : null} +
+
+ + {screenshotGroups.reduce((sum, group) => sum + group.urls.length, 0)} 张 + } + > + {screenshotGroups.length ? ( +
+ {screenshotGroups.map(group => ( +
+
+ {group.label} + {group.urls.length} 张 +
+ +
+ {group.urls.map(url => ( + {group.label} + ))} +
+
+
+ ))} +
+ ) : ( + 暂无截图 + )} +
+
+ + void submitReason()} + onCancel={() => setReasonAction(null)} + destroyOnHidden + > +

{reasonAction ? reasonCopy[reasonAction].hint : ''}

+
+ + + +
+
+ + void submitExternal()} + onCancel={() => setExternalOpen(false)} + destroyOnHidden + width={720} + > +

+ 调整后会重新计算号主价格与租客展示价格;请填写用户申请内容或资产修正原因。 +

+
+
+ + + + + + + + + + + + + + + +
+ + + 按降价金额 + 按发布比例 + + + + {({ getFieldValue }) => + getFieldValue('adjust_mode') === 'sale_ratio' ? ( + + + + ) : ( + + + + ) + } + + {listing ? ( +
+
+ 当前哈夫币 + {formatCoinM(listing.haf_coin_amount)} +
+
+ 当前商品售价 + ¥{(listing.price_cent / 100).toFixed(2)} +
+
+ 当前押金 + ¥{(listing.deposit_amount_cent / 100).toFixed(2)} +
+
+ ) : null} + + + +
+
+ + void submitTransfer()} + onCancel={() => setTransferOpen(false)} + destroyOnHidden + width={620} + > +

+ 仅支持转移给状态正常且已完成实名的用户;转移后商品归属与后续结算都会切换到新号主。 +

+
+ + + setUserKeyword(event.target.value)} + onPressEnter={() => void searchUsers()} + /> + + + + + + + {userOptions.map(user => ( + + {formatText(user.phone)} · {formatText(user.nickname)} · ID {user.id} + + ))} + + + + + + +
+
+
+ ) +} diff --git a/frontend/src/features/admin/AdminListingReviewDetailPage.tsx b/frontend/src/features/admin/AdminListingReviewDetailPage.tsx new file mode 100644 index 0000000..27da716 --- /dev/null +++ b/frontend/src/features/admin/AdminListingReviewDetailPage.tsx @@ -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, 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 ( +
+ + +
+ ) + } + + if (listingQuery.isError) { + return ( +
+ + void listingQuery.refetch()}>重试} + /> +
+ ) + } + + 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 ( +
+ + {listing.review_status === 'pending' ? 待审核 : null} + + + + ) : null + } + extra={ + + + + + + {listing?.review_status === 'pending' ? ( + <> + + + + ) : null} + + } + /> + + {listing ? ( + <> + +
+
+ 卖家发布价 + {formatMoney(pipeline.sellerTotalCent)} + 发布比例 {ratioText(pipeline.sellerRatio)} +
+ 纯币 {formatMoney(pipeline.sellerCoinBaseCent)} · 物品{' '} + {formatMoney(pipeline.consumableCent)} +
+
+
平台加价 →
+
+ 平台加价 + +{formatMoney(pipeline.platformMarkupCent)} + 按平台规则计算 +
号主合计 {formatMoney(pipeline.sellerTotalCent)}
+
+
最终展示 →
+
+ 买家端展示售价 + {formatMoney(pipeline.buyerTotalCent)} + 展示比例 {ratioText(pipeline.buyerRatio)} +
+ 纯币 {formatMoney(pipeline.buyerCoinBaseCent)} · 押金{' '} + {formatMoney(pipeline.depositCent)} +
+
+
+
+ +
+ + + {assetRows.map(row => ( + + {row.value} + + ))} + + + + + + + {formatText(listing.owner_nickname || listing.owner_phone)} · ID{' '} + {listing.owner_id} + + + {formatText(listing.owner_phone)} + + + {formatText(listing.source_channel)} + + + {listing.is_external_upload ? '是' : '否'} + + {listing.account_id} + + {formatDateTime(listing.updated_at)} + + + + + 额外物资 {formatMoney(pipeline.consumableCent)}} + > + {resources.length ? ( +
+ {resources.map(resource => ( +
+
+ {resource.label} + ×{resource.quantity} +
+ {resourceBadgeText(resource, 'compact')} +
+ ))} +
+ ) : ( + 暂无库存资源 + )} +
+ {skins.map(skin => ( + {skin} + ))} + {skins.length === 0 ? ( + 暂无皮肤数据 + ) : null} +
+
+ + + {screenshotGroups.reduce((sum, group) => sum + group.urls.length, 0)} 张 + + } + > +
+ {checklist.map(item => ( +
0 ? ' is-provided' : ' is-missing'}`} + key={item.key} + > + {item.count > 0 ? : } + {item.label} + {item.count > 0 ? `已提供 (${item.count})` : '未提供'} +
+ ))} +
+ {screenshotGroups.length ? ( +
+ {screenshotGroups.map(group => ( +
+
+ {group.label} + {group.urls.length} 张 +
+ +
+ {group.urls.map(url => ( + {group.label} + ))} +
+
+
+ ))} +
+ ) : ( + + )} +
+
+ + ) : null} + + void submitReject()} + onCancel={() => setRejectOpen(false)} + destroyOnHidden + width={620} + > +

点击下方原因可追加或取消,最终会合并写入审核意见。

+
+ {rejectReasonOptions.map(reason => { + const selected = rejectReason.includes(reason) + return ( + + ) + })} +
+ setRejectReason(event.target.value)} + maxLength={500} + showCount + placeholder="填写拒绝原因" + /> +
+ + void submitPrice()} + onCancel={() => setPriceOpen(false)} + destroyOnHidden + width={620} + > +
+ + + 按买家比例 + 按买家总价 + + + + {({ getFieldValue }) => + getFieldValue('mode') === 'ratio' ? ( + + + + ) : ( + + + + ) + } + +
+
+ 调整后比例 + {ratioText(pricePreview.ratio)} +
+
+ 调整后纯币价 + {formatMoney(Math.round(pricePreview.coinPrice * 100))} +
+
+ 调整后买家价 + {formatMoney(Math.round(pricePreview.totalPrice * 100))} +
+
+ + + +
+
+
+ ) +} diff --git a/frontend/src/features/admin/AdminListingsPage.tsx b/frontend/src/features/admin/AdminListingsPage.tsx index 04d848b..5859cc5 100644 --- a/frontend/src/features/admin/AdminListingsPage.tsx +++ b/frontend/src/features/admin/AdminListingsPage.tsx @@ -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(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 ? ( - setDetailOpen(false)} - onChanged={() => { - void refetch() - }} - /> - ) : null} ) } diff --git a/frontend/src/features/admin/AdminOrderDetailPage.tsx b/frontend/src/features/admin/AdminOrderDetailPage.tsx new file mode 100644 index 0000000..c7e3ad2 --- /dev/null +++ b/frontend/src/features/admin/AdminOrderDetailPage.tsx @@ -0,0 +1,1221 @@ +import { useMemo, useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Card, + Checkbox, + Descriptions, + Empty, + Form, + Input, + InputNumber, + Modal, + Select, + Space, + Table, + Tag, + Timeline, + type TableColumnsType, +} from 'antd' +import { ArrowLeftOutlined, ReloadOutlined, WarningOutlined } from '@ant-design/icons' +import { useNavigate, useParams } from 'react-router-dom' + +import { + approveAdminRefund, + closeAdminOrder, + confirmAdminOrderCheckout, + counterAdminOrderCheckout, + createAdminOrderDispute, + disputeAdminOrderCheckout, + fetchAdminOrder, + fetchAdminOrderHandoffRecords, + fetchAdminOrderProcessEvents, + fetchAdminOrderRefundStatus, + fetchAdminPayments, + forceHandoffAdminOrder, + holdAdminOrderDeposit, + markAbnormalAdminOrder, + markAdminOrderOfflineSettlement, + platformHandoffAdminOrder, + refundAdminOrder, + rejectAdminRefund, + releaseAdminOrderDeposit, + resetAdminOrderHandoff, + sealAdminOrder, + type AdminOrderDisputePayload, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { StatusTag } from '@/features/admin/components/StatusTag' +import { + accountSourceLabel, + checkoutStatusMap, + handoffModeMap, + handoffStatusMeta, + offlineSettlementStatusMap, + orderStatusMap, + paymentStatusMap, + readStatusMeta, + refundStatusMap, + settlementModeMap, + settlementStatusMap, +} from '@/features/admin/labels' +import { + orderEstimatedEndAt, + orderFundRows, + orderSnapshot, + orderSnapshotRatio, + orderSnapshotResources, + orderTotalCent, +} from '@/features/admin/orderDetailModel' +import type { + AdminOrderHandoffRecord, + AdminOrderProcessEvent, + AdminPaymentRow, +} from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { getAdminPath } from '@/shared/api/adminClient' +import { emptyText, formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +type ReasonAction = + | 'close' + | 'seal' + | 'abnormal' + | 'reset' + | 'deposit_hold' + | 'deposit_release' + | 'checkout_confirm' + | 'checkout_dispute' + +const reasonCopy: Record< + ReasonAction, + { title: string; hint: string; okText: string; danger?: boolean } +> = { + close: { + title: '客服关闭订单', + hint: '关闭后交易终止,已付款项会按规则原路退款(押金暂扣部分除外),关联商品会下架。', + okText: '确认关闭', + danger: true, + }, + seal: { + title: '封存订单', + hint: '封存后订单关闭、款项原路退款,关联商品会被永久封存,不可再次编辑或上架。', + okText: '确认封存', + danger: true, + }, + abnormal: { + title: '标记订单异常', + hint: '标记后订单进入客服介入状态,便于后续跟进处理。', + okText: '确认标记', + }, + reset: { + title: '重置交接状态', + hint: '按当前订单所处阶段回退交接/结账状态,便于重新走流程。', + okText: '确认重置', + }, + deposit_hold: { + title: '暂扣押金', + hint: '暂扣后订单继续流转;后续本应退给租客的押金会挂起,需客服手动归还。', + okText: '确认暂扣', + }, + deposit_release: { + title: '归还押金', + hint: '将已暂扣的押金原路归还租客。', + okText: '确认归还', + }, + checkout_confirm: { + title: '客服确认结账', + hint: '确认当前结账方案并按方案完成结算;如号主收入为 0,需要额外勾选确认。', + okText: '确认结账', + }, + checkout_dispute: { + title: '发起结账争议', + hint: '将当前结账方案转入争议流程,由客服继续仲裁处理。', + okText: '发起争议', + danger: true, + }, +} + +const disputeTypeOptions = [ + { label: '无法登录', value: 'cannot_login' }, + { label: '描述不符', value: 'false_description' }, + { label: '账号封禁', value: 'account_banned' }, + { label: '资产损失', value: 'asset_loss' }, + { label: '哈夫币争议', value: 'haf_coin_dispute' }, + { label: '交接超时', value: 'handoff_timeout' }, + { label: '归还超时', value: 'return_timeout' }, + { label: '结账金额争议', value: 'checkout_amount' }, +] + +const handoffTypeText: Record = { + handoff: '交接', + return: '归还', + checkout: '结账', + submit: '提交', + confirm: '确认', +} + +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 moneyOrDash(cents?: number | null) { + return typeof cents === 'number' && cents > 0 ? formatMoney(cents) : '—' +} + +function snapshotText(value: unknown) { + return value == null || value === '' ? '—' : String(value) +} + +const paymentColumns: TableColumnsType = [ + { + title: '流水号', + dataIndex: 'payment_no', + width: 190, + render: value => {formatText(value)}, + }, + { + title: '类型', + dataIndex: 'biz_type', + width: 120, + render: value => {formatText(value)}, + }, + { + title: '金额', + dataIndex: 'amount_cent', + width: 120, + align: 'right', + render: value => formatMoney(Number(value)), + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => , + }, + { title: '渠道', dataIndex: 'provider', width: 100, render: value => formatText(value) }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, +] + +function ProcessTimeline({ events }: { events: AdminOrderProcessEvent[] }) { + if (events.length === 0) { + return + } + return ( + ({ + color: event.action.includes('close') || event.action.includes('seal') ? 'red' : 'green', + children: ( +
+
+ {formatText(event.action || event.stage)} + {formatDateTime(event.created_at)} +
+ {event.content ?

{event.content}

: null} + {event.reason ?

原因:{event.reason}

: null} +
+ 操作方:{formatText(event.actor_name || event.actor_type)} + {event.target_name ? 对象:{event.target_name} : null} + {event.stage ? 阶段:{event.stage} : null} +
+
+ ), + }))} + /> + ) +} + +function HandoffRecords({ records }: { records: AdminOrderHandoffRecord[] }) { + if (records.length === 0) { + return + } + return ( +
+ {records.map(record => ( +
+
+ {handoffTypeText[record.type] ?? formatText(record.type)} + {formatDateTime(record.created_at)} +
+

{record.content || emptyText}

+
+ 发送:ID {record.from_user_id} + 接收:ID {record.to_user_id} + 租客确认:{formatDateTime(record.confirmed_by_renter_at)} + 号主确认:{formatDateTime(record.confirmed_by_owner_at)} +
+
+ ))} +
+ ) +} + +export function AdminOrderDetailPage() { + const { id } = useParams() + const orderId = 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 [reasonAction, setReasonAction] = useState(null) + const [reasonForm] = Form.useForm<{ reason: string; confirm_zero_owner_income?: boolean }>() + const [handoffForm] = Form.useForm<{ content: string; reason: string }>() + const [handoffOpen, setHandoffOpen] = useState<'platform' | 'force' | null>(null) + const [counterForm] = Form.useForm<{ + consumable_yuan?: number + coin_consumed_m?: number + other_yuan?: number + deposit_deduct_yuan?: number + reason: string + evidence: string + }>() + const [counterOpen, setCounterOpen] = useState(false) + const [offlineForm] = Form.useForm<{ remark?: string }>() + const [offlineOpen, setOfflineOpen] = useState(false) + const [disputeForm] = Form.useForm<{ + type: string + target_role: 'owner' | 'renter' + description: string + evidence: string + }>() + const [disputeOpen, setDisputeOpen] = useState(false) + + const orderQuery = useQuery({ + queryKey: ['admin-order', orderId], + queryFn: () => fetchAdminOrder(orderId), + enabled: Number.isFinite(orderId) && orderId > 0, + }) + const order = orderQuery.data + + const handoffQuery = useQuery({ + queryKey: ['admin-order-handoffs', orderId], + queryFn: () => fetchAdminOrderHandoffRecords(orderId), + enabled: Boolean(order), + }) + const eventsQuery = useQuery({ + queryKey: ['admin-order-events', orderId], + queryFn: () => fetchAdminOrderProcessEvents(orderId), + enabled: Boolean(order), + }) + const refundQuery = useQuery({ + queryKey: ['admin-order-refund', orderId], + queryFn: () => fetchAdminOrderRefundStatus(orderId), + enabled: Boolean(order), + }) + const paymentsQuery = useQuery({ + queryKey: ['admin-order-payments', order?.order_no], + queryFn: () => fetchAdminPayments({ order_no: order?.order_no, page_size: 50 }), + enabled: Boolean(order?.order_no) && can('wallet:view'), + }) + + const refresh = async () => { + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-order', orderId] }), + queryClient.invalidateQueries({ queryKey: ['admin-order-handoffs', orderId] }), + queryClient.invalidateQueries({ queryKey: ['admin-order-events', orderId] }), + queryClient.invalidateQueries({ queryKey: ['admin-order-refund', orderId] }), + queryClient.invalidateQueries({ queryKey: ['admin-order-payments'] }), + queryClient.invalidateQueries({ queryKey: ['admin-orders'] }), + ]) + } + + const run = async (action: () => Promise, 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 refundStatus = refundQuery.data + const payments = useMemo(() => paymentsQuery.data?.items ?? [], [paymentsQuery.data]) + const latestPayment = useMemo( + () => + [...payments].sort( + (a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime() + )[0], + [payments] + ) + const paidPayment = payments.find(item => item.biz_type === 'order_pay' && item.status === 'paid') + const fundRows = order ? orderFundRows(order) : [] + const snapshot = order ? orderSnapshot(order) : {} + const snapshotRatio = order ? orderSnapshotRatio(order) : { sellerRatio: 0, buyerRatio: 0 } + const snapshotResources = order ? orderSnapshotResources(order) : [] + const totalCent = order ? orderTotalCent(order) : 0 + + if (!Number.isFinite(orderId) || orderId <= 0) { + return ( +
+ + +
+ ) + } + + if (orderQuery.isError) { + return ( +
+ + void orderQuery.refetch()}>重试} + /> +
+ ) + } + + const actions = order?.admin_actions + const canOperate = Boolean(order && !['completed', 'cancelled', 'closed'].includes(order.status)) + const depositHoldStatus = order?.deposit_hold_status || 'none' + const depositHoldAmountCent = Number(order?.deposit_hold_amount_cent || 0) + const isDepositHeld = depositHoldStatus === 'held' + const canHoldDeposit = + canOperate && + depositHoldStatus === 'none' && + Number(order?.deposit_amount_cent || 0) > 0 && + can('order:deposit_hold') + const canReleaseDeposit = isDepositHeld && depositHoldAmountCent > 0 && can('order:deposit_hold') + + const openReason = (action: ReasonAction) => { + reasonForm.resetFields() + setReasonAction(action) + } + + const submitReason = async () => { + if (!order || !reasonAction) return + const values = await reasonForm.validateFields() + const reason = values.reason.trim() + const copy = reasonCopy[reasonAction] + let action: () => Promise + switch (reasonAction) { + case 'close': + action = () => closeAdminOrder(order.id, reason) + break + case 'seal': + action = () => sealAdminOrder(order.id, reason) + break + case 'abnormal': + action = () => markAbnormalAdminOrder(order.id, reason) + break + case 'reset': + action = () => resetAdminOrderHandoff(order.id, reason) + break + case 'deposit_hold': + action = () => holdAdminOrderDeposit(order.id, reason) + break + case 'deposit_release': + action = () => releaseAdminOrderDeposit(order.id, reason) + break + case 'checkout_confirm': + action = () => + confirmAdminOrderCheckout(order.id, reason, Boolean(values.confirm_zero_owner_income)) + break + case 'checkout_dispute': + action = () => disputeAdminOrderCheckout(order.id, reason) + break + } + if (await run(action, `${copy.okText}成功`)) setReasonAction(null) + } + + const submitHandoff = async () => { + if (!order || !handoffOpen) return + const values = await handoffForm.validateFields() + const content = values.content.trim() + const reason = values.reason.trim() + const api = handoffOpen === 'platform' ? platformHandoffAdminOrder : forceHandoffAdminOrder + if ( + await run( + () => api(order.id, content, reason), + handoffOpen === 'platform' ? '已代交接' : '已一键交接' + ) + ) { + setHandoffOpen(null) + } + } + + const submitCounter = async () => { + if (!order) return + const values = await counterForm.validateFields() + const evidence = (values.evidence || '') + .split('\n') + .map(line => line.trim()) + .filter(Boolean) + const ok = await run( + () => + counterAdminOrderCheckout(order.id, { + consumable_amount_cent: Math.round(Number(values.consumable_yuan || 0) * 100), + coin_consumed_m: Number(values.coin_consumed_m || 0), + other_amount_cent: Math.round(Number(values.other_yuan || 0) * 100), + deposit_deduct_amount_cent: Math.round(Number(values.deposit_deduct_yuan || 0) * 100), + reason: values.reason.trim(), + evidence_urls: evidence, + }), + '结账方案已修正' + ) + if (ok) setCounterOpen(false) + } + + const submitOffline = async () => { + if (!order) return + const values = await offlineForm.validateFields() + if ( + await run( + () => markAdminOrderOfflineSettlement(order.id, (values.remark || '').trim()), + '线下结算已确认' + ) + ) { + setOfflineOpen(false) + } + } + + const submitDispute = async () => { + if (!order) return + const values = await disputeForm.validateFields() + const payload: AdminOrderDisputePayload = { + type: values.type, + target_role: values.target_role, + description: values.description.trim(), + evidence_urls: (values.evidence || '') + .split('\n') + .map(line => line.trim()) + .filter(Boolean), + } + if (await run(() => createAdminOrderDispute(order.id, payload), '申诉已创建')) { + setDisputeOpen(false) + } + } + + const confirmRefund = () => { + if (!order) return + modal.confirm({ + title: '确认人工退款?', + icon: , + content: `订单 ${order.order_no} 将按剩余可退金额原路退款(押金暂扣部分会挂起),此操作不会关闭订单。`, + okText: '确认退款', + okButtonProps: { danger: true }, + onOk: () => run(() => refundAdminOrder(order.id), '退款已发起'), + }) + } + + const confirmApproveRefund = () => { + if (!order) return + modal.confirm({ + title: '通过退款审核?', + content: '将触发原路退款,押金暂扣部分会挂起。', + okText: '通过退款', + onOk: () => run(() => approveAdminRefund(order.id), '退款已通过'), + }) + } + + const confirmRejectRefund = (action: 'restore' | 'close') => { + if (!order) return + modal.confirm({ + title: action === 'restore' ? '驳回并恢复订单?' : '驳回并关闭订单?', + content: + action === 'restore' ? '驳回退款申请后订单恢复流转。' : '驳回退款申请并直接关闭订单。', + okText: '确认驳回', + okButtonProps: { danger: true }, + onOk: () => run(() => rejectAdminRefund(order.id, action), '已驳回退款申请'), + }) + } + + const metrics = order + ? [ + { + key: 'order', + label: '订单状态', + value: readStatusMeta(orderStatusMap, order.status).label, + detail: handoffStatusMeta(order.handoff_status, order).label, + }, + { + key: 'settlement', + label: '结算状态', + value: readStatusMeta(settlementStatusMap, order.settlement_status).label, + detail: `更新 ${formatDateTime(order.updated_at)}`, + }, + { + key: 'total', + label: '订单总额', + value: formatMoney(totalCent), + detail: `租金 ${formatMoney(order.rent_amount_cent)} / 押金 ${formatMoney( + order.deposit_amount_cent + )}`, + }, + { + key: 'payment', + label: '支付状态', + value: can('wallet:view') + ? latestPayment + ? readStatusMeta(paymentStatusMap, latestPayment.status).label + : '暂无流水' + : '无查看权限', + detail: paidPayment ? `支付 ${formatMoney(paidPayment.amount_cent)}` : '尚未支付', + }, + { + key: 'refund', + label: '退款状态', + value: readStatusMeta(refundStatusMap, refundStatus?.refund_status || 'none').label, + detail: refundStatus?.refund_amount_cent + ? formatMoney(refundStatus.refund_amount_cent) + : '暂无退款金额', + }, + { + key: 'deadline', + label: '预计截止', + value: formatDateTime(orderEstimatedEndAt(order)), + detail: `${order.estimated_duration_hours} 小时`, + }, + ] + : [] + + return ( +
+ + + + + + ) : null + } + extra={ + + + + + } + /> + + {order && orderQuery.isFetching ? ( + + ) : null} + +
+ {actions?.reset_handoff?.enabled && can('order:mark_abnormal') ? ( + + ) : null} + {actions?.platform_handoff?.enabled && can('order:mark_abnormal') ? ( + + ) : null} + {actions?.force_handoff?.enabled && can('order:force_handoff') ? ( + + ) : null} + {actions?.platform_checkout_confirm?.enabled && can('order:mark_abnormal') ? ( + + ) : null} + {actions?.platform_checkout_counter?.enabled && can('order:mark_abnormal') ? ( + + ) : null} + {actions?.platform_checkout_dispute?.enabled && can('order:mark_abnormal') ? ( + + ) : null} + {actions?.platform_offline_settlement?.enabled && can('order:mark_abnormal') ? ( + + ) : null} + {canOperate && !order?.active_dispute && can('dispute:arbitrate') ? ( + + ) : null} + {canOperate && can('order:mark_abnormal') ? ( + + ) : null} + {canOperate && can('order:close') ? ( + + ) : null} + {canOperate && can('order:close') ? ( + + ) : null} + {canHoldDeposit ? ( + + ) : null} + {canReleaseDeposit ? ( + + ) : null} + {can('order:close') && refundStatus?.refund_status !== 'refunded' ? ( + + ) : null} +
+ + {orderQuery.isPending ? ( + + ) : order ? ( + <> +
+ {metrics.map(metric => ( +
+ {metric.label} + {metric.value} + {metric.detail} +
+ ))} +
+ +
+ + + + {order.order_no} + + {order.id} + + {formatText(order.listing_no || order.listing_id)} + + {order.account_id} + + {formatText(order.server_region)} + + + {formatText(order.login_platform)} + + + {formatText(order.title)} + + + + + + + + {formatText(order.renter_phone)} · ID {order.renter_id} + + + {formatText(order.owner_phone)} · ID {order.owner_id} + + + {formatDateTime(order.created_at)} + + + {order.payment_deadline_at ? formatDateTime(order.payment_deadline_at) : '未设置'} + + + {order.rented_at ? formatDateTime(order.rented_at) : '未开始'} + + + {orderEstimatedEndAt(order) + ? formatDateTime(orderEstimatedEndAt(order)) + : '未设置'} + + + + + + + + {accountSourceLabel(order.account_source)} + + + {formatText(order.source_channel)} + + + {readStatusMeta(handoffModeMap, order.handoff_mode).label} + + + {readStatusMeta(settlementModeMap, order.settlement_mode).label} + + + {order.managed_admin_id ? `ID ${order.managed_admin_id}` : '—'} + + + {order.distribution_site_id ? `#${order.distribution_site_id}` : '主站'} + + + {formatText(order.source_track_code)} + + + + + +
+ {fundRows.map(row => ( +
+ {row.label} + {formatMoney(row.amountCent)} +
+ ))} +
+ 订单实付合计 + {formatMoney(totalCent)} +
+
+
+ + + } + > + + + {moneyOrDash(refundStatus?.refund_amount_cent)} + + + {formatMoney(refundStatus?.total_amount_cent ?? totalCent)} + + + {refundStatus?.refunded_at ? formatDateTime(refundStatus.refunded_at) : '未完成'} + + {payments.length} 条 + + {depositHoldStatus === 'held' + ? '押金已暂扣' + : depositHoldStatus === 'released' + ? '押金已归还' + : '未暂扣'} + + + {moneyOrDash(depositHoldAmountCent)} + + + {formatText(order.deposit_hold_reason)} + + + {formatDateTime(order.deposit_held_at)} + + + {formatDateTime(order.deposit_hold_released_at)} + + + {refundStatus?.refund_status === 'pending_review' && can('order:close') ? ( + + + + + + ) : null} + + + {order.offline_settlement_status && order.offline_settlement_status !== 'none' ? ( + + } + > + + + {formatMoney(order.offline_settlement_amount_cent)} + + + {order.offline_settled_by ? `ID ${order.offline_settled_by}` : '—'} + + + {formatDateTime(order.offline_settled_at)} + + + {formatText(order.offline_settlement_remark)} + + + + ) : null} + + {order.checkout ? ( + + } + > + + + {formatMoney(order.checkout.display_amount_cent ?? 0)} + + + {formatMoney(order.checkout.pure_coin_amount_cent ?? 0)} + + + {formatMoney(order.checkout.pure_coin_discount_cent ?? 0)} + + + {formatMoney(order.checkout.pure_coin_payable_cent ?? 0)} + + + {formatMoney(order.checkout.renter_refund_amount_cent)} + + + {formatMoney(order.checkout.owner_income_amount_cent)} + + + {formatMoney(order.checkout.deposit_deduct_amount_cent)} + + + {formatMoney(order.checkout.consumable_amount_cent)} + + + {order.checkout.coin_consumed_m} M + + + {order.checkout.round_count} / {order.checkout.max_rounds} + + + ID {order.checkout.initiated_by} + + + {order.checkout.evidence_urls?.length ?? 0} 张 + + + {order.checkout.content ? ( +
{order.checkout.content}
+ ) : null} + {order.checkout.evidence_urls?.length ? ( + + {order.checkout.evidence_urls.map((url, index) => ( + + 凭证 {index + 1} + + ))} + + ) : null} +
+ ) : null} + + {order.active_dispute ? ( + + + #{order.active_dispute.id} + + {formatText(order.active_dispute.type)} + + + {order.active_dispute.initiator_type} · {order.active_dispute.initiator_id} + + + {formatText(order.active_dispute.status)} + + + + ) : null} +
+ + {can('wallet:view') ? `${payments.length} 条` : '无权限'}} + > + {can('wallet:view') ? ( + + ) : ( + + )} + + + 每次提交、修改、确认及结算均单独留痕} + > + + + + 共 {(handoffQuery.data ?? []).length} 条} + > + + + + + {snapshotRatio.sellerRatio > 0 ? ( + 回收比例 1:{Math.round(snapshotRatio.sellerRatio)} + ) : null} + {snapshotRatio.buyerRatio > 0 ? ( + 售卖比例 1:{Math.round(snapshotRatio.buyerRatio)} + ) : null} + + } + > + + + {snapshotText(snapshot.account_id)} + + {snapshotText(snapshot.title)} + {snapshotText(snapshot.game_name)} + + {snapshotText(snapshot.server_region)} + + + {snapshotText(snapshot.haf_coin_amount)} + + + {snapshotText(snapshot.rank_level)} + + + {snapshotResources.length ? ( +
+ {snapshotResources.map(resource => ( + + {resource.label} ×{resource.quantity} + {resource.mode ? ` · ${resource.mode}` : ''} + {resource.price ? ` · ${resource.price}` : ''} + + ))} +
+ ) : null} +
+ 查看原始 JSON +
{JSON.stringify(snapshot, null, 2)}
+
+
+ + ) : null} + + void submitReason()} + onCancel={() => setReasonAction(null)} + destroyOnHidden + > +

{reasonAction ? reasonCopy[reasonAction].hint : ''}

+
+ + + + {reasonAction === 'checkout_confirm' ? ( + + 确认号主收入为 0,仍继续结账 + + ) : null} + +
+ + void submitHandoff()} + onCancel={() => setHandoffOpen(null)} + destroyOnHidden + > +
+ + + + + + + +
+ + void submitCounter()} + onCancel={() => setCounterOpen(false)} + destroyOnHidden + width={640} + > +
+
+ + + + + + + + + + + + +
+ + + + + + + +
+ + void submitOffline()} + onCancel={() => setOfflineOpen(false)} + destroyOnHidden + > +
+ + + + +
+ + void submitDispute()} + onCancel={() => setDisputeOpen(false)} + destroyOnHidden + width={620} + > +
+
+ + + +
+ + + + + + + +
+ + ) +} diff --git a/frontend/src/features/admin/AdminOrdersPage.tsx b/frontend/src/features/admin/AdminOrdersPage.tsx index 25cbeb8..86959db 100644 --- a/frontend/src/features/admin/AdminOrdersPage.tsx +++ b/frontend/src/features/admin/AdminOrdersPage.tsx @@ -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(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) => { @@ -317,21 +315,12 @@ export function AdminOrdersPage() { onRowClick={ canViewDetail ? row => { - setDetailId(row.id) - setDetailOpen(true) + navigate(getAdminPath(`/orders/${row.id}`)) } : undefined } emptyDescription="没有匹配的订单,换个筛选条件试试" /> - - {canViewDetail ? ( - setDetailOpen(false)} - /> - ) : null} ) } diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index 5bf0558..3c0f2d4 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -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>( + `/admin/orders/${id}/handoff-records` + ) + return data.data.items ?? [] +} + +export async function fetchAdminOrderProcessEvents(id: number | string) { + const { data } = await adminClient.get>( + `/admin/orders/${id}/process-events` + ) + return data.data.items ?? [] +} + +export async function fetchAdminOrderRefundStatus(id: number | string) { + const { data } = await adminClient.get>( + `/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>( + `/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>( + `/admin/orders/${id}/dispute`, + payload + ) + return data.data +} + export async function fetchAdminListings(query: AdminListingQuery) { const { data } = await adminClient.get>>( '/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>( + `/admin/listings/${id}/adjust-price`, + payload + ) + return data.data +} + +export async function adjustExternalAdminListing( + id: number | string, + payload: AdminExternalListingAdjustPayload +) { + const { data } = await adminClient.put>( + `/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>( + `/admin/listings/${id}/transfer-owner`, + { target_user_id: targetUserId, reason } + ) + return data.data +} + export async function fetchAdminUsers(query: AdminUserQuery) { const params: Record = { ...compactParams(query) } if (query.has_deposit_free_quota) { diff --git a/frontend/src/features/admin/labels.ts b/frontend/src/features/admin/labels.ts index ad4d66b..a1fb1b0 100644 --- a/frontend/src/features/admin/labels.ts +++ b/frontend/src/features/admin/labels.ts @@ -29,6 +29,16 @@ function toOptions(map: Record): StatusOption[] { return Object.entries(map).map(([value, meta]) => ({ value, label: meta.label })) } +const accountSourceLabels: Record = { + internal: '站内上传', + external: '外部上传', +} + +export function accountSourceLabel(value?: string | null) { + if (!value) return '—' + return accountSourceLabels[value] ?? value +} + export const orderStatusMap: Record = { pending_payment: { label: '待支付', tone: 'gold' }, pending_handoff: { label: '待交接', tone: 'blue' }, @@ -109,6 +119,15 @@ export const refundStatusMap: Record = { failed: { label: '退款失败', tone: 'red' }, } +export const paymentStatusMap: Record = { + 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, value?: string | null): StatusMeta { if (!value) return { label: '—', tone: 'default' } return map[value] ?? { label: value, tone: 'default' } diff --git a/frontend/src/features/admin/listingDetailModel.ts b/frontend/src/features/admin/listingDetailModel.ts new file mode 100644 index 0000000..1a86424 --- /dev/null +++ b/frontend/src/features/admin/listingDetailModel.ts @@ -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 { + return typeof value === 'object' && value !== null ? (value as Record) : {} +} + +export type ListingRow = { label: string; value: string } + +export type ListingScreenshotGroup = { key: string; label: string; urls: string[] } + +const screenshotGroupLabels: Record = { + 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) { + 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, + } +} diff --git a/frontend/src/features/admin/listingDisplay.ts b/frontend/src/features/admin/listingDisplay.ts index 5369dda..a2fe956 100644 --- a/frontend/src/features/admin/listingDisplay.ts +++ b/frontend/src/features/admin/listingDisplay.ts @@ -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)) +} diff --git a/frontend/src/features/admin/orderDetailModel.ts b/frontend/src/features/admin/orderDetailModel.ts new file mode 100644 index 0000000..15a0a5b --- /dev/null +++ b/frontend/src/features/admin/orderDetailModel.ts @@ -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 { + 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) : {} +} + +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() +} diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index f1c29eb..2f58385 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -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 | null + attachment_urls?: string[] + state_before?: Record | null + state_after?: Record | 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 diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index 0f6a44b..dca42df 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -6,6 +6,7 @@ import { Breadcrumb, Button, Dropdown, + Grid, Layout, Menu, Tag, @@ -462,6 +463,15 @@ const MENU_ALIASES: Record = { '/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 = { 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([]) 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) } }} /> diff --git a/frontend/src/shared/api/adminClient.ts b/frontend/src/shared/api/adminClient.ts index 876b61e..66c04b9 100644 --- a/frontend/src/shared/api/adminClient.ts +++ b/frontend/src/shared/api/adminClient.ts @@ -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() } diff --git a/frontend/src/styles/admin-listing-detail.css b/frontend/src/styles/admin-listing-detail.css new file mode 100644 index 0000000..b5c8c3d --- /dev/null +++ b/frontend/src/styles/admin-listing-detail.css @@ -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); + } +} diff --git a/frontend/src/styles/admin-order-detail.css b/frontend/src/styles/admin-order-detail.css new file mode 100644 index 0000000..d794230 --- /dev/null +++ b/frontend/src/styles/admin-order-detail.css @@ -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); + } +} diff --git a/frontend/src/styles/admin-shell.css b/frontend/src/styles/admin-shell.css index 97005c7..92ca84a 100644 --- a/frontend/src/styles/admin-shell.css +++ b/frontend/src/styles/admin-shell.css @@ -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; + } +} diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css index b2d2680..299bd58 100644 --- a/frontend/src/styles/global.css +++ b/frontend/src/styles/global.css @@ -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';