From 17b985a4a459b7f23d72a99cdd359d680066c7b1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 12:36:02 +0800 Subject: [PATCH] =?UTF-8?q?feat(admin):=20=E6=96=B0=E5=A2=9E=E5=90=8E?= =?UTF-8?q?=E5=8F=B0=E8=AE=A2=E5=8D=95=E5=88=97=E8=A1=A8=E4=B8=8E=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E6=8A=BD=E5=B1=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase1-验收报告.md | 6 +- docs/Phase2-订单管理验收.md | 92 +++++ docs/项目计划.md | 2 + frontend/src/app/AppRouter.tsx | 7 +- .../features/admin/AdminOrderDetailDrawer.tsx | 306 ++++++++++++++++ .../src/features/admin/AdminOrdersPage.tsx | 341 ++++++++++++++++++ frontend/src/features/admin/api.ts | 26 ++ .../admin/components/AdminPageHeader.tsx | 37 ++ .../features/admin/components/DataTable.tsx | 77 ++++ .../admin/components/DetailDrawer.tsx | 111 ++++++ .../features/admin/components/FilterBar.tsx | 57 +++ .../features/admin/components/StatusTag.tsx | 19 + frontend/src/features/admin/labels.ts | 157 ++++++++ frontend/src/features/admin/types.ts | 146 ++++++++ frontend/src/layouts/AdminLayout.tsx | 4 +- frontend/src/shared/utils/format.ts | 41 +++ frontend/src/styles/global.css | 233 ++++++++++++ 17 files changed, 1656 insertions(+), 6 deletions(-) create mode 100644 docs/Phase2-订单管理验收.md create mode 100644 frontend/src/features/admin/AdminOrderDetailDrawer.tsx create mode 100644 frontend/src/features/admin/AdminOrdersPage.tsx create mode 100644 frontend/src/features/admin/components/AdminPageHeader.tsx create mode 100644 frontend/src/features/admin/components/DataTable.tsx create mode 100644 frontend/src/features/admin/components/DetailDrawer.tsx create mode 100644 frontend/src/features/admin/components/FilterBar.tsx create mode 100644 frontend/src/features/admin/components/StatusTag.tsx create mode 100644 frontend/src/features/admin/labels.ts create mode 100644 frontend/src/shared/utils/format.ts diff --git a/docs/Phase1-验收报告.md b/docs/Phase1-验收报告.md index 49130dd..e5de562 100644 --- a/docs/Phase1-验收报告.md +++ b/docs/Phase1-验收报告.md @@ -182,12 +182,12 @@ dist/assets/MobileListingDetailPage-*.js 27.31 kB │ gzip 10.70 kB ## 5. 已知限制 1. 移动端订单与消息页仍是占位,下单、客服会话尚未接入。 -2. 后台 43 个页面目前只有仪表盘是真实页面,其余为统一占位。 +2. 后台除仪表盘、订单列表与订单详情外,其余页面仍为统一占位。 3. 分销后台尚未做品牌轻改。 -4. 后台菜单权限码基于旧项目推断,后续需与后端权限表逐一核对。 +4. 后台菜单权限码基于旧项目推断:订单页已核对为 `order:list`(列表)与 `order:view`(详情),其余页面仍需逐一核对。 5. SVG 中文尚未转曲。 6. 首屏包体仍偏大。 -7. 本移动端批次尚未提交 Git。 +7. 移动端批次已提交为 `ef01486`;后台订单列表与详情在后续批次提交。 --- diff --git a/docs/Phase2-订单管理验收.md b/docs/Phase2-订单管理验收.md new file mode 100644 index 0000000..7e418c5 --- /dev/null +++ b/docs/Phase2-订单管理验收.md @@ -0,0 +1,92 @@ +# Phase 2 验收报告(后台订单管理) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:后台订单列表、订单详情抽屉、通用列表/详情原型 +> 结论:**本切片通过,可继续迁移其它后台列表页** + +--- + +## 1. 本批次完成内容 + +### 1.1 接口与类型 + +- `fetchAdminOrders(query)` → `GET /api/admin/orders` + - 支持 `page`、`page_size`、`keyword`、`status`、`handoff_status`、`handoff_mode`、`settlement_status`、`offline_settlement_status`、`user_id` + - 空字符串参数自动剔除,避免后端收到无效筛选 +- `fetchAdminOrder(id)` → `GET /api/admin/orders/:id` +- 新增列表 `AdminOrderListItem`、分页 `AdminOrderPage`、详情 `AdminOrderDetail`、结账 `AdminCheckoutInfo`、操作位 `AdminOrderActions` 等类型 +- 列表只使用列表 DTO;详情字段只从详情接口读取,符合后端“最小暴露”设计 + +### 1.2 权限码核对 + +| 入口 | 后端实际权限码 | 前端菜单 | +| --- | --- | --- | +| 订单列表 | `order:list` | 已从推断值 `order:view` 修正为 `order:list` | +| 订单详情 | `order:view` | 无 `order:view` 时隐藏详情入口与行点击,操作列显示“无详情权限” | + +### 1.3 通用组件原型 + +位于 `frontend/src/features/admin/components/`,供后续后台页面复用: + +- `AdminPageHeader`:眉标 + 标题 + 描述 + 标签 + 右侧操作 +- `FilterBar` / `FilterField`:可换行筛选区,统一查询/重置按钮与回车提交 +- `DataTable`:卡片容器、空态、分页文案、行点击样式、横向滚动 +- `DetailDrawer`:抽屉标题区、状态标签、分段 `Descriptions`、加载/错误/空态 +- `StatusTag`:统一状态标签外观 + +配套: + +- `frontend/src/features/admin/labels.ts`:订单、交接、结算、线下结算、结账、退款状态文案与色调 +- `frontend/src/shared/utils/format.ts`:金额(分→元)、日期时间、数字、文本空值 + +### 1.4 订单列表页 + +`frontend/src/features/admin/AdminOrdersPage.tsx` + +- 头部:订单总数、用户筛选提示、刷新按钮 +- 筛选:关键词、订单状态、交接状态、订单类型、结算状态、线下结算 +- 列:订单信息、商品、租客/号主、金额、订单状态、交接、结算、操作 +- 分页:10 / 20 / 50,切换页长自动回到第一页 +- 支持 `?keyword=` 与 `?user_id=` 深链(为后续用户管理“明细”入口准备) +- 加载使用 `keepPreviousData`,翻页不闪空表 + +### 1.5 订单详情抽屉 + +`frontend/src/features/admin/AdminOrderDetailDrawer.tsx` + +- 标题区展示订单号、商品标题、创建时间、订单/交接/结算三个状态标签 +- 分段:订单概览、商品与账号、交易双方、金额明细、交接与结算 +- 存在结账单时展示协商轮次、消耗品、哈夫币、押金扣除、租客可退、号主到手、短收/超收、结账内容与凭证 +- 存在进行中申诉时展示申诉单信息 +- 支持复制订单号、加载骨架、错误重试 + +## 2. 验证结果 + +```text +npx tsc --noEmit 通过 +npm run build 通过(主包 887.21 kB,gzip 289.49 kB,仍超 700 kB 警告) +GET /console-jy8k3m/orders 200(Vite SPA 回退) +GET /console-jy8k3m/login 200 +GET /api/admin/orders(未登录) 401 +GET /api/admin/orders(管理员 Cookie) ok,返回 items/total/page/page_size +GET /api/admin/orders?status=renting&handoff_status=received&... ok +GET /api/admin/orders/1(不存在) 404 {"code":"not_found","message":"订单不存在"} +``` + +说明:当前开发库 `rental_orders` 为 0 条,列表验证以空态与接口契约为准;首次产生真实订单后需目视复核列宽与抽屉信息密度。 + +## 3. 已知限制 + +1. 本批次只读,不包含关闭、退款、强制交接、线下结算等操作按钮;后端操作接口已存在,留待 Phase 4 逐项接入并补齐二次确认与权限校验。 +2. 详情抽屉未加载交接记录时间线与过程事件,后续可复用 `GET /api/admin/orders/:id/handoff-records` 与 `/process-events`。 +3. 列表未做列显隐、导出与批量选择。 +4. 主包体积仍未达标。 + +## 4. 下一步建议 + +1. 后台商品管理列表 + 商品审核,复用本批 `DataTable` / `FilterBar` / `DetailDrawer`。 +2. 用户管理列表与用户详情,接通订单深链 `orders?user_id=`。 +3. 交接记录与过程事件时间线组件。 +4. 移动端 `/m/orders` 与 `/m/messages` 真实接口。 +5. 分销后台品牌轻改与 AntD 首包拆分。 diff --git a/docs/项目计划.md b/docs/项目计划.md index ce2697a..5991543 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -365,6 +365,8 @@ hfb_jysh/ ### Phase 2:前端基础设施(1 周) +> 状态:**进行中(2026-10-30)**。已完成双认证、401 单飞刷新、路由守卫与分站头;通用 `DataTable` / `FilterBar` / `DetailDrawer` / `StatusTag` / `AdminPageHeader` 原型已随后台订单页落地。 + - Axios 客户端、响应解包、错误提示、请求日志。 - 用户 Bearer 与管理员 Cookie 双认证。 - 401 单飞刷新队列。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 4a10760..d2f9263 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -48,6 +48,11 @@ const AdminDashboardPage = lazy(() => default: module.AdminDashboardPage, })) ) +const AdminOrdersPage = lazy(() => + import('@/features/admin/AdminOrdersPage').then(module => ({ + default: module.AdminOrdersPage, + })) +) const AdminLayout = lazy(() => import('@/layouts/AdminLayout').then(module => ({ default: module.AdminLayout })) ) @@ -56,7 +61,6 @@ const AdminSectionPlaceholder = lazy(() => ) const adminSections: Array<{ path: string; title: string; description: string }> = [ - { path: 'orders', title: '订单管理', description: '查看全量订单、交接记录与结账状态。' }, { path: 'orders/refund-review', title: '退款审核', description: '审核退款申请并核对资金影响。' }, { path: 'disputes', title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' }, { path: 'listings', title: '商品管理', description: '查看商品、账号资产与上下架状态。' }, @@ -113,6 +117,7 @@ export function AppRouter() { } /> }> } /> + } /> {adminSections.map(section => ( void +} + +function buildSections(order: AdminOrderDetail): DetailSection[] { + const sections: DetailSection[] = [ + { + key: 'overview', + title: '订单概览', + fields: [ + { key: 'order_no', label: '订单号', value: {order.order_no} }, + { + key: 'status', + label: '订单状态', + value: , + }, + { key: 'created_at', label: '创建时间', value: formatDateTime(order.created_at) }, + { key: 'updated_at', label: '更新时间', value: formatDateTime(order.updated_at) }, + { key: 'payment_deadline', label: '支付截止', value: formatDateTime(order.payment_deadline_at) }, + { key: 'listing_no', label: '商品编号', value: formatText(order.listing_no) }, + ], + }, + { + key: 'account', + title: '商品与账号', + fields: [ + { key: 'title', label: '商品标题', value: formatText(order.title), span: 2 }, + { key: 'server_region', label: '区服', value: formatText(order.server_region) }, + { key: 'login_platform', label: '登录平台', value: formatText(order.login_platform) }, + { key: 'account_source', label: '账号来源', value: formatText(order.account_source) }, + { + key: 'duration', + label: '预计租期', + value: order.estimated_duration_hours ? `${order.estimated_duration_hours} 小时` : emptyText, + }, + { key: 'estimated_end', label: '预计结束', value: formatDateTime(order.estimated_end_at) }, + { key: 'rented_at', label: '起租时间', value: formatDateTime(order.rented_at) }, + { + key: 'distribution_site', + label: '来源分站', + value: order.distribution_site_id ? `#${order.distribution_site_id}` : '主站', + }, + ], + }, + { + key: 'parties', + title: '交易双方', + fields: [ + { + key: 'owner', + label: '号主', + value: ( + + {formatText(order.owner_phone)} + ID {order.owner_id} + + ), + }, + { + key: 'renter', + label: '租客', + value: ( + + {formatText(order.renter_phone)} + ID {order.renter_id} + + ), + }, + { key: 'track_code', label: '来源追踪码', value: formatText(order.source_track_code) }, + { key: 'account_id', label: '账号 ID', value: formatText(order.account_id) }, + ], + }, + { + key: 'amounts', + title: '金额明细', + fields: [ + { key: 'display', label: '展示金额', value: formatMoney(order.display_amount_cent) }, + { key: 'rent', label: '租客租金', value: formatMoney(order.rent_amount_cent) }, + { key: 'owner_rent', label: '号主租金', value: formatMoney(order.owner_rent_amount_cent) }, + { key: 'platform_fee', label: '平台服务费', value: formatMoney(order.platform_fee_cent) }, + { key: 'deposit', label: '实收押金', value: formatMoney(order.deposit_amount_cent) }, + { key: 'deposit_original', label: '原始押金', value: formatMoney(order.deposit_original_amount_cent) }, + { key: 'deposit_waived', label: '减免押金', value: formatMoney(order.deposit_waived_amount_cent) }, + { + key: 'refund', + label: '退款', + value: ( + + + {formatMoney(order.refund_amount_cent)} + + ), + }, + ], + }, + { + key: 'fulfillment', + title: '交接与结算', + fields: [ + { + key: 'handoff_mode', + label: '订单类型', + value: , + }, + { key: 'handoff_status', label: '交接状态', value: }, + { + key: 'settlement_mode', + label: '结算方式', + value: , + }, + { + key: 'settlement_status', + label: '结算状态', + value: , + }, + { + key: 'offline_status', + label: '线下结算', + value: ( + + ), + }, + { key: 'offline_amount', label: '线下结算金额', value: formatMoney(order.offline_settlement_amount_cent) }, + { key: 'offline_remark', label: '线下结算备注', value: formatText(order.offline_settlement_remark), span: 2 }, + { key: 'offline_at', label: '线下结算时间', value: formatDateTime(order.offline_settled_at) }, + { + key: 'deposit_hold', + label: '押金冻结', + value: ( + + {formatText(order.deposit_hold_status)} + {formatMoney(order.deposit_hold_amount_cent)} + + ), + }, + { key: 'deposit_hold_reason', label: '冻结原因', value: formatText(order.deposit_hold_reason), span: 2 }, + ], + }, + ] + + if (order.checkout) { + const checkout = order.checkout + sections.push({ + key: 'checkout', + title: '结账单', + fields: [ + { + key: 'checkout_status', + label: '结账状态', + value: , + }, + { + key: 'checkout_round', + label: '协商轮次', + value: `${checkout.round_count} / ${checkout.max_rounds}`, + }, + { key: 'checkout_consumable', label: '消耗品费用', value: formatMoney(checkout.consumable_amount_cent) }, + { key: 'checkout_coin', label: '哈夫币消耗', value: `${checkout.coin_consumed_m} M` }, + { key: 'checkout_other', label: '其他费用', value: formatMoney(checkout.other_amount_cent) }, + { key: 'checkout_deduct', label: '押金扣除', value: formatMoney(checkout.deposit_deduct_amount_cent) }, + { key: 'checkout_renter_refund', label: '租客可退', value: formatMoney(checkout.renter_refund_amount_cent) }, + { key: 'checkout_owner_income', label: '号主到手', value: formatMoney(checkout.owner_income_amount_cent) }, + { + key: 'checkout_shortfall', + label: '差额', + value: ( + + 短收 {formatMoney(checkout.shortfall_cent)} + 超收 {formatMoney(checkout.overshoot_amount_cent)} + + ), + }, + { key: 'checkout_time', label: '最近更新', value: formatDateTime(checkout.updated_at) }, + { + key: 'checkout_content', + label: '结账内容', + span: 2, + value:
{checkout.content || emptyText}
, + }, + { + key: 'checkout_evidence', + label: '凭证', + span: 2, + value: + (checkout.evidence_urls?.length ?? 0) > 0 ? ( + + {checkout.evidence_urls.map((url, index) => ( + + 凭证 {index + 1} + + ))} + + ) : ( + emptyText + ), + }, + ], + }) + } + + if (order.active_dispute) { + sections.push({ + key: 'dispute', + title: '进行中的申诉', + fields: [ + { key: 'dispute_id', label: '申诉单号', value: `#${order.active_dispute.id}` }, + { key: 'dispute_type', label: '申诉类型', value: formatText(order.active_dispute.type) }, + { key: 'dispute_status', label: '状态', value: formatText(order.active_dispute.status) }, + { + key: 'dispute_initiator', + label: '发起方', + value: `${order.active_dispute.initiator_type} · ${order.active_dispute.initiator_id}`, + }, + ], + }) + } + + return sections +} + +export function AdminOrderDetailDrawer({ orderId, open, onClose }: AdminOrderDetailDrawerProps) { + const query = useQuery({ + queryKey: ['admin-order', orderId], + queryFn: () => fetchAdminOrder(orderId as number), + enabled: open && Boolean(orderId), + staleTime: 15_000, + }) + + const order = query.data + const errorMessage = query.error instanceof Error ? query.error.message : null + + const copyOrderNo = async () => { + if (!order) return + try { + await navigator.clipboard.writeText(order.order_no) + message.success('订单号已复制') + } catch { + message.warning('当前环境不支持剪贴板,请手动复制') + } + } + + return ( + void query.refetch()} + title={order?.order_no ? {order.order_no} : '订单详情'} + subtitle={ + order ? ( + + {order.title} · 创建于 {dayjs(order.created_at).format('YYYY-MM-DD HH:mm')} + + ) : null + } + tags={ + order ? ( + + + + + + ) : null + } + sections={order ? buildSections(order) : []} + footer={ + + + + + } + /> + ) +} diff --git a/frontend/src/features/admin/AdminOrdersPage.tsx b/frontend/src/features/admin/AdminOrdersPage.tsx new file mode 100644 index 0000000..bc805df --- /dev/null +++ b/frontend/src/features/admin/AdminOrdersPage.tsx @@ -0,0 +1,341 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery } from '@tanstack/react-query' +import { Alert, Button, Input, Select, Space, Tag, Typography, type TableColumnsType } from 'antd' +import { EyeOutlined, ReloadOutlined } from '@ant-design/icons' +import { useSearchParams } from 'react-router-dom' + +import { fetchAdminOrders } from '@/features/admin/api' +import { AdminOrderDetailDrawer } from '@/features/admin/AdminOrderDetailDrawer' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import { StatusTag } from '@/features/admin/components/StatusTag' +import { + handoffModeOptions, + handoffStatusMeta, + handoffStatusOptions, + offlineSettlementStatusOptions, + orderStatusMap, + orderStatusOptions, + readStatusMeta, + settlementStatusMap, + settlementStatusOptions, +} from '@/features/admin/labels' +import type { AdminOrderListItem, AdminOrderQuery } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +const emptyFilters: AdminOrderQuery = { + keyword: '', + status: '', + handoff_status: '', + handoff_mode: '', + settlement_status: '', + offline_settlement_status: '', +} + +function buildColumns( + onOpen: (row: AdminOrderListItem) => void, + canViewDetail: boolean +): TableColumnsType { + return [ + { + title: '订单', + dataIndex: 'order_no', + width: 200, + fixed: 'left', + render: (_, row) => ( +
+ {canViewDetail ? ( + + ) : ( + {row.order_no} + )} + {formatDateTime(row.created_at)} + 商品 {formatText(row.listing_no || row.listing_id)} +
+ ), + }, + { + title: '商品', + dataIndex: 'title', + width: 210, + render: (_, row) => ( +
+ {formatText(row.title)} + + {formatText(row.server_region)} · {formatText(row.login_platform)} + +
+ ), + }, + { + title: '租客 / 号主', + key: 'parties', + width: 190, + render: (_, row) => ( +
+ 租客 {formatText(row.renter_phone)} + {row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' ? ( + 号主 平台代管 + ) : ( + 号主 {formatText(row.owner_phone)} + )} +
+ ), + }, + { + title: '金额', + key: 'amount', + width: 150, + align: 'right', + render: (_, row) => ( +
+ {formatMoney(row.rent_amount_cent)} + 押金 {formatMoney(row.deposit_amount_cent)} + {typeof row.owner_income_amount_cent === 'number' ? ( + + 到手 {formatMoney(row.owner_income_amount_cent)} + + ) : null} +
+ ), + }, + { + title: '订单状态', + dataIndex: 'status', + width: 170, + render: value => , + }, + { + title: '交接', + key: 'handoff', + width: 180, + render: (_, row) => ( +
+ + {row.handoff_mode === 'platform' || row.settlement_mode === 'platform_managed' ? ( + 平台代管 + ) : null} +
+ ), + }, + { + title: '结算', + dataIndex: 'settlement_status', + width: 140, + render: value => , + }, + { + title: '操作', + key: 'actions', + width: 96, + fixed: 'right', + render: (_, row) => + canViewDetail ? ( + + ) : ( + 无详情权限 + ), + }, + ] +} + +export function AdminOrdersPage() { + const [searchParams] = useSearchParams() + const initialKeyword = searchParams.get('keyword')?.trim() ?? '' + const initialUserId = Number(searchParams.get('user_id')) || undefined + + const [draft, setDraft] = useState(() => ({ + ...emptyFilters, + keyword: initialKeyword, + user_id: initialUserId, + })) + const [applied, setApplied] = useState(() => ({ + ...emptyFilters, + keyword: initialKeyword, + user_id: initialUserId, + })) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [detailId, setDetailId] = useState(null) + const [detailOpen, setDetailOpen] = useState(false) + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canViewDetail = Boolean(permissions?.includes('*') || permissions?.includes('order:view')) + + const { data, isPending, isFetching, isError, error, refetch } = useQuery({ + queryKey: ['admin-orders', applied, page, pageSize], + queryFn: () => fetchAdminOrders({ ...applied, page, page_size: pageSize }), + placeholderData: keepPreviousData, + }) + + const columns = useMemo( + () => + buildColumns(row => { + setDetailId(row.id) + setDetailOpen(true) + }, canViewDetail), + [canViewDetail] + ) + + 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 = Object.entries(draft).some( + ([, value]) => value !== '' && value != null + ) + + const errorText = error instanceof Error ? error.message : '请稍后重试或重新登录后台。' + + return ( +
+ + 共 {data?.total ?? 0} 单 + {applied.user_id ? ( + 筛选用户 #{applied.user_id} + ) : null} + {isFetching && !isPending ? 正在更新 : null} + + } + extra={ + + } + /> + + + + patchFilter({ keyword: event.target.value })} + /> + + + patchFilter({ handoff_status: value ?? '' })} + /> + + + patchFilter({ settlement_status: value ?? '' })} + /> + + +