From ab5dac51f7190e72b8b087f23dc4b8ff7f05f93a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 13:19:26 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E7=94=B3=E8=AF=89=E5=88=97=E8=A1=A8=E4=B8=8E=E4=BB=B2?= =?UTF-8?q?=E8=A3=81=E6=8F=90=E4=BA=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase1-验收报告.md | 4 +- docs/Phase2-申诉仲裁验收.md | 82 ++++ docs/项目计划.md | 2 +- frontend/src/app/AppRouter.tsx | 7 +- .../admin/AdminDisputeDetailDrawer.tsx | 442 ++++++++++++++++++ .../src/features/admin/AdminDisputesPage.tsx | 275 +++++++++++ frontend/src/features/admin/api.ts | 33 ++ frontend/src/features/admin/types.ts | 53 +++ frontend/src/features/disputes/labels.ts | 75 +++ 9 files changed, 969 insertions(+), 4 deletions(-) create mode 100644 docs/Phase2-申诉仲裁验收.md create mode 100644 frontend/src/features/admin/AdminDisputeDetailDrawer.tsx create mode 100644 frontend/src/features/admin/AdminDisputesPage.tsx create mode 100644 frontend/src/features/disputes/labels.ts 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 ? ( + + ) : null} + + + ) : ( + + ) + } + /> + + void submit()} + onCancel={closeModal} + width={560} + > + {dispute ? ( + + 订单 {dispute.order_no},申诉 #{dispute.id}。裁决会立即影响订单、钱包与商品状态,请核对后提交。 + + ) : null} + + +
+ 裁决结果 + patchFilter({ keyword: event.target.value })} + /> + + + patchFilter({ type: value ?? '' })} + /> + + + + {isError ? ( + void refetch()}> + 重试 + + } + /> + ) : null} + + + title={申诉列表} + extra={ + + {data ? `第 ${data.page} 页 · 当前 ${data.items.length} 条` : '加载中'} + + } + 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 ? ( + setDetailOpen(false)} + onUpdated={updated => { + setActiveDispute(updated) + void refetch() + }} + /> + ) : null} +
+ ) +} 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 + ) +}