feat: 接单工单金额双击编辑与资料粘贴识别

- 表格双击发单金额/押金原地编辑,自动聚焦全选,Esc 取消
- 发单金额高于用户付款金额时保存前弹确认提示(编辑弹窗同步)
- 亏损单(发单金额>付款金额)在列表中红色加粗显示,便于定位
- Mock 工单支持填写用户付款金额(留空默认同接单金额),便于本地测试
- 人工补充资料支持客服消息剪贴板粘贴自动识别填充(系统/区服/角色名称/游戏编号,区服选填有则识别)
This commit is contained in:
yml2213
2026-08-14 17:34:13 +08:00
parent 7bb3142958
commit 85fa8f11fd
3 changed files with 295 additions and 32 deletions
@@ -797,13 +797,23 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
? rewardAmount
: 0
: normalizeAmountFen(payload.requiredDepositAmount, 0)
const paymentFen =
payload.paymentAmount === undefined &&
payload.paymentAmountYuan === undefined &&
payload.userPaymentAmount === undefined
? rewardAmount
: normalizeAmountFen(
payload.paymentAmount ?? payload.paymentAmountYuan ?? payload.userPaymentAmount,
rewardAmount,
)
const materialComplete = payload.materialComplete === true || payload.materialComplete === 'true'
const fields = normalizeRequirementFields(payload.fields)
const platformOrderId = String(
payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`,
).trim()
const productName = String(payload.productName || '指挥官秘钥1个').trim()
const material = materialComplete
const material = {
...(materialComplete
? {
collect: {
complete: true,
@@ -814,7 +824,13 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
}, {}),
},
}
: {}
: {}),
source: {
orderAmountFen: paymentFen,
syncSource: 'mock',
mock: true,
},
}
const workOrder = await createWorkOrder({
workOrderNo: randomId('WO'),
platformOrderId,
@@ -34,7 +34,7 @@ import {
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useEffect, useState } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useNavigate } from 'react-router'
import JsonPreview from '@/components/admin/JsonPreview'
@@ -126,6 +126,7 @@ export default function WorkOrdersPanel() {
const [resolutionOrder, setResolutionOrder] = useState<WorkOrder | null>(null)
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
const [materialScreenshots, setMaterialScreenshots] = useState<UploadedFile[]>([])
const [materialPasteText, setMaterialPasteText] = useState('')
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
@@ -177,6 +178,32 @@ export default function WorkOrdersPanel() {
setAssignWorkerId(undefined)
}
function applyMaterialPaste(text: string) {
if (!materialOrder) return
const recognized = parseWorkerMaterialText(text)
const keys = Object.keys(recognized) as Array<keyof typeof recognized>
if (keys.length === 0) {
message.warning('未识别到可填充的字段,请检查粘贴内容格式')
return
}
const requirementFields = getRequirementFields(materialOrder)
const availableKeys = new Set(requirementFields.map((field) => field.key))
const next: Record<string, string> = {}
const filled: string[] = []
for (const key of keys) {
if (!availableKeys.has(key)) continue
next[key] = recognized[key]
const label = requirementFields.find((field) => field.key === key)?.label || key
filled.push(`${label}=${recognized[key]}`)
}
if (filled.length === 0) {
message.warning('识别到的字段与当前资料要求不匹配')
return
}
materialForm.setFieldsValue({ fields: next })
message.success(`已自动填写:${filled.join('')}`)
}
async function submitAssign() {
if (!assignOrder || !assignWorkerId) return
const succeeded = await runAction(
@@ -396,7 +423,8 @@ export default function WorkOrdersPanel() {
timeoutPolicy?: string
}) {
if (!editOrder) return
const succeeded = await runAction(
const doSave = () =>
runAction(
() =>
updateAdminWorkOrder(editOrder.workOrderId, {
productName: String(values.productName || '').trim(),
@@ -410,6 +438,30 @@ export default function WorkOrdersPanel() {
}),
'工单信息已保存',
)
const paymentFen = getUserPaymentFen(editOrder)
const rewardFen = Math.round(Number(values.rewardAmount || 0) * 100)
if (paymentFen !== null && rewardFen > paymentFen) {
modal.confirm({
title: '发单金额高于用户付款金额',
content: (
<span>
{formatMoney(paymentFen)}{' '}
<Typography.Text strong type="danger">{formatMoney(rewardFen)}</Typography.Text>
</span>
),
okText: '确认保存',
cancelText: '取消',
onOk: async () => {
const succeeded = await doSave()
if (!succeeded) return
setEditOrder(null)
editForm.resetFields()
},
})
return
}
const succeeded = await doSave()
if (!succeeded) return
setEditOrder(null)
editForm.resetFields()
@@ -461,13 +513,17 @@ export default function WorkOrdersPanel() {
},
{
title: '发单金额',
width: 110,
render: (_, row) => formatMoney(row.rewardAmount),
width: 130,
render: (_, row) => (
<AmountEditableCell order={row} field="rewardAmount" onSaved={refreshAll} />
),
},
{
title: '押金',
width: 110,
render: (_, row) => formatMoney(row.requiredDepositAmount),
width: 130,
render: (_, row) => (
<AmountEditableCell order={row} field="requiredDepositAmount" onSaved={refreshAll} />
),
},
{
title: '时限',
@@ -1132,12 +1188,42 @@ export default function WorkOrdersPanel() {
open={Boolean(materialOrder)}
onCancel={() => {
setMaterialOrder(null)
setMaterialPasteText('')
materialForm.resetFields()
}}
onOk={() => materialForm.submit()}
destroyOnHidden
>
<Form form={materialForm} layout="vertical" onFinish={submitMaterial}>
<Form.Item label="粘贴自动识别">
<Space.Compact style={{ width: '100%' }}>
<Input.TextArea
rows={3}
value={materialPasteText}
onChange={(event) => setMaterialPasteText(event.target.value)}
onPaste={(event) => {
const text = event.clipboardData?.getData('text/plain') || ''
if (!text.trim()) return
event.preventDefault()
applyMaterialPaste(text)
setMaterialPasteText('')
}}
placeholder={
'粘贴客服消息后自动识别填充,格式如:\n安卓还是苹果(必填):安卓\nQ区还是V区(必填):Q区\n角色名称(必填):张三\n游戏编号(必填)123456'
}
/>
<Button
onClick={() => {
const text = materialPasteText.trim()
if (!text) return
applyMaterialPaste(text)
setMaterialPasteText('')
}}
>
</Button>
</Space.Compact>
</Form.Item>
{getRequirementFields(materialOrder).map((field) => (
<Form.Item
key={field.key}
@@ -1346,11 +1432,162 @@ export default function WorkOrdersPanel() {
}
function formatUserPaymentAmount(order: WorkOrder): string {
const paymentFen = getUserPaymentFen(order)
return paymentFen === null ? '-' : formatMoney(paymentFen)
}
/** 用户付款金额(分);未知返回 null(不参与超价提示) */
function getUserPaymentFen(order: WorkOrder): number | null {
const material = asRecord(order.material)
const source = asRecord(material.source)
const sendCode = asRecord(source.kuaishouSendCode)
const paymentFen = Number(sendCode.paymentFen ?? source.orderAmountFen)
return Number.isFinite(paymentFen) ? formatMoney(paymentFen) : '-'
return Number.isFinite(paymentFen) ? paymentFen : null
}
function AmountEditableCell({
order,
field,
onSaved,
}: {
order: WorkOrder
field: 'rewardAmount' | 'requiredDepositAmount'
onSaved: () => Promise<void> | void
}) {
const { message, modal } = App.useApp()
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState<number | null>(null)
const savingRef = useRef(false)
const editable =
['pending_material', 'unassigned', 'open'].includes(order.status) && !order.worker
const currentFen = Number(order[field] || 0)
const currentYuan = currentFen / 100
const paymentFen = field === 'rewardAmount' ? getUserPaymentFen(order) : null
const isLossOrder = paymentFen !== null && currentFen > paymentFen
async function doSave(nextYuan: number) {
if (savingRef.current) return
savingRef.current = true
try {
await updateAdminWorkOrder(order.workOrderId, { [field]: nextYuan })
message.success('金额已更新')
setEditing(false)
await onSaved()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
} finally {
savingRef.current = false
}
}
async function save() {
if (savingRef.current) return
if (draft === null || !Number.isFinite(Number(draft))) {
setEditing(false)
return
}
const nextYuan = Number(draft)
if (nextYuan < 0) {
message.error('金额不能为负数')
return
}
if (Math.abs(nextYuan - currentYuan) < 0.005) {
setEditing(false)
return
}
const nextFen = Math.round(nextYuan * 100)
if (paymentFen !== null && nextFen > paymentFen) {
modal.confirm({
title: '发单金额高于用户付款金额',
content: (
<span>
{formatMoney(paymentFen)}{' '}
<Typography.Text strong type="danger">{formatMoney(nextFen)}</Typography.Text>
</span>
),
okText: '确认保存',
cancelText: '取消',
onOk: () => doSave(nextYuan),
})
return
}
await doSave(nextYuan)
}
if (!editing) {
return (
<Typography.Text
title={editable ? '双击修改金额' : undefined}
strong={isLossOrder}
type={isLossOrder ? 'danger' : undefined}
style={{
cursor: editable ? 'pointer' : 'default',
...(editable && !isLossOrder ? { color: 'rgba(0,0,0,0.65)' } : {}),
}}
onDoubleClick={() => {
if (!editable) return
setDraft(currentYuan)
setEditing(true)
}}
>
{formatMoney(currentFen)}
</Typography.Text>
)
}
return (
<InputNumber
autoFocus
size="small"
min={0}
precision={2}
step={1}
style={{ width: 92 }}
value={draft}
onChange={setDraft}
onFocus={(event) => event.target.select()}
onPressEnter={() => void save()}
onBlur={() => void save()}
onKeyDown={(event) => {
if (event.key === 'Escape') setEditing(false)
}}
/>
)
}
/** 解析客服粘贴文本(如「安卓还是苹果(必填):安卓」),返回字段 key → 值 */
function parseWorkerMaterialText(text: string): Record<string, string> {
const result: Record<string, string> = {}
const lines = String(text || '')
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean)
for (const line of lines) {
const match = line.match(/^(.+?)[:]\s*(.+)$/)
if (!match) continue
const label = match[1]
.replace(/[(](必填|选填)[)]/g, '')
.trim()
.toLowerCase()
const value = match[2].trim()
if (!label || !value) continue
if (/系统|安卓|苹果/.test(label)) {
if (value.includes('苹果')) result.system = '苹果'
else if (value.includes('安卓')) result.system = '安卓'
} else if (/区/.test(label) || /服/.test(label)) {
const zoneValue = value.toLowerCase()
if (zoneValue.includes('微信') || /^v/.test(zoneValue)) result.serverZone = '微信区'
else if (zoneValue.includes('q')) result.serverZone = 'QQ区'
} else if (/角色|昵称|名称/.test(label)) {
result.gameNickname = value
} else if (/编号|游戏id|账号id|^id|uid/.test(label)) {
result.gameId = value
}
}
return result
}
function formatRequirementFieldsText(fields: CollectField[]): string {
@@ -1373,6 +1610,7 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
platformOrderId?: string
productName?: string
rewardAmount?: number
paymentAmount?: number
materialComplete?: boolean
}) {
setLoading(true)
@@ -1407,6 +1645,14 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
<Form.Item className="worker-order-mock-field-amount" label="接单金额" name="rewardAmount">
<InputNumber min={0.01} step={1} addonAfter="元" />
</Form.Item>
<Form.Item
className="worker-order-mock-field-amount"
label="用户付款金额"
name="paymentAmount"
tooltip="留空默认等于接单金额;填得比接单金额低,可测试超价提示"
>
<InputNumber min={0} step={1} addonAfter="元" placeholder="默认同接单金额" />
</Form.Item>
<Form.Item
className="worker-order-mock-field-complete"
label="资料完整"
@@ -202,6 +202,7 @@ export function createAdminMockWorkOrder(payload: {
platformOrderId?: string
productName?: string
rewardAmount?: number
paymentAmount?: number
materialComplete?: boolean
}) {
return apiPost<{ order: WorkOrder }>(