feat: 接单工单金额双击编辑与资料粘贴识别
- 表格双击发单金额/押金原地编辑,自动聚焦全选,Esc 取消 - 发单金额高于用户付款金额时保存前弹确认提示(编辑弹窗同步) - 亏损单(发单金额>付款金额)在列表中红色加粗显示,便于定位 - Mock 工单支持填写用户付款金额(留空默认同接单金额),便于本地测试 - 人工补充资料支持客服消息剪贴板粘贴自动识别填充(系统/区服/角色名称/游戏编号,区服选填有则识别)
This commit is contained in:
@@ -797,24 +797,40 @@ export async function createAdminMockWorkOrder(payload: JsonObject = {}) {
|
|||||||
? rewardAmount
|
? rewardAmount
|
||||||
: 0
|
: 0
|
||||||
: normalizeAmountFen(payload.requiredDepositAmount, 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 materialComplete = payload.materialComplete === true || payload.materialComplete === 'true'
|
||||||
const fields = normalizeRequirementFields(payload.fields)
|
const fields = normalizeRequirementFields(payload.fields)
|
||||||
const platformOrderId = String(
|
const platformOrderId = String(
|
||||||
payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`,
|
payload.platformOrderId || payload.orderNo || `MOCKWORK${Date.now()}`,
|
||||||
).trim()
|
).trim()
|
||||||
const productName = String(payload.productName || '指挥官秘钥1个').trim()
|
const productName = String(payload.productName || '指挥官秘钥1个').trim()
|
||||||
const material = materialComplete
|
const material = {
|
||||||
? {
|
...(materialComplete
|
||||||
collect: {
|
? {
|
||||||
complete: true,
|
collect: {
|
||||||
submittedAt: now,
|
complete: true,
|
||||||
fields: fields.reduce<Record<string, string>>((result, field) => {
|
submittedAt: now,
|
||||||
result[field.key] = String(field.mockValue || `${field.label}测试值`)
|
fields: fields.reduce<Record<string, string>>((result, field) => {
|
||||||
return result
|
result[field.key] = String(field.mockValue || `${field.label}测试值`)
|
||||||
}, {}),
|
return result
|
||||||
},
|
}, {}),
|
||||||
}
|
},
|
||||||
: {}
|
}
|
||||||
|
: {}),
|
||||||
|
source: {
|
||||||
|
orderAmountFen: paymentFen,
|
||||||
|
syncSource: 'mock',
|
||||||
|
mock: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
const workOrder = await createWorkOrder({
|
const workOrder = await createWorkOrder({
|
||||||
workOrderNo: randomId('WO'),
|
workOrderNo: randomId('WO'),
|
||||||
platformOrderId,
|
platformOrderId,
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ import {
|
|||||||
Typography,
|
Typography,
|
||||||
} from 'antd'
|
} from 'antd'
|
||||||
import type { TableColumnsType } from 'antd'
|
import type { TableColumnsType } from 'antd'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useNavigate } from 'react-router'
|
import { useNavigate } from 'react-router'
|
||||||
|
|
||||||
import JsonPreview from '@/components/admin/JsonPreview'
|
import JsonPreview from '@/components/admin/JsonPreview'
|
||||||
@@ -126,6 +126,7 @@ export default function WorkOrdersPanel() {
|
|||||||
const [resolutionOrder, setResolutionOrder] = useState<WorkOrder | null>(null)
|
const [resolutionOrder, setResolutionOrder] = useState<WorkOrder | null>(null)
|
||||||
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
|
const [materialOrder, setMaterialOrder] = useState<WorkOrder | null>(null)
|
||||||
const [materialScreenshots, setMaterialScreenshots] = useState<UploadedFile[]>([])
|
const [materialScreenshots, setMaterialScreenshots] = useState<UploadedFile[]>([])
|
||||||
|
const [materialPasteText, setMaterialPasteText] = useState('')
|
||||||
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
const [sharingOrder, setSharingOrder] = useState<WorkOrder | null>(null)
|
||||||
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
|
const [assignOrder, setAssignOrder] = useState<WorkOrder | null>(null)
|
||||||
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
|
const [assignWorkerId, setAssignWorkerId] = useState<number | undefined>(undefined)
|
||||||
@@ -177,6 +178,32 @@ export default function WorkOrdersPanel() {
|
|||||||
setAssignWorkerId(undefined)
|
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() {
|
async function submitAssign() {
|
||||||
if (!assignOrder || !assignWorkerId) return
|
if (!assignOrder || !assignWorkerId) return
|
||||||
const succeeded = await runAction(
|
const succeeded = await runAction(
|
||||||
@@ -396,20 +423,45 @@ export default function WorkOrdersPanel() {
|
|||||||
timeoutPolicy?: string
|
timeoutPolicy?: string
|
||||||
}) {
|
}) {
|
||||||
if (!editOrder) return
|
if (!editOrder) return
|
||||||
const succeeded = await runAction(
|
const doSave = () =>
|
||||||
() =>
|
runAction(
|
||||||
updateAdminWorkOrder(editOrder.workOrderId, {
|
() =>
|
||||||
productName: String(values.productName || '').trim(),
|
updateAdminWorkOrder(editOrder.workOrderId, {
|
||||||
platformOrderId: String(values.platformOrderId || '').trim(),
|
productName: String(values.productName || '').trim(),
|
||||||
categoryId: values.categoryId || null,
|
platformOrderId: String(values.platformOrderId || '').trim(),
|
||||||
rewardAmount: Number(values.rewardAmount || 0),
|
categoryId: values.categoryId || null,
|
||||||
requiredDepositAmount: Number(values.requiredDepositAmount || 0),
|
rewardAmount: Number(values.rewardAmount || 0),
|
||||||
fieldsText: String(values.fieldsText || ''),
|
requiredDepositAmount: Number(values.requiredDepositAmount || 0),
|
||||||
timeoutMinutes: Math.max(0, Number(values.timeoutMinutes || 0)),
|
fieldsText: String(values.fieldsText || ''),
|
||||||
timeoutPolicy: values.timeoutPolicy || 'reopen',
|
timeoutMinutes: Math.max(0, Number(values.timeoutMinutes || 0)),
|
||||||
}),
|
timeoutPolicy: values.timeoutPolicy || 'reopen',
|
||||||
'工单信息已保存',
|
}),
|
||||||
)
|
'工单信息已保存',
|
||||||
|
)
|
||||||
|
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
|
if (!succeeded) return
|
||||||
setEditOrder(null)
|
setEditOrder(null)
|
||||||
editForm.resetFields()
|
editForm.resetFields()
|
||||||
@@ -461,13 +513,17 @@ export default function WorkOrdersPanel() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '发单金额',
|
title: '发单金额',
|
||||||
width: 110,
|
width: 130,
|
||||||
render: (_, row) => formatMoney(row.rewardAmount),
|
render: (_, row) => (
|
||||||
|
<AmountEditableCell order={row} field="rewardAmount" onSaved={refreshAll} />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '押金',
|
title: '押金',
|
||||||
width: 110,
|
width: 130,
|
||||||
render: (_, row) => formatMoney(row.requiredDepositAmount),
|
render: (_, row) => (
|
||||||
|
<AmountEditableCell order={row} field="requiredDepositAmount" onSaved={refreshAll} />
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '时限',
|
title: '时限',
|
||||||
@@ -1132,12 +1188,42 @@ export default function WorkOrdersPanel() {
|
|||||||
open={Boolean(materialOrder)}
|
open={Boolean(materialOrder)}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setMaterialOrder(null)
|
setMaterialOrder(null)
|
||||||
|
setMaterialPasteText('')
|
||||||
materialForm.resetFields()
|
materialForm.resetFields()
|
||||||
}}
|
}}
|
||||||
onOk={() => materialForm.submit()}
|
onOk={() => materialForm.submit()}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
>
|
>
|
||||||
<Form form={materialForm} layout="vertical" onFinish={submitMaterial}>
|
<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) => (
|
{getRequirementFields(materialOrder).map((field) => (
|
||||||
<Form.Item
|
<Form.Item
|
||||||
key={field.key}
|
key={field.key}
|
||||||
@@ -1346,11 +1432,162 @@ export default function WorkOrdersPanel() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatUserPaymentAmount(order: WorkOrder): string {
|
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 material = asRecord(order.material)
|
||||||
const source = asRecord(material.source)
|
const source = asRecord(material.source)
|
||||||
const sendCode = asRecord(source.kuaishouSendCode)
|
const sendCode = asRecord(source.kuaishouSendCode)
|
||||||
const paymentFen = Number(sendCode.paymentFen ?? source.orderAmountFen)
|
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 {
|
function formatRequirementFieldsText(fields: CollectField[]): string {
|
||||||
@@ -1373,6 +1610,7 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
|||||||
platformOrderId?: string
|
platformOrderId?: string
|
||||||
productName?: string
|
productName?: string
|
||||||
rewardAmount?: number
|
rewardAmount?: number
|
||||||
|
paymentAmount?: number
|
||||||
materialComplete?: boolean
|
materialComplete?: boolean
|
||||||
}) {
|
}) {
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
@@ -1407,6 +1645,14 @@ function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
|
|||||||
<Form.Item className="worker-order-mock-field-amount" label="接单金额" name="rewardAmount">
|
<Form.Item className="worker-order-mock-field-amount" label="接单金额" name="rewardAmount">
|
||||||
<InputNumber min={0.01} step={1} addonAfter="元" />
|
<InputNumber min={0.01} step={1} addonAfter="元" />
|
||||||
</Form.Item>
|
</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
|
<Form.Item
|
||||||
className="worker-order-mock-field-complete"
|
className="worker-order-mock-field-complete"
|
||||||
label="资料完整"
|
label="资料完整"
|
||||||
|
|||||||
@@ -202,6 +202,7 @@ export function createAdminMockWorkOrder(payload: {
|
|||||||
platformOrderId?: string
|
platformOrderId?: string
|
||||||
productName?: string
|
productName?: string
|
||||||
rewardAmount?: number
|
rewardAmount?: number
|
||||||
|
paymentAmount?: number
|
||||||
materialComplete?: boolean
|
materialComplete?: boolean
|
||||||
}) {
|
}) {
|
||||||
return apiPost<{ order: WorkOrder }>(
|
return apiPost<{ order: WorkOrder }>(
|
||||||
|
|||||||
Reference in New Issue
Block a user