合并远程 main 最新改动

This commit is contained in:
yml2213
2026-11-05 16:11:40 +08:00
9 changed files with 393 additions and 19 deletions
@@ -40,6 +40,12 @@ export default function AdminWorkerPlatformPage() {
const location = useLocation()
const [activeTab, setActiveTab] = useState(() => loadActiveTab(searchParams.get('tab')))
const afterSalesPanelRef = useRef<AfterSalesPanelHandle>(null)
/**
* 从工单列表跳转过来时,售后面板可能尚未挂载(Tab 懒渲染),
* 此时 ref 还是 null,直接调用会被可选链静默吞掉,导致要点两次。
* 先记录待打开的工单,等面板挂载后再真正打开建单弹窗。
*/
const [pendingAfterSalesOrder, setPendingAfterSalesOrder] = useState<WorkOrder | null>(null)
const role = getAdminRole()
const isAdmin = role === 'admin'
const isSupport = role === 'support'
@@ -82,12 +88,20 @@ export default function AdminWorkerPlatformPage() {
const state = location.state as AfterSalesLocationState | null
if (state?.createOrder) {
setActiveTab('after-sales')
afterSalesPanelRef.current?.openCreateModal(state.createOrder)
setPendingAfterSalesOrder(state.createOrder)
// 清掉 state,避免刷新或后退时重复弹窗。
window.history.replaceState({}, '')
}
}, [location.state])
// 售后 Tab 挂载完成(ref 可写)后再打开建单弹窗,避免首点击丢失。
useEffect(() => {
if (visibleActiveTab !== 'after-sales' || !pendingAfterSalesOrder) return
if (!afterSalesPanelRef.current) return
afterSalesPanelRef.current.openCreateModal(pendingAfterSalesOrder)
setPendingAfterSalesOrder(null)
}, [visibleActiveTab, pendingAfterSalesOrder])
function changeTab(nextTab: string) {
if (!isWorkerPlatformTab(nextTab)) return
if (isFinance && nextTab !== 'finance') return
@@ -47,6 +47,8 @@ type AfterSalesCreateFormValues = {
type AfterSalesResolutionFormValues = {
action?: 'not_upheld' | 'remedied' | 'upheld'
resolutionNote?: string
/** 拼单售后追缴的份数;整单售后不展示该字段 */
recoveryQuantity?: number
recoveryAmount?: number
}
@@ -83,6 +85,15 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
const [afterSalesCreateForm] = Form.useForm<AfterSalesCreateFormValues>()
const [afterSalesResolutionForm] = Form.useForm<AfterSalesResolutionFormValues>()
const [afterSalesRepayForm] = Form.useForm<AfterSalesRepayFormValues>()
/** 拼单售后追缴份数(实时回显,用于金额联动展示)。 */
const resolutionRecoveryQuantityWatch = Form.useWatch(
'recoveryQuantity',
afterSalesResolutionForm,
)
const resolutionRecoveryQuantity = Math.max(
1,
Math.round(Number(resolutionRecoveryQuantityWatch || 1)),
)
const afterSalesSharingQuery = useQuery({
queryKey: ['admin-worker-platform-after-sales-sharing', afterSalesOrder?.workOrderId],
@@ -157,10 +168,24 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
function openAfterSalesResolutionModal(item: WorkerAfterSalesCase) {
setAfterSalesCase(item)
const existingRecoveryAmount = Number(item.recoveryAmount || 0)
const shareQuantity = Math.max(1, Number(item.shareQuantity || 1))
const unitRewardFen = Math.max(0, Number(item.shareUnitReward || 0))
// 已追缴过的按原金额反推份数;未处置的默认 1 份。
const recoveryQuantity =
item.shareId && existingRecoveryAmount > 0 && unitRewardFen > 0
? Math.min(shareQuantity, Math.max(1, Math.round(existingRecoveryAmount / unitRewardFen)))
: 1
afterSalesResolutionForm.setFieldsValue({
action: 'upheld',
resolutionNote: '',
recoveryAmount: Number(item.recoveryAmount || 0) / 100,
recoveryQuantity: item.shareId ? recoveryQuantity : undefined,
// 已追缴过的沿用原金额;未处置时按后端建议值:
// 整单 = 发单金额,拼单 = 该责任份额的一份价格。
recoveryAmount:
existingRecoveryAmount > 0
? existingRecoveryAmount / 100
: Number(item.suggestedRecoveryAmount || 0) / 100,
})
}
@@ -272,7 +297,7 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
setAfterSalesPageSize(nextPageSize)
},
})}
scroll={{ x: 1160 }}
scroll={{ x: 1230 }}
columns={[
{
title: '售后单',
@@ -289,13 +314,20 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
},
{
title: '责任打手',
width: 150,
width: 220,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text>{item.workerName || `打手#${item.workerId}`}</Typography.Text>
{item.shareQuantity ? (
<Tag color="purple">拼单 {item.shareQuantity} 份</Tag>
) : null}
{item.shareId ? (
<Tag color="purple">
拼单 {item.shareQuantity || 1} 份 · 一份 {formatMoney(item.shareUnitReward)} ·
合计 {formatMoney(item.shareRewardAmount)}
</Tag>
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
整单发单 {formatMoney(item.workOrderRewardAmount)}
</Typography.Text>
)}
</div>
),
},
@@ -447,7 +479,7 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
.filter((share) => share.status === 'accepted')
.map((share) => ({
value: share.shareId,
label: `${share.worker?.displayName || share.worker?.username || `打手#${share.workerId}`} · ${share.quantity} 份`,
label: `${share.worker?.displayName || share.worker?.username || `打手#${share.workerId}`} · ${share.quantity} 份 · 一份 ${formatMoney(share.unitReward)} · 合计 ${formatMoney(share.shareReward)}`,
}))}
/>
</Form.Item>
@@ -497,6 +529,16 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
form={afterSalesResolutionForm}
layout="vertical"
onFinish={submitAfterSalesResolution}
onValuesChange={(changedValues) => {
// 拼单:份数变化时按「一份价格 × 份数」重算追缴金额;金额仍可手动改。
if (!afterSalesCase?.shareId) return
if (changedValues.recoveryQuantity === undefined) return
const quantity = Math.max(1, Math.round(Number(changedValues.recoveryQuantity || 1)))
afterSalesResolutionForm.setFieldsValue({
recoveryAmount:
(Math.max(0, Number(afterSalesCase.shareUnitReward || 0)) * quantity) / 100,
})
}}
>
<Form.Item
label="处置结论"
@@ -517,14 +559,49 @@ const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPan
>
{({ getFieldValue }) =>
getFieldValue('action') === 'upheld' ? (
<Form.Item
label="追缴金额"
name="recoveryAmount"
rules={[{ required: true, message: '请输入追缴金额' }]}
extra="系统依次扣减该责任打手的本单待解冻金额、提现冻结中的金额(会自动驳回其待审核提现)、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
>
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
</Form.Item>
<>
{afterSalesCase.shareId ? (
<>
<Alert
showIcon
type="info"
style={{ marginBottom: 16 }}
message={`发单金额:一份 ${formatMoney(afterSalesCase.shareUnitReward)} × ${resolutionRecoveryQuantity} 份 = ${formatMoney(Number(afterSalesCase.shareUnitReward || 0) * resolutionRecoveryQuantity)}`}
description={`该责任份额共 ${afterSalesCase.shareQuantity || 1} 份,合计 ${formatMoney(afterSalesCase.shareRewardAmount)};默认追缴 1 份,调整份数后追缴金额自动重算,也可直接修改金额。`}
/>
<Form.Item
label="追缴份数"
name="recoveryQuantity"
rules={[{ required: true, message: '请输入追缴份数' }]}
extra={`最多可追缴 ${afterSalesCase.shareQuantity || 1} 份`}
>
<InputNumber
min={1}
max={Math.max(1, Number(afterSalesCase.shareQuantity || 1))}
precision={0}
addonAfter="份"
className="full-width"
/>
</Form.Item>
</>
) : (
<Alert
showIcon
type="info"
style={{ marginBottom: 16 }}
message={`发单金额:${formatMoney(afterSalesCase.workOrderRewardAmount)}(整单)`}
description="整单售后默认按工单发单金额追缴,可自行调整。"
/>
)}
<Form.Item
label="追缴金额"
name="recoveryAmount"
rules={[{ required: true, message: '请输入追缴金额' }]}
extra="系统依次扣减该责任打手的本单待解冻金额、提现冻结中的金额(会自动驳回其待审核提现)、可用余额;不足部分转为欠款,可从后续验收报酬、充值入账自动抵扣,也可在此手动偿还。"
>
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
</Form.Item>
</>
) : (
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
选择补救完成或问题不成立时,系统自动将该工单同一责任打手此前已追缴且未退还的金额退还至其可用余额。
@@ -235,6 +235,16 @@ export type WorkerAfterSalesCase = {
shopName: string
workerName: string
shareQuantity: number | null
/** 工单发单金额(分):整单售后按此金额追缴 */
workOrderRewardAmount: number
/** 工单是否开启拼单 */
sharingEnabled: boolean
/** 责任份额的一份价格(分) */
shareUnitReward: number
/** 责任份额的合计金额(分)= 一份价格 × 份数 */
shareRewardAmount: number
/** 后端建议的默认追缴金额(分):整单 = 发单金额,拼单 = 一份价格 */
suggestedRecoveryAmount: number
problemType: string
complaintNote: string
complaintFiles: UploadedFile[]
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import test from 'node:test'
function readSource(relativePath: string) {
return readFileSync(new URL(relativePath, import.meta.url).pathname, 'utf8')
}
test('工单跳到售后:先记录待打开工单,等面板挂载后再弹窗(避免点两次)', () => {
const source = readSource('../src/pages/admin/AdminWorkerPlatformPage.tsx')
assert.ok(source.includes('pendingAfterSalesOrder'), '必须用 pending state 承接待打开的售后工单')
assert.ok(
source.includes('setPendingAfterSalesOrder(state.createOrder)'),
'收到路由 state 时只记录待打开工单,不能直接调用尚未挂载的 ref',
)
assert.ok(
!source.includes('afterSalesPanelRef.current?.openCreateModal(state.createOrder)'),
'不能在 setActiveTab 的同一次同步执行里调用 ref(此时面板还没挂载,调用会被静默吞掉)',
)
assert.ok(
source.includes('}, [visibleActiveTab, pendingAfterSalesOrder])'),
'第二个 effect 必须在售后 Tab 激活后重跑',
)
assert.ok(
source.includes("if (visibleActiveTab !== 'after-sales' || !pendingAfterSalesOrder) return"),
'只有切到售后 Tab 且存在待打开工单时才处理',
)
assert.ok(
source.includes('afterSalesPanelRef.current.openCreateModal(pendingAfterSalesOrder)'),
'面板挂载后必须真正打开建单弹窗',
)
})
test('售后处置弹窗:默认金额取后端建议值,并展示发单金额与拼单份数', () => {
const source = readSource('../src/pages/admin/panels/AfterSalesPanel.tsx')
assert.ok(
source.includes('suggestedRecoveryAmount'),
'默认追缴金额必须使用 suggestedRecoveryAmount',
)
assert.ok(
source.includes('existingRecoveryAmount > 0'),
'已追缴过的售后必须沿用原有追缴金额,不能被建议值覆盖',
)
assert.ok(source.includes('afterSalesCase.workOrderRewardAmount'), '整单必须展示工单发单金额')
assert.ok(source.includes('afterSalesCase.shareUnitReward'), '拼单必须展示一份价格')
assert.ok(source.includes('afterSalesCase.shareRewardAmount'), '拼单必须展示份额合计金额')
assert.ok(source.includes('afterSalesCase.shareQuantity'), '拼单必须展示已拼份数')
assert.ok(source.includes('(整单)'), '整单场景要有明确标识')
})
test('拼单处置:默认一份,可输入份数并按「一份价格 × 份数」联动追缴金额;整单不出现份数输入', () => {
const source = readSource('../src/pages/admin/panels/AfterSalesPanel.tsx')
assert.ok(source.includes('recoveryQuantity?: number'), '表单值类型必须支持追缴份数')
assert.ok(source.includes('label="追缴份数"'), '拼单必须提供追缴份数输入框')
assert.ok(source.includes('name="recoveryQuantity"'), '份数输入必须绑定 recoveryQuantity')
assert.ok(source.includes('min={1}'), '份数至少 1 份')
assert.ok(
source.includes('Math.max(1, Number(afterSalesCase.shareQuantity || 1))'),
'份数上限必须是该责任份额的份数',
)
assert.ok(source.includes('onValuesChange'), '份数变化必须联动追缴金额')
assert.ok(
source.includes('changedValues.recoveryQuantity === undefined'),
'只有份数变化才重算金额,手动改金额不能被覆盖',
)
assert.ok(
source.includes('Number(afterSalesCase.shareUnitReward || 0)) * quantity') ||
source.includes('Number(afterSalesCase.shareUnitReward || 0)'),
'联动金额必须按一份价格乘以份数计算',
)
assert.ok(source.includes('resolutionRecoveryQuantity'), '弹窗必须回显当前追缴份数')
// 份数输入必须只在 shareId 分支(拼单)渲染,整单只展示发单金额。
const shareBranch = source.indexOf('afterSalesCase.shareId ? (')
const quantityLabel = source.indexOf('label="追缴份数"')
assert.ok(shareBranch > 0 && quantityLabel > shareBranch, '份数输入必须在拼单分支内')
const wholeOrderAlert = source.indexOf('(整单)')
const nextRecoveryField = source.indexOf('label="追缴金额"', wholeOrderAlert)
assert.ok(wholeOrderAlert > quantityLabel, '整单分支应在拼单分支之后')
assert.ok(
!source.slice(wholeOrderAlert, nextRecoveryField).includes('recoveryQuantity'),
'整单分支不能包含份数输入',
)
})
test('建单份额下拉:每个份额要显示份数、一份价格与合计金额', () => {
const source = readSource('../src/pages/admin/panels/AfterSalesPanel.tsx')
assert.ok(source.includes('`${share.worker?.displayName'), '份额下拉仍要显示打手名')
assert.ok(source.includes('一份 ${formatMoney(share.unitReward)}'), '份额下拉必须显示一份价格')
assert.ok(source.includes('合计 ${formatMoney(share.shareReward)}'), '份额下拉必须显示合计金额')
})
test('售后单类型必须包含金额字段,否则前端读取为 undefined', () => {
const source = readSource('../src/types/worker-platform.ts')
for (const field of [
'workOrderRewardAmount: number',
'sharingEnabled: boolean',
'shareUnitReward: number',
'shareRewardAmount: number',
'suggestedRecoveryAmount: number',
]) {
assert.ok(source.includes(field), `WorkerAfterSalesCase 缺少 ${field}`)
}
})