feat(pickup): 新增线下提号列表与详情页

This commit is contained in:
yml2213
2026-10-30 16:45:25 +08:00
parent c0c9f161ae
commit 030cedb6fd
9 changed files with 1643 additions and 14 deletions
+88
View File
@@ -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`。
+2 -2
View File
@@ -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`)。
+12
View File
@@ -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() {
<Route path="admin-users" element={<AdminStaffAccountsPage />} />
<Route path="manual-entries" element={<AdminManualEntriesPage />} />
<Route path="payments" element={<AdminPaymentsPage />} />
<Route path="pickup" element={<AdminPickupsPage />} />
<Route path="pickup/:id" element={<AdminPickupDetailPage />} />
<Route path="finance/details" element={<AdminFinanceDetailsPage />} />
<Route path="wallet-ledger" element={<AdminWalletLedgerPage />} />
<Route path="chat-quick-replies" element={<AdminQuickRepliesPage />} />
@@ -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<AdminPickupFinancialAdjustment> = [
{
title: '调整',
key: 'delta',
width: 200,
render: (_, row) => (
<div className="admin-stack">
<span>利润 {formatMoney(row.profit_delta_cent)}</span>
<span className="admin-mono">结算 {formatMoney(row.settle_delta_cent)}</span>
</div>
),
},
{
title: '原因',
dataIndex: 'reason',
ellipsis: true,
render: value => formatText(value),
},
{
title: '状态',
dataIndex: 'status',
width: 110,
render: value => (
<Tag bordered={false} color={value === 'settled' ? 'green' : 'gold'}>
{value === 'settled' ? '已结算' : '待结算'}
</Tag>
),
},
{
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<unknown>, 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 (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 线下提号" title="提号详情" />
<Alert type="warning" showIcon message="当前账号没有线下提号权限(order:pickup)" />
</div>
)
}
if (pickupQuery.isError) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 线下提号" title="提号详情" />
<Alert
type="error"
showIcon
message="提号详情加载失败"
description={errorText(pickupQuery.error)}
action={<Button onClick={() => navigate('/pickup')}>返回列表</Button>}
/>
</div>
)
}
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 (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 线下提号"
title={pickup ? `线下提号 · ${pickup.pickup_no}` : '线下提号详情'}
description="创建后账号锁定为提号中;完成后按结算金额进入号主钱包或线下结算。"
meta={
<Space size={8} wrap>
<Tag bordered={false} color={statusMeta.color}>
{statusMeta.label}
</Tag>
<Tag bordered={false} color={settlementMeta.color}>
{settlementMeta.label}
</Tag>
{pickup ? <Tag>{pickupSourceLabel(pickup.account_source)}</Tag> : null}
</Space>
}
extra={
<Space wrap>
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate('/pickup')}>
返回列表
</Button>
<Button
icon={<ReloadOutlined />}
loading={pickupQuery.isFetching}
onClick={() => void pickupQuery.refetch()}
>
刷新
</Button>
{pickup?.status === 'picking_up' ? (
<>
<Button
type="primary"
onClick={() => {
completeForm.setFieldsValue({
settle_amount: pickup.effective_settle_amount_cent / 100,
profit_amount: pickup.effective_profit_amount_cent / 100,
remark: '',
})
setCompleteOpen(true)
}}
>
完成提号
</Button>
<Button danger onClick={() => {
cancelForm.setFieldsValue({ reason: '' })
setCancelOpen(true)
}}>
取消提号
</Button>
</>
) : null}
{pickup?.status === 'completed' &&
pickup.offline_settlement_status === 'pending' ? (
<Button
type="primary"
onClick={() => {
settleForm.setFieldsValue({ remark: '' })
setSettleOpen(true)
}}
>
标记线下已结算
</Button>
) : null}
</Space>
}
/>
<Card title="基本信息" className="admin-table-card">
<Descriptions column={2} size="middle" bordered>
<Descriptions.Item label="提号编号">{formatText(pickup?.pickup_no)}</Descriptions.Item>
<Descriptions.Item label="上架编号">{formatText(pickup?.listing_no)}</Descriptions.Item>
<Descriptions.Item label="账号标题">{formatText(pickup?.account_title)}</Descriptions.Item>
<Descriptions.Item label="区服 / 登录">
{formatText(pickup?.server_region)} · {formatText(pickup?.login_platform)}
</Descriptions.Item>
<Descriptions.Item label="号主手机">{formatText(pickup?.owner_phone)}</Descriptions.Item>
<Descriptions.Item label="操作人">{formatText(pickup?.operator)}</Descriptions.Item>
<Descriptions.Item label="交易渠道">{formatText(pickup?.platform)}</Descriptions.Item>
<Descriptions.Item label="店铺">{formatText(pickup?.shop_name)}</Descriptions.Item>
<Descriptions.Item label="第三方单号">
<Space size={6}>
<span>{formatText(pickup?.third_party_order_no)}</span>
<Button
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => {
thirdPartyForm.setFieldsValue({
third_party_order_no: pickup?.third_party_order_no ?? '',
})
setThirdPartyOpen(true)
}}
>
修改
</Button>
</Space>
</Descriptions.Item>
<Descriptions.Item label="结算模式">{formatText(pickup?.settlement_mode)}</Descriptions.Item>
<Descriptions.Item label="创建时间">{formatDateTime(pickup?.created_at)}</Descriptions.Item>
<Descriptions.Item label="完成时间">
{pickup?.completed_at ? formatDateTime(pickup.completed_at) : '—'}
</Descriptions.Item>
<Descriptions.Item label="备注" span={2}>
{formatText(pickup?.remark)}
</Descriptions.Item>
</Descriptions>
</Card>
<div className="wallet-metrics" style={{ marginTop: 16 }}>
<div className="wallet-metric">
<span>下单金额</span>
<strong>{formatMoney(pickup?.order_amount_cent ?? 0)}</strong>
<small>押金 {formatMoney(pickup?.deposit_amount_cent ?? 0)}</small>
</div>
<div className="wallet-metric">
<span>站点售价</span>
<strong>{formatMoney(pickup?.listing_price_cent ?? 0)}</strong>
<small>号主价 {formatMoney(pickup?.owner_price_cent ?? 0)}</small>
</div>
<div className="wallet-metric">
<span>线下利润</span>
<strong>{formatMoney(pickup?.effective_profit_amount_cent ?? 0)}</strong>
<small>
原 {formatMoney(pickup?.profit_amount_cent ?? 0)} · 调整{' '}
{formatMoney(pickup?.profit_adjustment_cent ?? 0)}
</small>
<Button
size="small"
onClick={() => {
profitForm.setFieldsValue({
profit_amount: (pickup?.profit_amount_cent ?? 0) / 100,
reason: '',
})
setProfitOpen(true)
}}
>
修改利润
</Button>
</div>
<div className="wallet-metric">
<span>应结算金额</span>
<strong>{formatMoney(pickup?.effective_settle_amount_cent ?? 0)}</strong>
<small>
原 {formatMoney(pickup?.settle_amount_cent ?? 0)} · 调整{' '}
{formatMoney(pickup?.settle_adjustment_cent ?? 0)}
</small>
</div>
</div>
<Card
title="完成后财务调整"
className="admin-table-card"
style={{ marginTop: 16 }}
extra={
<Button
size="small"
onClick={() => {
adjustForm.setFieldsValue({ reason: '' })
setAdjustOpen(true)
}}
>
新建调整
</Button>
}
>
<Table<AdminPickupFinancialAdjustment>
rowKey="id"
size="small"
columns={[
...adjustmentColumns,
{
title: '操作',
key: 'actions',
width: 120,
render: (_, row) =>
row.status === 'settled' ? (
<Typography.Text type="secondary">已处理</Typography.Text>
) : (
<Button
type="link"
size="small"
onClick={() => {
setSubmitting(true)
void settleAdminPickupFinancialAdjustment(row.id, '后台确认线下已处理')
.then(async () => {
message.success('调整已结算')
await invalidate()
})
.catch(error => message.error(errorText(error)))
.finally(() => setSubmitting(false))
}}
>
确认结算
</Button>
),
},
]}
dataSource={adjustmentsQuery.data ?? []}
loading={adjustmentsQuery.isFetching}
pagination={false}
locale={{ emptyText: '暂无财务调整' }}
/>
</Card>
<Card title="流程事件" className="admin-table-card" style={{ marginTop: 16 }}>
{eventsQuery.data?.length ? (
<Timeline
items={eventsQuery.data.map(event => ({
color: event.action?.includes('cancel') ? 'red' : 'green',
children: (
<div className="admin-stack">
<strong>
{formatText(event.stage)} · {formatText(event.action)}
</strong>
<span>{formatText(event.content || event.reason)}</span>
<span className="admin-mono">
{formatText(event.actor_name)} · {formatDateTime(event.created_at)}
</span>
</div>
),
}))}
/>
) : (
<Typography.Text type="secondary">暂无流程事件</Typography.Text>
)}
</Card>
<Modal
open={completeOpen}
title="完成提号"
okText="确认完成"
cancelText="取消"
confirmLoading={submitting}
onCancel={() => (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)
}}
>
<Form form={completeForm} layout="vertical" requiredMark={false}>
<Form.Item
name="settle_amount"
label="结算金额(元)"
rules={[{ required: true, message: '请输入结算金额' }]}
extra="站内上传会进入号主钱包;平台代管按线下结算处理"
>
<InputNumber min={0.01} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="profit_amount" label="线下利润(元)">
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="remark" label="完成备注">
<Input.TextArea rows={2} maxLength={255} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
open={cancelOpen}
title="取消提号"
okText="确认取消"
okButtonProps={{ danger: true }}
cancelText="返回"
confirmLoading={submitting}
onCancel={() => (submitting ? undefined : setCancelOpen(false))}
onOk={async () => {
const values = await cancelForm.validateFields()
const ok = await run(() => cancelAdminPickup(pickupId, values.reason.trim()), '提号已取消')
if (ok) setCancelOpen(false)
}}
>
<Form form={cancelForm} layout="vertical" requiredMark={false}>
<Form.Item
name="reason"
label="取消原因"
rules={[{ required: true, message: '请填写取消原因' }]}
>
<Input.TextArea rows={3} maxLength={255} showCount />
</Form.Item>
</Form>
</Modal>
<Modal
open={settleOpen}
title="标记线下已结算"
okText="确认已结算"
cancelText="取消"
confirmLoading={submitting}
onCancel={() => (submitting ? undefined : setSettleOpen(false))}
onOk={async () => {
const values = await settleForm.validateFields()
const ok = await run(
() => markAdminPickupOfflineSettlement(pickupId, values.remark?.trim() ?? ''),
'已标记线下结算'
)
if (ok) setSettleOpen(false)
}}
>
<Form form={settleForm} layout="vertical" requiredMark={false}>
<Form.Item name="remark" label="结算备注">
<Input.TextArea rows={3} maxLength={255} showCount placeholder="打款凭证/备注" />
</Form.Item>
</Form>
</Modal>
<Modal
open={profitOpen}
title="修改线下利润"
okText="保存"
cancelText="取消"
confirmLoading={submitting}
onCancel={() => (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)
}}
>
<Form form={profitForm} layout="vertical" requiredMark={false}>
<Form.Item
name="profit_amount"
label="利润金额(元)"
rules={[{ required: true, message: '请输入利润金额' }]}
>
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="reason" label="调整原因">
<Input placeholder="可选" maxLength={255} />
</Form.Item>
</Form>
</Modal>
<Modal
open={thirdPartyOpen}
title="修改第三方单号"
okText="保存"
cancelText="取消"
confirmLoading={submitting}
onCancel={() => (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)
}}
>
<Form form={thirdPartyForm} layout="vertical" requiredMark={false}>
<Form.Item
name="third_party_order_no"
label="第三方单号"
rules={[{ required: true, message: '请输入第三方单号' }, { max: 128 }]}
>
<Input placeholder="淘宝/拼多多/外部订单号" />
</Form.Item>
</Form>
</Modal>
<Modal
open={adjustOpen}
title="新建完成后财务调整"
okText="创建调整"
cancelText="取消"
confirmLoading={submitting}
onCancel={() => (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)
}}
>
<Form form={adjustForm} layout="vertical" requiredMark={false}>
<Form.Item name="profit_amount" label="目标利润(元)" extra="填写后按差额生成调整流水">
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="settle_amount" label="目标结算(元)">
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item
name="reason"
label="调整原因"
rules={[{ required: true, message: '请填写调整原因' }]}
>
<Input.TextArea rows={2} maxLength={255} showCount />
</Form.Item>
</Form>
</Modal>
</div>
)
}
@@ -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<string, { label: string; color: string }> = {
picking_up: { label: '提号中', color: 'gold' },
completed: { label: '已完成', color: 'green' },
cancelled: { label: '已取消', color: 'default' },
}
export const offlineSettlementMeta: Record<string, { label: string; color: string }> = {
none: { label: '未结算', color: 'default' },
pending: { label: '待结算', color: 'gold' },
settled: { label: '已结算', color: 'green' },
}
const sourceLabels: Record<string, string> = {
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<CreateFormValues>()
const [shopForm] = Form.useForm<{ name: string; platform?: string }>()
const [filters, setFilters] = useState<Filters>(emptyFilters)
const [applied, setApplied] = useState<Filters>(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<AdminPickup> = [
{
title: '提号编号',
key: 'pickup',
width: 250,
fixed: 'left',
render: (_, row) => (
<div className="admin-stack">
<button
type="button"
className="admin-order-link"
onClick={() => navigate(`/pickup/${row.id}`)}
>
{row.pickup_no}
</button>
<strong>{row.account_title}</strong>
<span className="admin-mono">{formatText(row.listing_no)}</span>
</div>
),
},
{
title: '来源 / 店铺',
key: 'source',
width: 180,
render: (_, row) => (
<div className="admin-stack">
<Tag bordered={false} color={row.account_source === 'external' ? 'purple' : 'blue'}>
{pickupSourceLabel(row.account_source)}
</Tag>
<span>{formatText(row.shop_name)}</span>
<span className="admin-mono">{formatText(row.platform)}</span>
</div>
),
},
{
title: '金额',
key: 'amount',
width: 170,
align: 'right',
render: (_, row) => (
<div className="admin-stack">
<span>下单 {formatMoney(row.order_amount_cent)}</span>
<span className="admin-mono">押金 {formatMoney(row.deposit_amount_cent)}</span>
<span className="admin-mono">利润 {formatMoney(row.effective_profit_amount_cent)}</span>
</div>
),
},
{
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 (
<Space size={4} wrap>
<Tag bordered={false} color={status.color}>
{status.label}
</Tag>
<Tag bordered={false} color={settlement.color}>
{settlement.label}
</Tag>
</Space>
)
},
},
{
title: '操作人',
key: 'operator',
width: 150,
render: (_, row) => (
<div className="admin-stack">
<span>{formatText(row.operator)}</span>
<span className="admin-mono">{formatText(row.third_party_order_no)}</span>
</div>
),
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 170,
render: value => formatDateTime(value),
},
{
title: '操作',
key: 'actions',
width: 100,
fixed: 'right',
render: (_, row) => (
<Button type="link" size="small" onClick={() => navigate(`/pickup/${row.id}`)}>
详情
</Button>
),
},
]
if (!canManage) {
return (
<div className="admin-page">
<AdminPageHeader eyebrow="JIEYOU OPS · 线下提号" title="线下提号" />
<Alert type="warning" showIcon message="当前账号没有线下提号权限(order:pickup)" />
</div>
)
}
return (
<div className="admin-page">
<AdminPageHeader
eyebrow="JIEYOU OPS · 线下提号"
title="线下提号"
description="管理员线下提号,站内上传结算到钱包,平台代管通过线下结算确认打款。"
meta={
<Space size={8} wrap>
<Tag color="geekblue">共 {pickupsQuery.data?.total ?? 0} 单</Tag>
</Space>
}
extra={
<Space>
<Button icon={<SettingOutlined />} onClick={() => setShopOpen(true)}>
店铺管理
</Button>
<Button
icon={<ReloadOutlined />}
loading={pickupsQuery.isFetching}
onClick={() => void pickupsQuery.refetch()}
>
刷新
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
创建提号
</Button>
</Space>
}
/>
<FilterBar
loading={pickupsQuery.isFetching}
onSearch={() => {
setPage(1)
setApplied(filters)
}}
onReset={() => {
setFilters(emptyFilters)
setApplied(emptyFilters)
setPage(1)
}}
>
<FilterField label="关键字">
<Input
allowClear
value={filters.keyword}
placeholder="提号编号 / 上架编号 / 账号标题 / 手机号"
onChange={event =>
setFilters(current => ({ ...current, keyword: event.target.value }))
}
/>
</FilterField>
<FilterField label="提号状态">
<Select
allowClear
value={filters.status}
placeholder="全部状态"
options={[
{ value: 'picking_up', label: '提号中' },
{ value: 'completed', label: '已完成' },
{ value: 'cancelled', label: '已取消' },
]}
onChange={value => setFilters(current => ({ ...current, status: value }))}
/>
</FilterField>
<FilterField label="账号来源">
<Select
allowClear
value={filters.account_source}
placeholder="全部来源"
options={[
{ value: 'internal', label: '站内上传' },
{ value: 'external', label: '外部上传' },
]}
onChange={value => setFilters(current => ({ ...current, account_source: value }))}
/>
</FilterField>
<FilterField label="结算状态">
<Select
allowClear
value={filters.offline_settlement_status}
placeholder="全部状态"
options={[
{ value: 'none', label: '未结算' },
{ value: 'pending', label: '待结算' },
{ value: 'settled', label: '已结算' },
]}
onChange={value =>
setFilters(current => ({ ...current, offline_settlement_status: value }))
}
/>
</FilterField>
<FilterField label="操作人">
<Input
allowClear
value={filters.operator}
placeholder="经办人"
onChange={event =>
setFilters(current => ({ ...current, operator: event.target.value }))
}
/>
</FilterField>
<FilterField label="创建时间" width={280}>
<DatePicker.RangePicker
style={{ width: '100%' }}
value={filters.range}
allowEmpty={[true, true]}
onChange={value =>
setFilters(current => ({
...current,
range: value && value[0] && value[1] ? [value[0], value[1]] : null,
}))
}
/>
</FilterField>
</FilterBar>
{pickupsQuery.isError ? (
<Alert
type="error"
showIcon
className="admin-table-alert"
message="提号列表加载失败"
description={errorText(pickupsQuery.error)}
/>
) : null}
<DataTable<AdminPickup>
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="还没有线下提号记录"
/>
<Modal
open={createOpen}
title="创建线下提号"
okText="创建提号"
cancelText="取消"
width={720}
confirmLoading={submitting}
onOk={() => void submitCreate()}
onCancel={() => (submitting ? undefined : setCreateOpen(false))}
maskClosable={!submitting}
>
<Form form={createForm} layout="vertical" requiredMark={false}>
<Form.Item label="账号来源">
<Radio.Group
value={sourceType}
onChange={event => setSourceType(event.target.value)}
optionType="button"
options={[
{ value: '', label: '全部' },
{ value: 'external', label: '外部上传 · 平台代管' },
{ value: 'internal', label: '站内上传' },
]}
/>
</Form.Item>
<Form.Item
name="listing_id"
label="选择账号"
rules={[{ required: true, message: '请选择要提号的账号' }]}
extra="仅显示已发布、已审核、未在交易中的账号"
>
<Select
showSearch
filterOption={false}
loading={listingsQuery.isFetching}
placeholder="输入上架编号或账号标题搜索"
onSearch={value => setListingKeyword(value)}
options={(listingsQuery.data?.items ?? []).map(item => ({
value: item.id,
label: `${item.listing_no} · ${item.account_title} · ${formatMoney(item.listing_price_cent)}`,
}))}
/>
</Form.Item>
<Space size={12} style={{ width: '100%' }} align="start">
<Form.Item name="operator" label="操作人" style={{ width: 240 }}>
<Input placeholder="经办人/客服姓名" />
</Form.Item>
<Form.Item name="platform" label="交易渠道" style={{ width: 240 }}>
<Input placeholder="淘宝 / 微信 / QQ" />
</Form.Item>
<Form.Item name="third_party_order_no" label="第三方单号" style={{ width: 240 }}>
<Input placeholder="外部订单号" />
</Form.Item>
</Space>
<Form.Item name="shop_name" label="店铺名称" extra="可直接输入新店铺名称">
<AutoComplete
allowClear
placeholder="选择或输入店铺"
options={(shopOptionsQuery.data ?? []).map(name => ({ value: name }))}
/>
</Form.Item>
<Space size={12} style={{ width: '100%' }} align="start">
<Form.Item name="order_amount" label="下单金额(元)">
<InputNumber min={0} precision={2} style={{ width: 200 }} prefix="¥" />
</Form.Item>
<Form.Item name="deposit_amount" label="押金金额(元)">
<InputNumber min={0} precision={2} style={{ width: 200 }} prefix="¥" />
</Form.Item>
<Form.Item name="profit_amount" label="线下利润(元)">
<InputNumber min={0} precision={2} style={{ width: 200 }} prefix="¥" />
</Form.Item>
</Space>
<Form.Item name="remark" label="备注">
<Input.TextArea rows={2} maxLength={255} showCount placeholder="线下交易说明(可选)" />
</Form.Item>
</Form>
<Alert
type="info"
showIcon
message='创建后账号会被锁定为「提号中」,卖家收到通知;不会立即结算,需手动完成提号。'
/>
</Modal>
<Modal
open={shopOpen}
title="提号店铺管理"
onCancel={() => setShopOpen(false)}
footer={<Button onClick={() => setShopOpen(false)}>关闭</Button>}
width={620}
>
<Form form={shopForm} layout="inline" style={{ marginBottom: 12, gap: 8 }}>
<Form.Item name="name" rules={[{ required: true, message: '请输入店铺名' }]}>
<Input placeholder="店铺名称" style={{ width: 200 }} />
</Form.Item>
<Form.Item name="platform">
<Input placeholder="平台(可选)" style={{ width: 140 }} />
</Form.Item>
<Form.Item>
<Button type="primary" icon={<PlusOutlined />} onClick={() => void addShop()}>
新增
</Button>
</Form.Item>
</Form>
<Space size={6} wrap>
{(shopsQuery.data ?? []).map(shop => (
<Tag
key={shop.id}
closable
color="geekblue"
onClose={event => {
event.preventDefault()
removeShop(shop.id, shop.name)
}}
>
{shop.name}
{shop.platform ? ` · ${shop.platform}` : ''}
</Tag>
))}
{shopsQuery.data?.length === 0 ? (
<Typography.Text type="secondary">还没有配置店铺</Typography.Text>
) : null}
</Space>
</Modal>
</div>
)
}
+159
View File
@@ -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<ApiResponse<Partial<AdminPickupPage>>>('/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<ApiResponse<AdminPickup>>(`/admin/pickups/${id}`)
return data.data
}
export async function createAdminPickup(payload: AdminPickupCreatePayload) {
const { data } = await adminClient.post<ApiResponse<AdminPickup>>('/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<ApiResponse<AdminPickup>>(
`/admin/pickups/${id}/complete`,
payload
)
return data.data
}
export async function cancelAdminPickup(id: number | string, reason: string) {
const { data } = await adminClient.post<ApiResponse<AdminPickup>>(
`/admin/pickups/${id}/cancel`,
{ reason }
)
return data.data
}
export async function markAdminPickupOfflineSettlement(id: number | string, remark: string) {
const { data } = await adminClient.post<ApiResponse<AdminPickup>>(
`/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<ApiResponse<AdminPickup>>(
`/admin/pickups/${id}/profit`,
payload
)
return data.data
}
export async function updateAdminPickupThirdPartyOrderNo(id: number | string, value: string) {
const { data } = await adminClient.put<ApiResponse<AdminPickup>>(
`/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<ApiResponse<AdminPickupShop[]>>('/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<ApiResponse<AdminPickupShop>>(
'/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<ApiResponse<string[]>>('/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<ApiResponse<{ items: AdminPickupProcessEvent[] }>>(
`/admin/pickups/${id}/process-events`
)
return data.data.items ?? []
}
export async function fetchAdminPickupFinancialAdjustments(id: number | string) {
const { data } = await adminClient.get<ApiResponse<AdminPickupFinancialAdjustment[]>>(
`/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<ApiResponse<AdminPickupFinancialAdjustment>>(
`/admin/pickups/${id}/financial-adjustments`,
payload
)
return data.data
}
export async function settleAdminPickupFinancialAdjustment(
adjustmentId: number,
remark: string
) {
const { data } = await adminClient.post<ApiResponse<AdminPickupFinancialAdjustment>>(
`/admin/pickup-financial-adjustments/${adjustmentId}/settle`,
{ remark }
)
return data.data
}
+12 -12
View File
@@ -235,8 +235,8 @@ export const auditBizTypeMap: Record<string, string> = {
payment_config: '支付配置',
manual_disbursement: '人工打款',
operating_expense: '运营支出',
pickup: '捡漏订单',
pickup_shop: '捡漏店铺',
pickup: '线下提号',
pickup_shop: '提号店铺',
distribution: '分销',
announcement: '公告',
}
@@ -284,16 +284,16 @@ export const auditActionMap: Record<string, string> = {
'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': '更新第三方单号',
+140
View File
@@ -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
+2
View File
@@ -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<string, { title: string; description: string }> = {
'/orders': { title: '订单管理', description: '查看全量订单、交接记录与结账状态。' },
'/orders/refund-review': { title: '退款审核', description: '审核退款申请并核对资金影响。' },
'/pickup': { title: '线下提号', description: '管理员线下提号,站内结算或线下打款确认。' },
'/disputes': { title: '申诉仲裁', description: '处理订单争议、证据与仲裁结果。' },
'/listings': { title: '商品管理', description: '查看商品、账号资产与上下架状态。' },
'/listings/review': { title: '商品审核', description: '审核待上架商品与账号截图。' },