From 030cedb6fd0452e9fcf0653200520c39df1dd6a0 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 30 Oct 2026 16:45:25 +0800 Subject: [PATCH] =?UTF-8?q?feat(pickup):=20=E6=96=B0=E5=A2=9E=E7=BA=BF?= =?UTF-8?q?=E4=B8=8B=E6=8F=90=E5=8F=B7=E5=88=97=E8=A1=A8=E4=B8=8E=E8=AF=A6?= =?UTF-8?q?=E6=83=85=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/Phase2-线下提号验收.md | 88 +++ docs/项目计划.md | 4 +- frontend/src/app/AppRouter.tsx | 12 + .../features/admin/AdminPickupDetailPage.tsx | 605 +++++++++++++++++ .../src/features/admin/AdminPickupsPage.tsx | 623 ++++++++++++++++++ frontend/src/features/admin/api.ts | 159 +++++ frontend/src/features/admin/labels.ts | 24 +- frontend/src/features/admin/types.ts | 140 ++++ frontend/src/layouts/AdminLayout.tsx | 2 + 9 files changed, 1643 insertions(+), 14 deletions(-) create mode 100644 docs/Phase2-线下提号验收.md create mode 100644 frontend/src/features/admin/AdminPickupDetailPage.tsx create mode 100644 frontend/src/features/admin/AdminPickupsPage.tsx diff --git a/docs/Phase2-线下提号验收.md b/docs/Phase2-线下提号验收.md new file mode 100644 index 0000000..ff5cc5f --- /dev/null +++ b/docs/Phase2-线下提号验收.md @@ -0,0 +1,88 @@ +# Phase 2 验收报告(线下提号) + +> 日期:2026-10-30 +> 项目:解忧商行(hfb_jysh) +> 范围:后台线下提号列表、创建、详情与结算动作 +> 结论:**页面与路由已落地,tsc/build 通过,只读接口回归 200;写操作未在验收中执行** + +--- + +## 1. 命名口径 + +`pickup` 模块统一称「线下提号」,与旧站 `AdminLayout.vue` 菜单「线下提号」及 `AdminPickupView` 文案(创建提号 / 完成提号 / 取消提号 / 提号编号 / 提号状态 `picking_up`)一致。 + +本轮同时修正了 `frontend/src/features/admin/labels.ts` 中遗留的错误译法: + +- `pickup: '捡漏订单'` → `线下提号` +- `pickup_shop: '捡漏店铺'` → `提号店铺` +- 审计动作 `pickup_create / pickup_cancel / pickup_complete / pickup_profit_update / pickup_offline_settlement / pickup_financial_adjustment_* / pickup_shop_*` 全部由「捡漏」改为「提号」口径。 + +## 2. 页面与路由 + +| 页面 | 路由 | 文件 | +|---|---|---| +| 线下提号列表 | `/console-jy8k3m/pickup` | `frontend/src/features/admin/AdminPickupsPage.tsx` | +| 线下提号详情 | `/console-jy8k3m/pickup/:id` | `frontend/src/features/admin/AdminPickupDetailPage.tsx` | + +权限:`order:pickup`(列表、创建、完成、取消、结算、利润调整、第三方单号、店铺管理均复用该权限,与后端路由一致)。 + +## 3. 列表页能力 + +- 筛选:关键字(提号/上架编号/账号标题/手机号)、提号状态(提号中/已完成/已取消)、账号来源(站内/外部)、线下结算状态(未结算/待结算/已结算)、操作人、创建时间区间。 +- 列表:提号编号、账号标题与上架编号、来源与店铺、下单/押金/利润金额、状态双标签、操作人与第三方单号、创建时间。 +- 创建提号弹窗: + - 账号来源切换(全部 / 外部上传·平台代管 / 站内上传)驱动 `GET /admin/pickups/available-listings?source_type=`; + - 远程搜索已发布且未在交易的账号; + - 操作人、交易渠道、第三方单号、店铺(AutoComplete 支持手填)、下单金额、押金、线下利润、备注; + - 金额按元录入并 `Math.round(yuan * 100)` 转分提交; + - 创建成功后跳转详情页。 +- 店铺管理弹窗:`GET/POST/DELETE /admin/pickups/shops`,标签式展示与删除,新增后刷新 `shop-options` 下拉。 + +## 4. 详情页能力 + +- 基本信息:提号编号、上架编号、账号标题、区服/登录方式、号主手机、操作人、交易渠道、店铺、第三方单号(可改)、结算模式、创建/完成时间、备注。 +- 金额区:下单金额、押金、站点售价、号主价、线下利润(含原值与调整)、应结算(含原值与调整)。 +- 操作: + - 完成提号 → `POST /admin/pickups/:id/complete`(结算金额、可选利润、备注) + - 取消提号 → `POST /admin/pickups/:id/cancel`(必填原因) + - 标记线下已结算 → `POST /admin/pickups/:id/offline-settlement`(仅 `completed` + `pending` 显示) + - 修改线下利润 → `PUT /admin/pickups/:id/profit` + - 修改第三方单号 → `PUT /admin/pickups/:id/third-party-order-no` + - 新建财务调整 → `POST /admin/pickups/:id/financial-adjustments`(目标利润/目标结算,至少填一项) + - 结算财务调整 → `POST /admin/pickup-financial-adjustments/:id/settle` +- 流程事件:`GET /admin/pickups/:id/process-events` 以 Timeline 展示阶段、动作、内容、操作人与时间。 + +## 5. 菜单接入 + +- `frontend/src/layouts/AdminLayout.tsx` + - 交易组新增「线下提号」→ `/pickup`(`order:pickup`)。 + - `sectionMeta` 补 `/pickup` 标题与描述。 + +## 6. 回归结果 + +```text +前端 http://localhost:5289 +后端 http://127.0.0.1:18089 +Cookie /tmp/jysh-admin-cookie2.txt +``` + +| 检查项 | 状态 | +|---|---| +| `GET /console-jy8k3m/pickup` | 200 | +| `GET /console-jy8k3m/pickup/1` | 200 | +| `GET /api/admin/pickups?page=1&page_size=2` | 200(空列表) | +| `GET /api/admin/pickups/shops` | 200 | +| `GET /api/admin/pickups/shop-options` | 200 | +| `GET /api/admin/pickups/available-listings?page=1&page_size=2` | 200 | + +`npx tsc --noEmit` 通过;`npm run build` 通过,主入口 `498.58 kB` / gzip `156.99 kB`,无超大包告警。 + +## 7. 未覆盖 + +- 创建、完成、取消、线下结算、利润调整、财务调整与店铺增删均未执行写操作(开发库无可用账号与历史提号数据)。 +- 财务调整的金额差值口径(目标金额 vs 差额)仅在类型层面对齐,未用真实数据验证。 +- 提号完成后对号主钱包的实际入账未验证。 + +## 8. 下一步(B2 剩余) + +客服会话工作台 `/chats`、主站二维码池 `/qrcode-pool`、通知 `/notifications`、推送 `/push`、邀请 `/invitations`。 diff --git a/docs/项目计划.md b/docs/项目计划.md index 9753236..cc4a2f9 100644 --- a/docs/项目计划.md +++ b/docs/项目计划.md @@ -413,8 +413,8 @@ hfb_jysh/ - 用户端资金闭环。 - 已落地:`/wallet`(余额 + 流水)、`/wallet/payment-accounts`、`/wallet/withdrawal`、移动端 `/m/wallet/withdrawal`、卖家 `/seller/earnings`、`/seller/handoffs`、`/seller/pickups`。 - 申诉仲裁、手工订单、提号、客服群、快捷回复。 - - 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`),手工录单(`/manual-entries`,权限 `cs_manual_entry:*`),快捷回复(`/chat-quick-replies`,权限 `chat:view` / `chat:send`)。 - - 待落地:无(手工录单与快捷回复已迁移完毕)。 + - 已落地:客服群组(`/chat-support-groups`,权限 `chat:manage`),角色权限矩阵(`/roles`,权限 `role:manage`),手工录单(`/manual-entries`,权限 `cs_manual_entry:*`),快捷回复(`/chat-quick-replies`,权限 `chat:view` / `chat:send`),线下提号(`/pickup`、`/pickup/:id`,权限 `order:pickup`,口径统一为「线下提号」)。 + - 待落地:客服会话工作台 `/chats`、主站二维码池 `/qrcode-pool`。 - 分销站点、规则、订单、结算、二维码池、分销用户、公告。 - 公告、邀请返利、推送、追踪链接。 - 已落地:公告管理、邀请返利、推广追踪(`/tracking-links`,权限 `ad_tracking:manage`)。 diff --git a/frontend/src/app/AppRouter.tsx b/frontend/src/app/AppRouter.tsx index 91bed05..c0af8a8 100644 --- a/frontend/src/app/AppRouter.tsx +++ b/frontend/src/app/AppRouter.tsx @@ -236,6 +236,16 @@ const AdminPaymentsPage = lazy(() => default: module.AdminPaymentsPage, })) ) +const AdminPickupsPage = lazy(() => + import('@/features/admin/AdminPickupsPage').then(module => ({ + default: module.AdminPickupsPage, + })) +) +const AdminPickupDetailPage = lazy(() => + import('@/features/admin/AdminPickupDetailPage').then(module => ({ + default: module.AdminPickupDetailPage, + })) +) const AdminFinanceDetailsPage = lazy(() => import('@/features/admin/AdminFinanceDetailsPage').then(module => ({ default: module.AdminFinanceDetailsPage, @@ -396,6 +406,8 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> } /> } /> } /> diff --git a/frontend/src/features/admin/AdminPickupDetailPage.tsx b/frontend/src/features/admin/AdminPickupDetailPage.tsx new file mode 100644 index 0000000..061529e --- /dev/null +++ b/frontend/src/features/admin/AdminPickupDetailPage.tsx @@ -0,0 +1,605 @@ +import { useState } from 'react' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + Button, + Card, + Descriptions, + Form, + Input, + InputNumber, + Modal, + Space, + Table, + Tag, + Timeline, + Typography, + type TableColumnsType, +} from 'antd' +import { ArrowLeftOutlined, EditOutlined, ReloadOutlined } from '@ant-design/icons' +import { useNavigate, useParams } from 'react-router-dom' + +import { + cancelAdminPickup, + completeAdminPickup, + createAdminPickupFinancialAdjustment, + fetchAdminPickup, + fetchAdminPickupFinancialAdjustments, + fetchAdminPickupProcessEvents, + markAdminPickupOfflineSettlement, + settleAdminPickupFinancialAdjustment, + updateAdminPickupProfit, + updateAdminPickupThirdPartyOrderNo, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import type { AdminPickupFinancialAdjustment } from '@/features/admin/types' +import { offlineSettlementMeta, pickupSourceLabel, pickupStatusMeta } from '@/features/admin/AdminPickupsPage' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +const adjustmentColumns: TableColumnsType = [ + { + title: '调整', + key: 'delta', + width: 200, + render: (_, row) => ( +
+ 利润 {formatMoney(row.profit_delta_cent)} + 结算 {formatMoney(row.settle_delta_cent)} +
+ ), + }, + { + title: '原因', + dataIndex: 'reason', + ellipsis: true, + render: value => formatText(value), + }, + { + title: '状态', + dataIndex: 'status', + width: 110, + render: value => ( + + {value === 'settled' ? '已结算' : '待结算'} + + ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, +] + +export function AdminPickupDetailPage() { + const { id } = useParams() + const pickupId = Number(id) + const navigate = useNavigate() + const { message } = App.useApp() + const queryClient = useQueryClient() + const [completeForm] = Form.useForm<{ settle_amount?: number; profit_amount?: number; remark?: string }>() + const [cancelForm] = Form.useForm<{ reason: string }>() + const [settleForm] = Form.useForm<{ remark?: string }>() + const [profitForm] = Form.useForm<{ profit_amount?: number; reason?: string }>() + const [thirdPartyForm] = Form.useForm<{ third_party_order_no: string }>() + const [adjustForm] = Form.useForm<{ profit_amount?: number; settle_amount?: number; reason: string }>() + const [completeOpen, setCompleteOpen] = useState(false) + const [cancelOpen, setCancelOpen] = useState(false) + const [settleOpen, setSettleOpen] = useState(false) + const [profitOpen, setProfitOpen] = useState(false) + const [thirdPartyOpen, setThirdPartyOpen] = useState(false) + const [adjustOpen, setAdjustOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canManage = Boolean(permissions?.includes('*') || permissions?.includes('order:pickup')) + + const pickupQuery = useQuery({ + queryKey: ['admin-pickup', pickupId], + queryFn: () => fetchAdminPickup(pickupId), + enabled: canManage && Number.isFinite(pickupId) && pickupId > 0, + }) + const adjustmentsQuery = useQuery({ + queryKey: ['admin-pickup-adjustments', pickupId], + queryFn: () => fetchAdminPickupFinancialAdjustments(pickupId), + enabled: canManage && Number.isFinite(pickupId) && pickupId > 0, + }) + const eventsQuery = useQuery({ + queryKey: ['admin-pickup-events', pickupId], + queryFn: () => fetchAdminPickupProcessEvents(pickupId), + enabled: canManage && Number.isFinite(pickupId) && pickupId > 0, + }) + + const pickup = pickupQuery.data + const invalidate = () => + Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-pickup', pickupId] }), + queryClient.invalidateQueries({ queryKey: ['admin-pickup-adjustments', pickupId] }), + queryClient.invalidateQueries({ queryKey: ['admin-pickup-events', pickupId] }), + queryClient.invalidateQueries({ queryKey: ['admin-pickups'] }), + ]) + + const run = async (action: () => Promise, success: string) => { + setSubmitting(true) + try { + await action() + message.success(success) + await invalidate() + return true + } catch (error) { + message.error(errorText(error)) + return false + } finally { + setSubmitting(false) + } + } + + if (!canManage) { + return ( +
+ + +
+ ) + } + + if (pickupQuery.isError) { + return ( +
+ + navigate('/pickup')}>返回列表} + /> +
+ ) + } + + const statusMeta = pickupStatusMeta[pickup?.status ?? ''] ?? { + label: pickup?.status ?? '—', + color: 'default', + } + const settlementMeta = offlineSettlementMeta[pickup?.offline_settlement_status ?? ''] ?? { + label: pickup?.offline_settlement_status ?? '—', + color: 'default', + } + + return ( +
+ + + {statusMeta.label} + + + {settlementMeta.label} + + {pickup ? {pickupSourceLabel(pickup.account_source)} : null} + + } + extra={ + + + + {pickup?.status === 'picking_up' ? ( + <> + + + + ) : null} + {pickup?.status === 'completed' && + pickup.offline_settlement_status === 'pending' ? ( + + ) : null} + + } + /> + + + + {formatText(pickup?.pickup_no)} + {formatText(pickup?.listing_no)} + {formatText(pickup?.account_title)} + + {formatText(pickup?.server_region)} · {formatText(pickup?.login_platform)} + + {formatText(pickup?.owner_phone)} + {formatText(pickup?.operator)} + {formatText(pickup?.platform)} + {formatText(pickup?.shop_name)} + + + {formatText(pickup?.third_party_order_no)} + + + + {formatText(pickup?.settlement_mode)} + {formatDateTime(pickup?.created_at)} + + {pickup?.completed_at ? formatDateTime(pickup.completed_at) : '—'} + + + {formatText(pickup?.remark)} + + + + +
+
+ 下单金额 + {formatMoney(pickup?.order_amount_cent ?? 0)} + 押金 {formatMoney(pickup?.deposit_amount_cent ?? 0)} +
+
+ 站点售价 + {formatMoney(pickup?.listing_price_cent ?? 0)} + 号主价 {formatMoney(pickup?.owner_price_cent ?? 0)} +
+
+ 线下利润 + {formatMoney(pickup?.effective_profit_amount_cent ?? 0)} + + 原 {formatMoney(pickup?.profit_amount_cent ?? 0)} · 调整{' '} + {formatMoney(pickup?.profit_adjustment_cent ?? 0)} + + +
+
+ 应结算金额 + {formatMoney(pickup?.effective_settle_amount_cent ?? 0)} + + 原 {formatMoney(pickup?.settle_amount_cent ?? 0)} · 调整{' '} + {formatMoney(pickup?.settle_adjustment_cent ?? 0)} + +
+
+ + { + adjustForm.setFieldsValue({ reason: '' }) + setAdjustOpen(true) + }} + > + 新建调整 + + } + > + + rowKey="id" + size="small" + columns={[ + ...adjustmentColumns, + { + title: '操作', + key: 'actions', + width: 120, + render: (_, row) => + row.status === 'settled' ? ( + 已处理 + ) : ( + + ), + }, + ]} + dataSource={adjustmentsQuery.data ?? []} + loading={adjustmentsQuery.isFetching} + pagination={false} + locale={{ emptyText: '暂无财务调整' }} + /> + + + + {eventsQuery.data?.length ? ( + ({ + color: event.action?.includes('cancel') ? 'red' : 'green', + children: ( +
+ + {formatText(event.stage)} · {formatText(event.action)} + + {formatText(event.content || event.reason)} + + {formatText(event.actor_name)} · {formatDateTime(event.created_at)} + +
+ ), + }))} + /> + ) : ( + 暂无流程事件 + )} +
+ + (submitting ? undefined : setCompleteOpen(false))} + onOk={async () => { + const values = await completeForm.validateFields() + const ok = await run( + () => + completeAdminPickup(pickupId, { + settle_amount_cent: Math.round(Number(values.settle_amount ?? 0) * 100), + profit_amount_cent: + values.profit_amount == null + ? undefined + : Math.round(Number(values.profit_amount) * 100), + complete_remark: values.remark?.trim(), + }), + '提号已完成' + ) + if (ok) setCompleteOpen(false) + }} + > +
+ + + + + + + + + +
+
+ + (submitting ? undefined : setCancelOpen(false))} + onOk={async () => { + const values = await cancelForm.validateFields() + const ok = await run(() => cancelAdminPickup(pickupId, values.reason.trim()), '提号已取消') + if (ok) setCancelOpen(false) + }} + > +
+ + + +
+
+ + (submitting ? undefined : setSettleOpen(false))} + onOk={async () => { + const values = await settleForm.validateFields() + const ok = await run( + () => markAdminPickupOfflineSettlement(pickupId, values.remark?.trim() ?? ''), + '已标记线下结算' + ) + if (ok) setSettleOpen(false) + }} + > +
+ + + +
+
+ + (submitting ? undefined : setProfitOpen(false))} + onOk={async () => { + const values = await profitForm.validateFields() + const ok = await run( + () => + updateAdminPickupProfit(pickupId, { + profit_amount_cent: Math.round(Number(values.profit_amount ?? 0) * 100), + reason: values.reason?.trim(), + }), + '利润已更新' + ) + if (ok) setProfitOpen(false) + }} + > +
+ + + + + + +
+
+ + (submitting ? undefined : setThirdPartyOpen(false))} + onOk={async () => { + const values = await thirdPartyForm.validateFields() + const ok = await run( + () => updateAdminPickupThirdPartyOrderNo(pickupId, values.third_party_order_no.trim()), + '第三方单号已更新' + ) + if (ok) setThirdPartyOpen(false) + }} + > +
+ + + +
+
+ + (submitting ? undefined : setAdjustOpen(false))} + onOk={async () => { + const values = await adjustForm.validateFields() + const profit = + values.profit_amount == null ? undefined : Math.round(Number(values.profit_amount) * 100) + const settle = + values.settle_amount == null ? undefined : Math.round(Number(values.settle_amount) * 100) + if (profit == null && settle == null) { + message.warning('至少填写利润或结算金额') + return + } + const ok = await run( + () => + createAdminPickupFinancialAdjustment(pickupId, { + profit_amount_cent: profit, + settle_amount_cent: settle, + reason: values.reason.trim(), + }), + '财务调整已创建' + ) + if (ok) setAdjustOpen(false) + }} + > +
+ + + + + + + + + +
+
+
+ ) +} diff --git a/frontend/src/features/admin/AdminPickupsPage.tsx b/frontend/src/features/admin/AdminPickupsPage.tsx new file mode 100644 index 0000000..9396343 --- /dev/null +++ b/frontend/src/features/admin/AdminPickupsPage.tsx @@ -0,0 +1,623 @@ +import { useMemo, useState } from 'react' +import { keepPreviousData, useQuery, useQueryClient } from '@tanstack/react-query' +import { + Alert, + App, + AutoComplete, + Button, + DatePicker, + Form, + Input, + InputNumber, + Modal, + Radio, + Select, + Space, + Tag, + Typography, + type TableColumnsType, +} from 'antd' +import { PlusOutlined, ReloadOutlined, SettingOutlined } from '@ant-design/icons' +import { useNavigate } from 'react-router-dom' +import { type Dayjs } from 'dayjs' + +import { + createAdminPickup, + createAdminPickupShop, + deleteAdminPickupShop, + fetchAdminPickupAvailableListings, + fetchAdminPickupShops, + fetchAdminPickupShopOptions, + fetchAdminPickups, +} from '@/features/admin/api' +import { AdminPageHeader } from '@/features/admin/components/AdminPageHeader' +import { DataTable } from '@/features/admin/components/DataTable' +import { FilterBar, FilterField } from '@/features/admin/components/FilterBar' +import type { AdminPickup, AdminPickupCreatePayload, AdminPickupQuery } from '@/features/admin/types' +import { useAdminSessionStore } from '@/stores/adminSession' +import { formatDateTime, formatMoney, formatText } from '@/shared/utils/format' + +interface Filters { + keyword: string + status?: string + account_source?: string + offline_settlement_status?: string + operator: string + range: [Dayjs, Dayjs] | null +} + +interface CreateFormValues { + source_type?: string + listing_id: number + operator?: string + platform?: string + third_party_order_no?: string + shop_name?: string + order_amount?: number + deposit_amount?: number + profit_amount?: number + remark?: string +} + +const emptyFilters: Filters = { + keyword: '', + status: undefined, + account_source: undefined, + offline_settlement_status: undefined, + operator: '', + range: null, +} + +export const pickupStatusMeta: Record = { + picking_up: { label: '提号中', color: 'gold' }, + completed: { label: '已完成', color: 'green' }, + cancelled: { label: '已取消', color: 'default' }, +} + +export const offlineSettlementMeta: Record = { + none: { label: '未结算', color: 'default' }, + pending: { label: '待结算', color: 'gold' }, + settled: { label: '已结算', color: 'green' }, +} + +const sourceLabels: Record = { + internal: '站内上传', + external: '外部上传', +} + +export function pickupSourceLabel(value?: string | null) { + if (!value) return '未知来源' + return sourceLabels[value] ?? value +} + +function errorText(error: unknown) { + const response = (error as { response?: { data?: { message?: string } } })?.response + if (response?.data?.message) return response.data.message + return error instanceof Error ? error.message : '请求失败,请稍后重试' +} + +export function AdminPickupsPage() { + const navigate = useNavigate() + const { message, modal } = App.useApp() + const queryClient = useQueryClient() + const [createForm] = Form.useForm() + const [shopForm] = Form.useForm<{ name: string; platform?: string }>() + const [filters, setFilters] = useState(emptyFilters) + const [applied, setApplied] = useState(emptyFilters) + const [page, setPage] = useState(1) + const [pageSize, setPageSize] = useState(20) + const [createOpen, setCreateOpen] = useState(false) + const [shopOpen, setShopOpen] = useState(false) + const [submitting, setSubmitting] = useState(false) + const [sourceType, setSourceType] = useState('') + const [listingKeyword, setListingKeyword] = useState('') + + const permissions = useAdminSessionStore(state => state.admin?.permissions) + const canManage = Boolean(permissions?.includes('*') || permissions?.includes('order:pickup')) + + const query: AdminPickupQuery = useMemo( + () => ({ + keyword: applied.keyword.trim() || undefined, + status: applied.status, + account_source: applied.account_source, + offline_settlement_status: applied.offline_settlement_status, + operator: applied.operator.trim() || undefined, + start_time: applied.range?.[0]?.format('YYYY-MM-DD 00:00:00'), + end_time: applied.range?.[1]?.format('YYYY-MM-DD 23:59:59'), + page, + page_size: pageSize, + }), + [applied, page, pageSize] + ) + + const pickupsQuery = useQuery({ + queryKey: ['admin-pickups', query], + queryFn: () => fetchAdminPickups(query), + enabled: canManage, + placeholderData: keepPreviousData, + }) + + const listingsQuery = useQuery({ + queryKey: ['admin-pickup-available-listings', sourceType, listingKeyword], + queryFn: () => + fetchAdminPickupAvailableListings({ + keyword: listingKeyword || undefined, + source_type: sourceType || undefined, + page: 1, + page_size: 20, + }), + enabled: canManage && createOpen, + }) + + const shopOptionsQuery = useQuery({ + queryKey: ['admin-pickup-shop-options'], + queryFn: () => fetchAdminPickupShopOptions(), + enabled: canManage && createOpen, + }) + + const shopsQuery = useQuery({ + queryKey: ['admin-pickup-shops'], + queryFn: () => fetchAdminPickupShops(), + enabled: canManage && shopOpen, + }) + + const invalidate = () => + queryClient.invalidateQueries({ queryKey: ['admin-pickups'] }) + + const openCreate = () => { + setSourceType('') + setListingKeyword('') + createForm.setFieldsValue({ + listing_id: undefined, + operator: '', + platform: '', + third_party_order_no: '', + shop_name: undefined, + order_amount: 0, + deposit_amount: 0, + profit_amount: 0, + remark: '', + }) + setCreateOpen(true) + } + + const submitCreate = async () => { + const values = await createForm.validateFields() + const payload: AdminPickupCreatePayload = { + listing_id: values.listing_id, + operator: values.operator?.trim(), + platform: values.platform?.trim(), + third_party_order_no: values.third_party_order_no?.trim(), + shop_name: values.shop_name?.trim(), + order_amount_cent: Math.round(Number(values.order_amount ?? 0) * 100), + deposit_amount_cent: Math.round(Number(values.deposit_amount ?? 0) * 100), + profit_amount_cent: Math.round(Number(values.profit_amount ?? 0) * 100), + remark: values.remark?.trim(), + } + setSubmitting(true) + try { + const created = await createAdminPickup(payload) + message.success('线下提号已创建') + setCreateOpen(false) + await invalidate() + if (created?.id) navigate(`/pickup/${created.id}`) + } catch (error) { + message.error(errorText(error)) + } finally { + setSubmitting(false) + } + } + + const addShop = async () => { + const values = await shopForm.validateFields() + try { + await createAdminPickupShop({ name: values.name.trim(), platform: values.platform?.trim() }) + message.success('店铺已新增') + shopForm.setFieldsValue({ name: '', platform: '' }) + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-pickup-shops'] }), + queryClient.invalidateQueries({ queryKey: ['admin-pickup-shop-options'] }), + ]) + } catch (error) { + message.error(errorText(error)) + } + } + + const removeShop = (id: number, name: string) => { + modal.confirm({ + title: `确认删除提号店铺「${name}」?`, + content: '历史提号记录保留原店铺名,仅从可选列表中移除。', + okText: '删除', + okButtonProps: { danger: true }, + cancelText: '取消', + onOk: async () => { + try { + await deleteAdminPickupShop(id) + message.success('店铺已删除') + await Promise.all([ + queryClient.invalidateQueries({ queryKey: ['admin-pickup-shops'] }), + queryClient.invalidateQueries({ queryKey: ['admin-pickup-shop-options'] }), + ]) + } catch (error) { + message.error(errorText(error)) + } + }, + }) + } + + const columns: TableColumnsType = [ + { + title: '提号编号', + key: 'pickup', + width: 250, + fixed: 'left', + render: (_, row) => ( +
+ + {row.account_title} + {formatText(row.listing_no)} +
+ ), + }, + { + title: '来源 / 店铺', + key: 'source', + width: 180, + render: (_, row) => ( +
+ + {pickupSourceLabel(row.account_source)} + + {formatText(row.shop_name)} + {formatText(row.platform)} +
+ ), + }, + { + title: '金额', + key: 'amount', + width: 170, + align: 'right', + render: (_, row) => ( +
+ 下单 {formatMoney(row.order_amount_cent)} + 押金 {formatMoney(row.deposit_amount_cent)} + 利润 {formatMoney(row.effective_profit_amount_cent)} +
+ ), + }, + { + title: '状态', + key: 'status', + width: 170, + render: (_, row) => { + const status = pickupStatusMeta[row.status] ?? { label: row.status, color: 'default' } + const settlement = + offlineSettlementMeta[row.offline_settlement_status] ?? { + label: row.offline_settlement_status || '—', + color: 'default', + } + return ( + + + {status.label} + + + {settlement.label} + + + ) + }, + }, + { + title: '操作人', + key: 'operator', + width: 150, + render: (_, row) => ( +
+ {formatText(row.operator)} + {formatText(row.third_party_order_no)} +
+ ), + }, + { + title: '创建时间', + dataIndex: 'created_at', + width: 170, + render: value => formatDateTime(value), + }, + { + title: '操作', + key: 'actions', + width: 100, + fixed: 'right', + render: (_, row) => ( + + ), + }, + ] + + if (!canManage) { + return ( +
+ + +
+ ) + } + + return ( +
+ + 共 {pickupsQuery.data?.total ?? 0} 单 + + } + extra={ + + + + + + } + /> + + { + setPage(1) + setApplied(filters) + }} + onReset={() => { + setFilters(emptyFilters) + setApplied(emptyFilters) + setPage(1) + }} + > + + + setFilters(current => ({ ...current, keyword: event.target.value })) + } + /> + + + setFilters(current => ({ ...current, account_source: value }))} + /> + + + + setFilters(current => ({ ...current, operator: event.target.value })) + } + /> + + + + setFilters(current => ({ + ...current, + range: value && value[0] && value[1] ? [value[0], value[1]] : null, + })) + } + /> + + + + {pickupsQuery.isError ? ( + + ) : null} + + + columns={columns} + dataSource={pickupsQuery.data?.items ?? []} + rowKey="id" + loading={pickupsQuery.isFetching} + total={pickupsQuery.data?.total ?? 0} + page={pickupsQuery.data?.page ?? page} + pageSize={pickupsQuery.data?.page_size ?? pageSize} + onPageChange={(nextPage, nextSize) => { + setPage(nextPage) + setPageSize(nextSize) + }} + emptyDescription="还没有线下提号记录" + /> + + void submitCreate()} + onCancel={() => (submitting ? undefined : setCreateOpen(false))} + maskClosable={!submitting} + > +
+ + setSourceType(event.target.value)} + optionType="button" + options={[ + { value: '', label: '全部' }, + { value: 'external', label: '外部上传 · 平台代管' }, + { value: 'internal', label: '站内上传' }, + ]} + /> + + + + + + + + + + + + + ({ value: name }))} + /> + + + + + + + + + + + + + + + +
+ +
+ + setShopOpen(false)} + footer={} + width={620} + > +
+ + + + + + + + + +
+ + {(shopsQuery.data ?? []).map(shop => ( + { + event.preventDefault() + removeShop(shop.id, shop.name) + }} + > + {shop.name} + {shop.platform ? ` · ${shop.platform}` : ''} + + ))} + {shopsQuery.data?.length === 0 ? ( + 还没有配置店铺 + ) : null} + +
+
+ ) +} diff --git a/frontend/src/features/admin/api.ts b/frontend/src/features/admin/api.ts index f608ad2..c63e8e8 100644 --- a/frontend/src/features/admin/api.ts +++ b/frontend/src/features/admin/api.ts @@ -102,6 +102,14 @@ import type { AdminPaymentQuery, AdminWalletLedgerPage, AdminWalletLedgerQuery, + AdminPickup, + AdminPickupAvailableListing, + AdminPickupCreatePayload, + AdminPickupFinancialAdjustment, + AdminPickupPage, + AdminPickupProcessEvent, + AdminPickupQuery, + AdminPickupShop, ManualDisbursementPayload, OperatingExpensePage, OperatingExpensePayload, @@ -1396,3 +1404,154 @@ export async function fetchAdminWalletLedger(query: AdminWalletLedgerQuery = {}) } } +export async function fetchAdminPickups(query: AdminPickupQuery = {}) { + const { data } = await adminClient.get>>('/admin/pickups', { + 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 fetchAdminPickup(id: number | string) { + const { data } = await adminClient.get>(`/admin/pickups/${id}`) + return data.data +} + +export async function createAdminPickup(payload: AdminPickupCreatePayload) { + const { data } = await adminClient.post>('/admin/pickups', payload) + return data.data +} + +export async function completeAdminPickup( + id: number | string, + payload: { settle_amount_cent: number; profit_amount_cent?: number; complete_remark?: string } +) { + const { data } = await adminClient.post>( + `/admin/pickups/${id}/complete`, + payload + ) + return data.data +} + +export async function cancelAdminPickup(id: number | string, reason: string) { + const { data } = await adminClient.post>( + `/admin/pickups/${id}/cancel`, + { reason } + ) + return data.data +} + +export async function markAdminPickupOfflineSettlement(id: number | string, remark: string) { + const { data } = await adminClient.post>( + `/admin/pickups/${id}/offline-settlement`, + { remark } + ) + return data.data +} + +export async function updateAdminPickupProfit( + id: number | string, + payload: { profit_amount_cent: number; reason?: string } +) { + const { data } = await adminClient.put>( + `/admin/pickups/${id}/profit`, + payload + ) + return data.data +} + +export async function updateAdminPickupThirdPartyOrderNo(id: number | string, value: string) { + const { data } = await adminClient.put>( + `/admin/pickups/${id}/third-party-order-no`, + { third_party_order_no: value } + ) + return data.data +} + +export async function fetchAdminPickupShops(platform = '') { + const { data } = await adminClient.get>('/admin/pickups/shops', { + params: platform ? { platform } : undefined, + }) + return data.data ?? [] +} + +export async function createAdminPickupShop(payload: { + name: string + platform?: string + sort_order?: number + remark?: string +}) { + const { data } = await adminClient.post>( + '/admin/pickups/shops', + payload + ) + return data.data +} + +export async function deleteAdminPickupShop(id: number) { + await adminClient.delete(`/admin/pickups/shops/${id}`) +} + +export async function fetchAdminPickupShopOptions(platform = '') { + const { data } = await adminClient.get>('/admin/pickups/shop-options', { + params: platform ? { platform } : undefined, + }) + return data.data ?? [] +} + +export async function fetchAdminPickupAvailableListings(query: { + keyword?: string + source_type?: string + page?: number + page_size?: number +}) { + const { data } = await adminClient.get< + ApiResponse<{ items?: AdminPickupAvailableListing[]; total?: number }> + >('/admin/pickups/available-listings', { params: compactParams(query) }) + return { + items: data.data.items ?? [], + total: data.data.total ?? 0, + } +} + +export async function fetchAdminPickupProcessEvents(id: number | string) { + const { data } = await adminClient.get>( + `/admin/pickups/${id}/process-events` + ) + return data.data.items ?? [] +} + +export async function fetchAdminPickupFinancialAdjustments(id: number | string) { + const { data } = await adminClient.get>( + `/admin/pickups/${id}/financial-adjustments` + ) + return data.data ?? [] +} + +export async function createAdminPickupFinancialAdjustment( + id: number | string, + payload: { profit_amount_cent?: number; settle_amount_cent?: number; reason: string } +) { + const { data } = await adminClient.post>( + `/admin/pickups/${id}/financial-adjustments`, + payload + ) + return data.data +} + +export async function settleAdminPickupFinancialAdjustment( + adjustmentId: number, + remark: string +) { + const { data } = await adminClient.post>( + `/admin/pickup-financial-adjustments/${adjustmentId}/settle`, + { remark } + ) + return data.data +} + + diff --git a/frontend/src/features/admin/labels.ts b/frontend/src/features/admin/labels.ts index a7f4fac..150831c 100644 --- a/frontend/src/features/admin/labels.ts +++ b/frontend/src/features/admin/labels.ts @@ -235,8 +235,8 @@ export const auditBizTypeMap: Record = { payment_config: '支付配置', manual_disbursement: '人工打款', operating_expense: '运营支出', - pickup: '捡漏订单', - pickup_shop: '捡漏店铺', + pickup: '线下提号', + pickup_shop: '提号店铺', distribution: '分销', announcement: '公告', } @@ -284,16 +284,16 @@ export const auditActionMap: Record = { 'manual_disbursement_void': '作废人工打款', 'operating_expense_create': '登记运营支出', 'operating_expense_void': '作废运营支出', - 'pickup_create': '创建捡漏订单', - 'pickup_cancel': '取消捡漏订单', - 'pickup_complete': '完成捡漏订单', - 'pickup_profit_update': '更新捡漏利润', - 'pickup_offline_settlement': '捡漏线下结算', - 'pickup_financial_adjustment_create': '新建捡漏财务调整', - 'pickup_financial_adjustment_settle': '结算捡漏财务调整', - 'pickup_shop_create': '新增捡漏店铺', - 'pickup_shop_update': '更新捡漏店铺', - 'pickup_shop_delete': '删除捡漏店铺', + 'pickup_create': '创建线下提号', + 'pickup_cancel': '取消线下提号', + 'pickup_complete': '完成线下提号', + 'pickup_profit_update': '更新提号利润', + 'pickup_offline_settlement': '提号线下结算', + 'pickup_financial_adjustment_create': '新建提号财务调整', + 'pickup_financial_adjustment_settle': '结算提号财务调整', + 'pickup_shop_create': '新增提号店铺', + 'pickup_shop_update': '更新提号店铺', + 'pickup_shop_delete': '删除提号店铺', 'pickup_shop_map_historical': '关联历史捡漏店铺', 'pickup_shop_unmap_historical': '解除历史捡漏店铺', 'pickup_third_party_order_no_update': '更新第三方单号', diff --git a/frontend/src/features/admin/types.ts b/frontend/src/features/admin/types.ts index d62ce7f..6b1c7ea 100644 --- a/frontend/src/features/admin/types.ts +++ b/frontend/src/features/admin/types.ts @@ -609,6 +609,146 @@ export interface AdminWalletLedgerQuery { page_size?: number } +export interface AdminPickup { + id: number + pickup_no: string + listing_id: number + listing_no: string + account_title: string + server_region: string + login_platform: string + owner_id: number + owner_phone: string + admin_id: number + operator: string + platform: string + third_party_order_no: string + shop_name: string + raw_shop_name: string + account_source: string + source_channel: string + settlement_mode: string + listing_price_cent: number + order_amount_cent: number + deposit_amount_cent: number + owner_price_cent: number + website_profit_cent: number + profit_amount_cent: number + profit_adjustment_cent: number + effective_profit_amount_cent: number + seller_ratio: number + buyer_ratio: number + settle_amount_cent: number + settle_adjustment_cent: number + effective_settle_amount_cent: number + status: string + offline_settlement_status: string + offline_settlement_remark: string + offline_settled_by?: number | null + offline_settled_at?: string | null + remark: string + complete_remark: string + created_at: string + completed_at?: string | null + cancelled_at?: string | null +} + +export interface AdminPickupPage { + items: AdminPickup[] + total: number + page: number + page_size: number +} + +export interface AdminPickupQuery { + keyword?: string + status?: string + shop_name?: string + account_source?: string + offline_settlement_status?: string + operator?: string + third_party_order_no?: string + start_time?: string + end_time?: string + page?: number + page_size?: number +} + +export interface AdminPickupCreatePayload { + listing_id: number + operator?: string + platform?: string + third_party_order_no?: string + shop_name?: string + order_amount_cent?: number + deposit_amount_cent?: number + profit_amount_cent?: number + remark?: string +} + +export interface AdminPickupShop { + id: number + name: string + platform: string + status: string + sort_order: number + remark: string + created_at: string + updated_at: string +} + +export interface AdminPickupAvailableListing { + id: number + listing_no: string + account_title: string + server_region: string + login_platform: string + owner_id: number + owner_phone: string + listing_price_cent: number + owner_price_cent: number + owner_total_price_cent: number + website_profit_cent: number + seller_ratio: number + buyer_ratio: number + account_source: string + is_external_upload: boolean + source_channel: string +} + +export interface AdminPickupProcessEvent { + id: number + business_type: string + business_id: number + stage: string + action: string + actor_type: string + actor_id: number + actor_name: string + target_type: string + target_id?: number | null + target_name: string + content: string + reason: string + attachment_urls?: string[] + created_at: string +} + +export interface AdminPickupFinancialAdjustment { + id: number + pickup_id: number + profit_delta_cent: number + settle_delta_cent: number + settlement_mode: string + status: string + reason: string + created_by: number + settled_by?: number | null + settled_at?: string | null + settlement_remark: string + created_at: string +} + export interface AdminAnnouncementQuery { category?: string status?: string diff --git a/frontend/src/layouts/AdminLayout.tsx b/frontend/src/layouts/AdminLayout.tsx index 18c4e31..5a839be 100644 --- a/frontend/src/layouts/AdminLayout.tsx +++ b/frontend/src/layouts/AdminLayout.tsx @@ -61,6 +61,7 @@ const allNavGroups: NavGroup[] = [ children: [ { key: '/orders', label: '订单管理', permission: 'order:list' }, { key: '/orders/refund-review', label: '退款审核', permission: 'order:close' }, + { key: '/pickup', label: '线下提号', permission: 'order:pickup' }, { key: '/disputes', label: '申诉仲裁', permission: 'dispute:view' }, ], }, @@ -162,6 +163,7 @@ const allNavGroups: NavGroup[] = [ const sectionMeta: Record = { '/orders': { title: '订单管理', description: '查看全量订单、交接记录与结账状态。' }, '/orders/refund-review': { title: '退款审核', description: '审核退款申请并核对资金影响。' }, + '/pickup': { title: '线下提号', description: '管理员线下提号,站内结算或线下打款确认。' }, '/disputes': { title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' }, '/listings': { title: '商品管理', description: '查看商品、账号资产与上下架状态。' }, '/listings/review': { title: '商品审核', description: '审核待上架商品与账号截图。' },