feat(admin): 新增后台申诉列表与仲裁提交
This commit is contained in:
+2
-2
@@ -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. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。
|
||||
|
||||
@@ -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
@@ -395,7 +395,7 @@ hfb_jysh/
|
||||
|
||||
### Phase 4:管理后台核心(4–5 周)
|
||||
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作;申诉与系统配置待迁移。
|
||||
> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁;财务看板、系统配置与审计日志待迁移。
|
||||
|
||||
- 后台登录、工作台、订单列表、订单详情。
|
||||
- 商品列表、商品详情、审核。
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user