feat: 接单工单金额双击编辑与资料粘贴识别
- 表格双击发单金额/押金原地编辑,自动聚焦全选,Esc 取消 - 发单金额高于用户付款金额时保存前弹确认提示(编辑弹窗同步) - 亏损单(发单金额>付款金额)在列表中红色加粗显示,便于定位 - Mock 工单支持填写用户付款金额(留空默认同接单金额),便于本地测试 - 人工补充资料支持客服消息剪贴板粘贴自动识别填充(系统/区服/角色名称/游戏编号,区服选填有则识别)
This commit is contained in:
@@ -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 }>(
|
||||
|
||||
Reference in New Issue
Block a user