diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md
index 5011ddb..ba9a345 100644
--- a/docs/Phase1-验收报告.md
+++ b/docs/Phase1-验收报告.md
@@ -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. 移动端、订单、分销轻改与首屏优化均已提交;商品管理与审核在本批次提交。
diff --git a/docs/Phase2-申诉仲裁验收.md b/docs/Phase2-申诉仲裁验收.md
new file mode 100644
index 0000000..3af82c2
--- /dev/null
+++ b/docs/Phase2-申诉仲裁验收.md
@@ -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 与附件上传。
diff --git a/docs/项目计划.md b/docs/项目计划.md
index e75b30e..ce9285b 100644
--- a/docs/项目计划.md
+++ b/docs/项目计划.md
@@ -395,7 +395,7 @@ hfb_jysh/
### Phase 4:管理后台核心(4–5 周)
-> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作;申诉与系统配置待迁移。
+> 状态:**进行中(2026-10-30)**。已完成仪表盘、订单列表/详情、退款审核、提现审核、商品列表/详情与审核、用户列表/详情与人工操作、申诉仲裁;财务看板、系统配置与审计日志待迁移。
- 后台登录、工作台、订单列表、订单详情。
- 商品列表、商品详情、审核。
diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx
index 58d7006..0aa8aac 100644
--- a/frontend/src/app/AppRouter.tsx
+++ b/frontend/src/app/AppRouter.tsx
@@ -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() {
} />
} />
} />
+ } />
{adminSections.map(section => (
,
+ },
+ { 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: {row.order_no},
+ },
+ { key: 'title', label: '商品标题', value: formatText(row.title), span: 2 },
+ { key: 'listing_no', label: '商品编号', value: formatText(row.listing_no) },
+ {
+ key: 'order_status',
+ label: '订单状态',
+ value: ,
+ },
+ {
+ key: 'handoff_status',
+ label: '交接状态',
+ value: ,
+ },
+ {
+ key: 'settlement_status',
+ label: '结算状态',
+ value: ,
+ },
+ {
+ 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:
{row.description || emptyText}
,
+ },
+ ],
+ },
+ ]
+
+ 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: {row.arbitration_remark || emptyText}
,
+ },
+ ],
+ })
+ }
+
+ if (row.evidence_urls && row.evidence_urls.length > 0) {
+ sections.push({
+ key: 'evidence',
+ title: '证据材料',
+ fields: [
+ {
+ key: 'images',
+ label: '证据图片',
+ span: 2,
+ value: (
+
+
+ {row.evidence_urls.map((url, index) => (
+
+ ))}
+
+
+ ),
+ },
+ ],
+ })
+ }
+
+ 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(null)
+ const [coinM, setCoinM] = useState(null)
+ const [consumableYuan, setConsumableYuan] = useState(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 (
+ <>
+
+ {dispute.order_no} · {disputeTypeLabel(dispute.type)} ·{' '}
+ {initiatorLabel(dispute)}
+
+ ) : null
+ }
+ tags={
+ dispute ? (
+
+
+ {disputeTypeLabel(dispute.type)}
+
+ ) : null
+ }
+ sections={dispute ? buildSections(dispute) : []}
+ emptyDescription="请选择一条申诉"
+ footer={
+ dispute ? (
+
+ {canHandle ? (
+ }
+ onClick={openArbitration}
+ >
+ 提交仲裁
+
+ ) : null}
+
+
+ ) : (
+
+ )
+ }
+ />
+
+ void submit()}
+ onCancel={closeModal}
+ width={560}
+ >
+ {dispute ? (
+
+ 订单 {dispute.order_no},申诉 #{dispute.id}。裁决会立即影响订单、钱包与商品状态,请核对后提交。
+
+ ) : null}
+
+
+
+ 裁决结果
+
+
+
+ {showActual ? (
+ <>
+
+ 哈夫币实际消耗(M)
+ setCoinM(typeof value === 'number' ? value : null)}
+ />
+
+
+ 消耗品实际金额
+
+ setConsumableYuan(typeof value === 'number' ? value : null)
+ }
+ />
+
+ >
+ ) : null}
+
+ {showAmount ? (
+
+
+ {result === 'partial_refund'
+ ? '退款给租客金额'
+ : result === 'actual_settlement'
+ ? '押金扣除金额(留空按结账单快照)'
+ : '押金扣除金额(留空按全额押金)'}
+
+ setAmountYuan(typeof value === 'number' ? value : null)}
+ />
+
+ ) : null}
+
+
+ 处理说明(必填)
+ setRemark(event.target.value)}
+ />
+
+
+
+ >
+ )
+}
diff --git a/frontend/src/features/admin/AdminDisputesPage.tsx b/frontend/src/features/admin/AdminDisputesPage.tsx
new file mode 100644
index 0000000..6602252
--- /dev/null
+++ b/frontend/src/features/admin/AdminDisputesPage.tsx
@@ -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 {
+ return [
+ {
+ title: '申诉',
+ dataIndex: 'id',
+ width: 170,
+ fixed: 'left',
+ render: (_, row) => (
+
+ {canView ? (
+
+ ) : (
+ 申诉 #{row.id}
+ )}
+ {disputeTypeLabel(row.type)}
+
+
+ ),
+ },
+ {
+ title: '订单',
+ key: 'order',
+ width: 230,
+ render: (_, row) => (
+
+ {row.order_no}
+ {row.title}
+ 商品 {formatText(row.listing_no)}
+
+
+ ),
+ },
+ {
+ title: '双方',
+ key: 'parties',
+ width: 170,
+ render: (_, row) => (
+
+ 号主 {formatText(row.owner_phone)}
+ 租客 {formatText(row.renter_phone)}
+
+ ),
+ },
+ {
+ title: '发起方',
+ key: 'initiator',
+ width: 130,
+ render: (_, row) => (
+
+ {initiatorLabel(row)}
+ 目标用户 #{row.target_user_id}
+
+ ),
+ },
+ {
+ title: '诉求描述',
+ dataIndex: 'description',
+ width: 260,
+ ellipsis: true,
+ render: value => {formatText(value)},
+ },
+ {
+ title: '提交时间',
+ dataIndex: 'created_at',
+ width: 170,
+ render: value => formatDateTime(value),
+ },
+ {
+ title: '操作',
+ key: 'actions',
+ width: 96,
+ fixed: 'right',
+ render: (_, row) =>
+ canView ? (
+ } onClick={() => onOpen(row)}>
+ 处理
+
+ ) : (
+ 无权限
+ ),
+ },
+ ]
+}
+
+export function AdminDisputesPage() {
+ const [draft, setDraft] = useState(emptyFilters)
+ const [applied, setApplied] = useState(emptyFilters)
+ const [page, setPage] = useState(1)
+ const [pageSize, setPageSize] = useState(20)
+ const [activeDispute, setActiveDispute] = useState(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) => {
+ 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 (
+
+ )
+}
diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts
index e360458..0142fbc 100644
--- a/frontend/src/features/admin/api.ts
+++ b/frontend/src/features/admin/api.ts
@@ -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>>(
+ '/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>(
+ `/admin/disputes/${id}/arbitrate`,
+ payload
+ )
+ return data.data
+}
diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts
index 59ed69e..8573c57 100644
--- a/frontend/src/features/admin/types.ts
+++ b/frontend/src/features/admin/types.ts
@@ -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
+}
diff --git a/frontend/src/features/disputes/labels.ts b/frontend/src/features/disputes/labels.ts
new file mode 100644
index 0000000..21630e7
--- /dev/null
+++ b/frontend/src/features/disputes/labels.ts
@@ -0,0 +1,75 @@
+import type { StatusMeta } from '@/features/admin/labels'
+
+export const disputeStatusMap: Record = {
+ open: { label: '待处理', tone: 'gold' },
+ processing: { label: '处理中', tone: 'blue' },
+ resolved: { label: '已裁决', tone: 'success' },
+ closed: { label: '已关闭', tone: 'default' },
+ cancelled: { label: '已取消', tone: 'default' },
+}
+
+export const disputeTypeMap: Record = {
+ cannot_login: '无法登录',
+ false_description: '描述不符',
+ account_banned: '账号封禁',
+ asset_loss: '资产损失',
+ haf_coin_dispute: '哈夫币争议',
+ handoff_timeout: '交接超时',
+ return_timeout: '归还超时',
+ checkout_amount: '结账金额争议',
+ checkout_dispute: '结账争议',
+}
+
+export const arbitrationResultMap: Record = {
+ 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
+ )
+}