feat(admin): 新增后台申诉列表与仲裁提交

This commit is contained in:
yml2213
2026-10-30 13:19:26 +08:00
parent f2cc2bb857
commit ab5dac51f7
9 changed files with 969 additions and 4 deletions
+2 -2
View File
@@ -194,9 +194,9 @@ dist/assets/AdminLayout-*.js 36.87 kB │ gzip 13.02 kB
## 5. 已知限制
1. 移动端订单列表/详情与消息中心已接入真实接口;下单、支付、交接、归还、结账等操作仍待迁移。
2. 后台已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作,其余页面仍为统一占位。
2. 后台已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁,其余页面仍为统一占位。
3. 分销后台已完成主题、Logo、登录页与 Shell 轻改,页面结构未重写。
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`;其余页面仍需核对。
4. 后台菜单权限码按后端逐个核对:订单 `order:list` / `order:view` / `order:close`,商品 `listing:view` / `listing:approve` / `listing:reject` / `listing:offline`,用户 `user:view` / `user:freeze` / `user:unfreeze` / `user:deposit_free` / `user:growth_points` / `user:manual_realname` / `user:revoke_realname` / `user:wallet_adjust`,提现 `withdrawal:list` / `withdrawal:detail` / `withdrawal:review` / `withdrawal:pay`,申诉 `dispute:view` / `dispute:arbitrate`;其余页面仍需核对。
5. SVG 中文尚未转曲。
6. 入口包已降至 495 kB(gzip 161 kB);AntD 共享基础设施仍在入口,后续可拆独立入口。
7. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。
+82
View File
@@ -0,0 +1,82 @@
# Phase 2 验收报告(后台申诉仲裁)
> 日期:2026-10-30
> 项目:解忧商行(hfb_jysh)
> 范围:`/console-jy8k3m/disputes`
> 结论:**本切片通过,申诉列表、证据查看与仲裁提交已接真实接口**
---
## 1. 接口与权限
| 能力 | 接口 | 权限码 |
| --- | --- | --- |
| 申诉列表 | `GET /api/admin/disputes` | `dispute:view` |
| 提交仲裁 | `POST /api/admin/disputes/:id/arbitrate` | `dispute:arbitrate` |
后端没有后台申诉详情接口,列表 DTO 已包含订单、双方、结账快照与证据,详情抽屉直接使用行数据;仲裁成功后用接口返回值刷新。
## 2. 申诉列表
`frontend/src/features/admin/AdminDisputesPage.tsx`
- 筛选:关键词(订单号 / 商品编号 / 手机号)、申诉状态、申诉类型
- 列:申诉(编号/类型/状态)、订单(单号/标题/商品编号/订单状态)、双方、发起方、诉求描述、提交时间、处理
- 分页 10 / 20 / 50
- 点击编号、行或「处理」打开详情抽屉
- 无 `dispute:view` 时隐藏处理入口
## 3. 详情与证据
`frontend/src/features/admin/AdminDisputeDetailDrawer.tsx`
- 分段:申诉概览、关联订单、交易双方、诉求描述、结账快照、裁决结果、证据材料
- 结账快照展示哈夫币消耗、消耗品金额、押金扣除、结账单状态
- 证据图片支持 AntD 预览;文件接口接受管理员 Cookie
## 4. 仲裁提交
- 仅 `open` / `processing` 且具备 `dispute:arbitrate` 时显示「提交仲裁」
- 裁决结果支持 8 种:
- `actual_settlement` 按实际消耗结算
- `full_refund` 全额退款
- `partial_refund` 部分退款
- `deduct_deposit` 扣押金
- `release_deposit` 释放押金
- `compensate_owner` 赔付号主
- `order_close` 关闭订单
- `mark_abnormal` 标记异常
- 按实际消耗类结果(实际结算/释放押金/扣押金/赔付号主)展示:
- 哈夫币实际消耗(M)
- 消耗品实际金额(元 → 分)
- 金额字段:
- 部分退款:必填,大于 0
- 按实际结算:可选押金扣除,留空使用结账单快照
- 扣押金/赔付号主:可选押金扣除,留空按全额押金
- 处理说明必填,最多 500 字
- 提交成功刷新列表并更新当前抽屉状态
## 5. 验证结果
```text
npx tsc --noEmit 通过
npm run build 通过(入口 496.32 kB,gzip 161.17 kB)
GET /api/admin/disputes?page=1&page_size=5 ok,{"items":[],"total":0}
GET /console-jy8k3m/disputes 200
```
开发库当前没有申诉数据,仲裁未做真实写入验证,避免污染订单与资金数据;8 种结果的请求体与后端金额分支已逐项核对。
## 6. 已知限制
1. 后端无申诉详情接口,抽屉无法独立刷新单条申诉。
2. 仲裁提交前无法预览双方最终到账金额,以后端计算为准。
3. 未做证据上传、批量处理或申诉催办。
4. 结账争议的结账单只展示快照字段,未嵌入完整协商时间线。
## 7. 下一步建议
1. 后台财务看板与资金流水。
2. 后台公告管理与系统配置。
3. 移动端下单、支付与交接/归还/结账。
4. 聊天 SSE 与附件上传。
+1 -1
View File
@@ -395,7 +395,7 @@ hfb_jysh/
### Phase 4:管理后台核心(4–5 周)
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作;申诉与系统配置待迁移。
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁;财务看板、系统配置与审计日志待迁移。
- 后台登录、工作台、订单列表、订单详情。
- 商品列表、商品详情、审核。
+6 -1
View File
@@ -88,6 +88,11 @@ const AdminWithdrawalsPage = lazy(() =>
default: module.AdminWithdrawalsPage,
}))
)
const AdminDisputesPage = lazy(() =>
import('@/features/admin/AdminDisputesPage').then(module => ({
default: module.AdminDisputesPage,
}))
)
const AdminLayout = lazy(() =>
import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout }))
)
@@ -96,7 +101,6 @@ const AdminSectionPlaceholder = lazy(() =>
)
const adminSections: Array<{ path: string; title: string; description: string }> = [
{ path: 'disputes', title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' },
{ path: 'finance', title: '财务看板', description: '查看收入、退款、结算与资金趋势。' },
{ path: 'distribution/sites', title: '分销站点', description: '管理分站、规则、结算与站点钱包。' },
{ path: 'announcements', title: '公告管理', description: '维护主站与分站公告。' },
@@ -139,6 +143,7 @@ export function AppRouter() {
<Route path="listings/review" element={<AdminListingsPage mode="review" />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="withdrawals" element={<AdminWithdrawalsPage />} />
<Route path="disputes" element={<AdminDisputesPage />} />
{adminSections.map(section => (
<Route
key={section.path}
@@ -0,0 +1,442 @@
import { useState } from 'react'
import { App, Button, Image, Input, InputNumber, Modal, Select, Space, Tag, Typography } from 'antd'
import { CheckCircleOutlined } from '@ant-design/icons'
import { arbitrateAdminDispute } from '@/features/admin/api'
import { DetailDrawer, type DetailSection } from '@/features/admin/components/DetailDrawer'
import { StatusTag } from '@/features/admin/components/StatusTag'
import { handoffStatusMeta, orderStatusMap, readStatusMeta, settlementStatusMap } from '@/features/admin/labels'
import {
allowsAmount,
arbitrationResultLabel,
arbitrationResultOptions,
disputeStatusMeta,
disputeTypeLabel,
usesActualCheckout,
} from '@/features/disputes/labels'
import type { AdminDispute } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { emptyText, formatDateTime, formatMoney, formatText } from '@/shared/utils/format'
function initiatorLabel(row: AdminDispute) {
if (row.initiator_type === 'admin') {
return `客服 #${row.initiator_admin_id ?? row.initiator_id}`
}
if (row.initiator_type === 'owner') return `号主 #${row.initiator_id}`
return `租客 #${row.initiator_id}`
}
function buildSections(row: AdminDispute): DetailSection[] {
const sections: DetailSection[] = [
{
key: 'overview',
title: '申诉概览',
fields: [
{ key: 'id', label: '申诉编号', value: `#${row.id}` },
{
key: 'status',
label: '申诉状态',
value: <StatusTag meta={disputeStatusMeta(row.status)} />,
},
{ key: 'type', label: '申诉类型', value: disputeTypeLabel(row.type) },
{ key: 'initiator', label: '发起方', value: initiatorLabel(row) },
{ key: 'target', label: '目标用户', value: `#${row.target_user_id}` },
{ key: 'created', label: '提交时间', value: formatDateTime(row.created_at) },
{ key: 'updated', label: '更新时间', value: formatDateTime(row.updated_at) },
],
},
{
key: 'order',
title: '关联订单',
fields: [
{
key: 'order_no',
label: '订单号',
value: <span className="admin-mono">{row.order_no}</span>,
},
{ key: 'title', label: '商品标题', value: formatText(row.title), span: 2 },
{ key: 'listing_no', label: '商品编号', value: formatText(row.listing_no) },
{
key: 'order_status',
label: '订单状态',
value: <StatusTag meta={readStatusMeta(orderStatusMap, row.order_status)} />,
},
{
key: 'handoff_status',
label: '交接状态',
value: <StatusTag meta={handoffStatusMeta(row.handoff_status, null)} />,
},
{
key: 'settlement_status',
label: '结算状态',
value: <StatusTag meta={readStatusMeta(settlementStatusMap, row.settlement_status)} />,
},
{
key: 'checkout_id',
label: '结账单',
value: row.checkout_id ? `#${row.checkout_id}` : emptyText,
},
],
},
{
key: 'parties',
title: '交易双方',
fields: [
{
key: 'owner',
label: '号主',
value: `${formatText(row.owner_phone)} · UID ${row.owner_id}`,
},
{
key: 'renter',
label: '租客',
value: `${formatText(row.renter_phone)} · UID ${row.renter_id}`,
},
],
},
{
key: 'description',
title: '诉求描述',
fields: [
{
key: 'description',
label: '描述',
span: 2,
value: <div className="admin-detail__pre">{row.description || emptyText}</div>,
},
],
},
]
const hasCheckoutSnapshot =
row.checkout_id != null ||
row.checkout_coin_consumed_m != null ||
row.checkout_consumable_amount_cent != null ||
row.checkout_deposit_deduct_amount_cent != null ||
(row.extra_item_original_amount_cent ?? 0) > 0
if (hasCheckoutSnapshot) {
sections.push({
key: 'checkout',
title: '结账快照',
fields: [
{
key: 'coin',
label: '哈夫币消耗',
value:
row.checkout_coin_consumed_m != null ? `${row.checkout_coin_consumed_m} M` : emptyText,
},
{
key: 'consumable',
label: '消耗品金额',
value: formatMoney(
row.checkout_consumable_amount_cent ?? row.extra_item_original_amount_cent
),
},
{
key: 'deduct',
label: '押金扣除',
value: formatMoney(row.checkout_deposit_deduct_amount_cent),
},
{
key: 'checkout_status',
label: '结账单状态',
value: formatText(row.previous_checkout_status),
},
],
})
}
if (row.arbitration_result || row.handled_at) {
sections.push({
key: 'arbitration',
title: '裁决结果',
fields: [
{ key: 'result', label: '裁决结果', value: arbitrationResultLabel(row.arbitration_result) },
{ key: 'handled_at', label: '裁决时间', value: formatDateTime(row.handled_at) },
{
key: 'remark',
label: '处理说明',
span: 2,
value: <div className="admin-detail__pre">{row.arbitration_remark || emptyText}</div>,
},
],
})
}
if (row.evidence_urls && row.evidence_urls.length > 0) {
sections.push({
key: 'evidence',
title: '证据材料',
fields: [
{
key: 'images',
label: '证据图片',
span: 2,
value: (
<Image.PreviewGroup>
<Space size={8} wrap>
{row.evidence_urls.map((url, index) => (
<Image
key={url}
src={url}
alt={`申诉证据 ${index + 1}`}
width={120}
height={90}
style={{ objectFit: 'cover', borderRadius: 6 }}
/>
))}
</Space>
</Image.PreviewGroup>
),
},
],
})
}
return sections
}
interface AdminDisputeDetailDrawerProps {
dispute: AdminDispute | null
open: boolean
onClose: () => void
onUpdated: (dispute: AdminDispute) => void
}
export function AdminDisputeDetailDrawer({
dispute,
open,
onClose,
onUpdated,
}: AdminDisputeDetailDrawerProps) {
const { message } = App.useApp()
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canArbitrate = Boolean(
permissions?.includes('*') || permissions?.includes('dispute:arbitrate')
)
const [modalOpen, setModalOpen] = useState(false)
const [result, setResult] = useState('actual_settlement')
const [remark, setRemark] = useState('')
const [amountYuan, setAmountYuan] = useState<number | null>(null)
const [coinM, setCoinM] = useState<number | null>(null)
const [consumableYuan, setConsumableYuan] = useState<number | null>(null)
const [submitting, setSubmitting] = useState(false)
const openArbitration = () => {
if (!dispute) return
setResult('actual_settlement')
setRemark('')
setAmountYuan(
dispute.checkout_deposit_deduct_amount_cent != null
? dispute.checkout_deposit_deduct_amount_cent / 100
: null
)
setCoinM(dispute.checkout_coin_consumed_m ?? null)
setConsumableYuan(
(dispute.checkout_consumable_amount_cent ?? dispute.extra_item_original_amount_cent ?? 0) / 100
)
setModalOpen(true)
}
const closeModal = () => {
if (submitting) return
setModalOpen(false)
}
const submit = async () => {
if (!dispute) return
const text = remark.trim()
if (!text) {
message.warning('请填写处理说明')
return
}
if (result === 'partial_refund' && (amountYuan == null || amountYuan <= 0)) {
message.warning('部分退款需要填写大于 0 的退款金额')
return
}
if (usesActualCheckout(result)) {
if (coinM == null || coinM < 0) {
message.warning('请填写有效的哈夫币消耗')
return
}
if (consumableYuan == null || consumableYuan < 0) {
message.warning('请填写有效的消耗品金额')
return
}
}
if (allowsAmount(result) && amountYuan != null && amountYuan < 0) {
message.warning('金额不能为负数')
return
}
setSubmitting(true)
try {
const updated = await arbitrateAdminDispute(dispute.id, {
result,
remark: text,
amount_cent:
allowsAmount(result) && amountYuan != null ? Math.round(amountYuan * 100) : undefined,
actual_coin_consumed_m: usesActualCheckout(result) ? coinM ?? undefined : undefined,
actual_consumable_amount_cent: usesActualCheckout(result)
? Math.round((consumableYuan ?? 0) * 100)
: undefined,
})
message.success('仲裁结果已保存')
setModalOpen(false)
onUpdated(updated)
} catch (error) {
const text =
error && typeof error === 'object' && 'response' in error
? ((error as { response?: { data?: { message?: string } } }).response?.data?.message ??
'仲裁失败')
: error instanceof Error
? error.message
: '仲裁失败'
message.error(text)
} finally {
setSubmitting(false)
}
}
const canHandle = dispute != null && ['open', 'processing'].includes(dispute.status) && canArbitrate
const showAmount = allowsAmount(result)
const showActual = usesActualCheckout(result)
return (
<>
<DetailDrawer
open={open}
onClose={onClose}
title={dispute ? `申诉 #${dispute.id}` : '申诉详情'}
subtitle={
dispute ? (
<span>
{dispute.order_no} · {disputeTypeLabel(dispute.type)} ·{' '}
{initiatorLabel(dispute)}
</span>
) : null
}
tags={
dispute ? (
<Space size={6} wrap>
<StatusTag meta={disputeStatusMeta(dispute.status)} />
<Tag>{disputeTypeLabel(dispute.type)}</Tag>
</Space>
) : null
}
sections={dispute ? buildSections(dispute) : []}
emptyDescription="请选择一条申诉"
footer={
dispute ? (
<Space wrap>
{canHandle ? (
<Button
type="primary"
icon={<CheckCircleOutlined />}
onClick={openArbitration}
>
提交仲裁
</Button>
) : null}
<Button onClick={onClose}>关闭</Button>
</Space>
) : (
<Button onClick={onClose}>关闭</Button>
)
}
/>
<Modal
open={modalOpen}
title="提交仲裁结果"
okText="确认裁决"
cancelText="取消"
confirmLoading={submitting}
okButtonProps={{ danger: true }}
onOk={() => void submit()}
onCancel={closeModal}
width={560}
>
{dispute ? (
<Typography.Paragraph type="secondary">
订单 {dispute.order_no},申诉 #{dispute.id}。裁决会立即影响订单、钱包与商品状态,请核对后提交。
</Typography.Paragraph>
) : null}
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<div>
<Typography.Text type="secondary">裁决结果</Typography.Text>
<Select
style={{ width: '100%', marginTop: 6 }}
value={result}
options={arbitrationResultOptions}
onChange={setResult}
/>
</div>
{showActual ? (
<>
<div>
<Typography.Text type="secondary">哈夫币实际消耗(M)</Typography.Text>
<InputNumber
style={{ width: '100%', marginTop: 6 }}
min={0}
precision={2}
value={coinM}
onChange={value => setCoinM(typeof value === 'number' ? value : null)}
/>
</div>
<div>
<Typography.Text type="secondary">消耗品实际金额</Typography.Text>
<InputNumber
style={{ width: '100%', marginTop: 6 }}
min={0}
precision={2}
value={consumableYuan}
addonBefore="¥"
onChange={value =>
setConsumableYuan(typeof value === 'number' ? value : null)
}
/>
</div>
</>
) : null}
{showAmount ? (
<div>
<Typography.Text type="secondary">
{result === 'partial_refund'
? '退款给租客金额'
: result === 'actual_settlement'
? '押金扣除金额(留空按结账单快照)'
: '押金扣除金额(留空按全额押金)'}
</Typography.Text>
<InputNumber
style={{ width: '100%', marginTop: 6 }}
min={0}
precision={2}
value={amountYuan}
addonBefore="¥"
onChange={value => setAmountYuan(typeof value === 'number' ? value : null)}
/>
</div>
) : null}
<div>
<Typography.Text type="secondary">处理说明(必填)</Typography.Text>
<Input.TextArea
style={{ marginTop: 6 }}
rows={4}
value={remark}
maxLength={500}
showCount
placeholder="写明裁决依据、金额计算方式与后续处理"
onChange={event => setRemark(event.target.value)}
/>
</div>
</Space>
</Modal>
</>
)
}
@@ -0,0 +1,275 @@
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 { fetchAdminDisputes } from '@/features/admin/api'
import { AdminDisputeDetailDrawer } from '@/features/admin/AdminDisputeDetailDrawer'
import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader'
import { DataTable } from '@/features/admin/components/DataTable'
import { FilterBar, FilterField } from '@/features/admin/components/FilterBar'
import { StatusTag } from '@/features/admin/components/StatusTag'
import {
disputeStatusMeta,
disputeStatusOptions,
disputeTypeLabel,
disputeTypeOptions,
} from '@/features/disputes/labels'
import { orderStatusMap, readStatusMeta } from '@/features/admin/labels'
import type { AdminDispute, AdminDisputeQuery } from '@/features/admin/types'
import { useAdminSessionStore } from '@/stores/adminSession'
import { formatDateTime, formatText } from '@/shared/utils/format'
const emptyFilters: AdminDisputeQuery = {
keyword: '',
status: '',
type: '',
}
function initiatorLabel(row: AdminDispute) {
if (row.initiator_type === 'admin') {
return `客服 #${row.initiator_admin_id ?? row.initiator_id}`
}
if (row.initiator_type === 'owner') return `号主 #${row.initiator_id}`
return `租客 #${row.initiator_id}`
}
function buildColumns(
onOpen: (row: AdminDispute) => void,
canView: boolean
): TableColumnsType<AdminDispute> {
return [
{
title: '申诉',
dataIndex: 'id',
width: 170,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
{canView ? (
<button type="button" className="admin-order-link" onClick={() => onOpen(row)}>
申诉 #{row.id}
</button>
) : (
<strong>申诉 #{row.id}</strong>
)}
<span>{disputeTypeLabel(row.type)}</span>
<StatusTag meta={disputeStatusMeta(row.status)} />
</div>
),
},
{
title: '订单',
key: 'order',
width: 230,
render: (_, row) => (
<div className="admin-stack">
<span className="admin-mono">{row.order_no}</span>
<strong>{row.title}</strong>
<span>商品 {formatText(row.listing_no)}</span>
<StatusTag meta={readStatusMeta(orderStatusMap, row.order_status)} />
</div>
),
},
{
title: '双方',
key: 'parties',
width: 170,
render: (_, row) => (
<div className="admin-stack">
<span>号主 {formatText(row.owner_phone)}</span>
<span>租客 {formatText(row.renter_phone)}</span>
</div>
),
},
{
title: '发起方',
key: 'initiator',
width: 130,
render: (_, row) => (
<div className="admin-stack">
<span>{initiatorLabel(row)}</span>
<span>目标用户 #{row.target_user_id}</span>
</div>
),
},
{
title: '诉求描述',
dataIndex: 'description',
width: 260,
ellipsis: true,
render: value => <Typography.Text type="secondary">{formatText(value)}</Typography.Text>,
},
{
title: '提交时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 96,
fixed: 'right',
render: (_, row) =>
canView ? (
<Button type="link" size="small" icon={<EyeOutlined />} onClick={() => onOpen(row)}>
处理
</Button>
) : (
<Typography.Text type="secondary">无权限</Typography.Text>
),
},
]
}
export function AdminDisputesPage() {
const [draft, setDraft] = useState<AdminDisputeQuery>(emptyFilters)
const [applied, setApplied] = useState<AdminDisputeQuery>(emptyFilters)
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(20)
const [activeDispute, setActiveDispute] = useState<AdminDispute | null>(null)
const [detailOpen, setDetailOpen] = useState(false)
const permissions = useAdminSessionStore(state => state.admin?.permissions)
const canView = Boolean(permissions?.includes('*') || permissions?.includes('dispute:view'))
const { data, isPending, isFetching, isError, error, refetch } = useQuery({
queryKey: ['admin-disputes', applied, page, pageSize],
queryFn: () => fetchAdminDisputes({ ...applied, page, page_size: pageSize }),
placeholderData: keepPreviousData,
})
const openDetail = (row: AdminDispute) => {
if (!canView) return
setActiveDispute(row)
setDetailOpen(true)
}
const columns = useMemo(() => buildColumns(openDetail, canView), [canView])
const patchFilter = (patch: Partial<AdminDisputeQuery>) => {
setDraft(current => ({ ...current, ...patch }))
}
const applyFilters = () => {
setApplied({ ...draft, keyword: draft.keyword?.trim() ?? '' })
setPage(1)
}
const resetFilters = () => {
setDraft(emptyFilters)
setApplied(emptyFilters)
setPage(1)
}
const hasActiveFilters =
Boolean(draft.keyword?.trim()) || Boolean(draft.status) || Boolean(draft.type)
const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。'
const pendingCount = (data?.items ?? []).filter(item =>
['open', 'processing'].includes(item.status)
).length
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 交易"
title="申诉仲裁"
description="核对申诉描述、证据与结账快照,给出可追溯的仲裁结果。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {data?.total ?? 0} 条</Tag>
{pendingCount > 0 ? <Tag color="gold">本页待处理 {pendingCount}</Tag> : null}
{isFetching && !isPending ? <Tag color="processing">正在更新</Tag> : null}
</Space>
}
extra={
<Button icon={<ReloadOutlined />} onClick={() => void refetch()} loading={isFetching}>
刷新数据
</Button>
}
/>
<FilterBar
onSearch={applyFilters}
onReset={hasActiveFilters ? resetFilters : undefined}
loading={isFetching}
>
<FilterField label="关键词" width={300}>
<Input
allowClear
value={draft.keyword}
placeholder="订单号 / 商品编号 / 手机号"
onChange={event => patchFilter({ keyword: event.target.value })}
/>
</FilterField>
<FilterField label="申诉状态" width={150}>
<Select
allowClear
value={draft.status || undefined}
placeholder="全部状态"
options={disputeStatusOptions}
onChange={value => patchFilter({ status: value ?? '' })}
/>
</FilterField>
<FilterField label="申诉类型" width={180}>
<Select
allowClear
value={draft.type || undefined}
placeholder="全部类型"
options={disputeTypeOptions}
onChange={value => patchFilter({ type: value ?? '' })}
/>
</FilterField>
</FilterBar>
{isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="申诉列表没有加载出来"
description={errorText}
action={
<Button size="small" onClick={() => void refetch()}>
重试
</Button>
}
/>
) : null}
<DataTable<AdminDispute>
title={<span>申诉列表</span>}
extra={
<Typography.Text type="secondary">
{data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'}
</Typography.Text>
}
rowKey="id"
columns={columns}
dataSource={data?.items ?? []}
loading={isFetching}
total={data?.total ?? 0}
page={data?.page ?? page}
pageSize={data?.page_size ?? pageSize}
onPageChange={(nextPage, nextPageSize) => {
setPage(nextPageSize !== pageSize ? 1 : nextPage)
setPageSize(nextPageSize)
}}
onRowClick={canView ? row => openDetail(row) : undefined}
emptyDescription="没有匹配的申诉"
/>
{canView ? (
<AdminDisputeDetailDrawer
dispute={activeDispute}
open={detailOpen}
onClose={() => setDetailOpen(false)}
onUpdated={updated => {
setActiveDispute(updated)
void refetch()
}}
/>
) : null}
</div>
)
}
+33
View File
@@ -2,6 +2,9 @@ import { adminClient } from '@/shared/api/adminClient'
import type { ApiResponse } from '@/shared/api/types'
import type {
AdminCaptcha,
AdminDispute,
AdminDisputePage,
AdminDisputeQuery,
AdminListing,
AdminListingPage,
AdminListingQuery,
@@ -278,3 +281,33 @@ export async function confirmAdminWithdrawalPayment(
)
return data.data
}
export async function fetchAdminDisputes(query: AdminDisputeQuery) {
const { data } = await adminClient.get<ApiResponse<Partial<AdminDisputePage>>>(
'/admin/disputes',
{ params: compactParams(query) }
)
return {
items: data.data.items ?? [],
total: data.data.total ?? 0,
page: data.data.page ?? query.page ?? 1,
page_size: data.data.page_size ?? query.page_size ?? 20,
}
}
export async function arbitrateAdminDispute(
id: number | string,
payload: {
result: string
remark: string
amount_cent?: number
actual_coin_consumed_m?: number
actual_consumable_amount_cent?: number
}
) {
const { data } = await adminClient.post<ApiResponse<AdminDispute>>(
`/admin/disputes/${id}/arbitrate`,
payload
)
return data.data
}
+53
View File
@@ -351,3 +351,56 @@ export interface AdminWithdrawalPage {
page: number
page_size: number
}
export interface AdminDisputeQuery {
page?: number
page_size?: number
keyword?: string
status?: string
type?: string
}
export interface AdminDispute {
id: number
order_id: number
order_no: string
order_status: string
handoff_status: string
settlement_status: string
extra_item_original_amount_cent?: number
listing_no: string
title: string
owner_id: number
renter_id: number
owner_phone: string
renter_phone: string
initiator_id: number
initiator_type: string
initiator_admin_id?: number | null
target_user_id: number
type: string
status: string
description: string
evidence_urls?: string[] | null
previous_order_status: string
previous_handoff_status: string
previous_settlement_status: string
checkout_id?: number | null
checkout_coin_consumed_m?: number | null
checkout_consumable_amount_cent?: number | null
checkout_deposit_deduct_amount_cent?: number | null
previous_checkout_status: string
arbitration_result: string
arbitration_remark: string
handled_by?: number | null
handled_at?: string | null
created_at: string
updated_at: string
}
export interface AdminDisputePage {
items: AdminDispute[]
total: number
page: number
page_size: number
}
+75
View File
@@ -0,0 +1,75 @@
import type { StatusMeta } from '@/features/admin/labels'
export const disputeStatusMap: Record<string, StatusMeta> = {
open: { label: '待处理', tone: 'gold' },
processing: { label: '处理中', tone: 'blue' },
resolved: { label: '已裁决', tone: 'success' },
closed: { label: '已关闭', tone: 'default' },
cancelled: { label: '已取消', tone: 'default' },
}
export const disputeTypeMap: Record<string, string> = {
cannot_login: '无法登录',
false_description: '描述不符',
account_banned: '账号封禁',
asset_loss: '资产损失',
haf_coin_dispute: '哈夫币争议',
handoff_timeout: '交接超时',
return_timeout: '归还超时',
checkout_amount: '结账金额争议',
checkout_dispute: '结账争议',
}
export const arbitrationResultMap: Record<string, string> = {
actual_settlement: '按实际消耗结算',
full_refund: '全额退款',
partial_refund: '部分退款',
deduct_deposit: '扣押金',
release_deposit: '释放押金',
compensate_owner: '赔付号主',
order_close: '关闭订单',
mark_abnormal: '标记异常',
}
export const disputeStatusOptions = Object.entries(disputeStatusMap).map(([value, meta]) => ({
value,
label: meta.label,
}))
export const disputeTypeOptions = Object.entries(disputeTypeMap).map(([value, label]) => ({
value,
label,
}))
export const arbitrationResultOptions = Object.entries(arbitrationResultMap).map(
([value, label]) => ({ value, label })
)
export function disputeStatusMeta(value?: string | null): StatusMeta {
if (!value) return { label: '—', tone: 'default' }
return disputeStatusMap[value] ?? { label: value, tone: 'default' }
}
export function disputeTypeLabel(value?: string | null) {
if (!value) return '—'
return disputeTypeMap[value] ?? value
}
export function arbitrationResultLabel(value?: string | null) {
if (!value) return '—'
return arbitrationResultMap[value] ?? value
}
/** 这四类结果按实际消耗计算,需要填写哈夫币与消耗品金额。 */
export function usesActualCheckout(result: string) {
return ['actual_settlement', 'release_deposit', 'deduct_deposit', 'compensate_owner'].includes(
result
)
}
/** 这些结果允许填写押金扣除/退款金额。 */
export function allowsAmount(result: string) {
return ['partial_refund', 'actual_settlement', 'deduct_deposit', 'compensate_owner'].includes(
result
)
}