feat: 优化接单工单资料补充体验

- 区服改为非必填,保持 QQ区/微信区 单选;游戏编号/昵称保持输入框,系统保持安卓/苹果单选
- 后台工单 API 归一化输出资料字段,后台人工补充资料弹窗与收集页字段一致
- 人工补充资料弹窗单选字段由下拉框改为直接点选(Radio)
- 买家主页截图支持全局剪贴板粘贴(Ctrl+V/⌘V),并提示点击/粘贴/拖拽三种上传方式
This commit is contained in:
yml2213
2026-08-14 16:05:55 +08:00
parent 951d0574b1
commit 88b205f4fd
6 changed files with 139 additions and 13 deletions
@@ -365,7 +365,9 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
),
material: safeParseJson(workOrder.material_json),
requirement: safeParseJson(workOrder.requirement_json),
requirement: {
fields: resolveRequirementFields(workOrder),
},
acceptance: mapAcceptanceForResponse(workOrder.acceptance_json),
problemNote: workOrder.problem_note || '',
worker: workOrder.assigned_worker_id
@@ -502,7 +504,7 @@ export function normalizeRequirementFields(value: unknown) {
{ key: 'gameId', label: '游戏编号', required: true, type: 'text', options: [], mockValue: 'test_uid' },
{ key: 'gameNickname', label: '游戏昵称', required: true, type: 'text', options: [], mockValue: '测试角色' },
{ key: 'system', label: '系统', required: true, type: 'select', options: ['安卓', '苹果'], mockValue: '安卓' },
{ key: 'serverZone', label: '区服', required: true, type: 'select', options: ['QQ区', '微信区'], mockValue: 'QQ区' },
{ key: 'serverZone', label: '区服', required: false, type: 'select', options: ['QQ区', '微信区'], mockValue: 'QQ区' },
]
}
@@ -2,7 +2,11 @@ import assert from 'node:assert/strict'
import test from 'node:test'
import type { WorkOrderRow, WorkerUserRow } from '../../repositories/worker-platform/index.js'
import { assertWorkerLoginAllowed, resolveCollectSubmitTargetWorkOrder } from './index.js'
import {
assertWorkerLoginAllowed,
normalizeRequirementFields,
resolveCollectSubmitTargetWorkOrder,
} from './index.js'
function buildWorkerUserRow(overrides: Partial<WorkerUserRow> = {}): WorkerUserRow {
return {
@@ -67,6 +71,34 @@ test('assertWorkerLoginAllowed blocks disabled workers with worker_disabled', ()
)
})
test('normalizeRequirementFields default template keeps gameId/gameNickname as text inputs', () => {
const fields = normalizeRequirementFields([])
const gameId = fields.find((field) => field.key === 'gameId')
const gameNickname = fields.find((field) => field.key === 'gameNickname')
assert.equal(gameId?.type, 'text')
assert.equal(gameId?.required, true)
assert.deepEqual(gameId?.options, [])
assert.equal(gameNickname?.type, 'text')
assert.equal(gameNickname?.required, true)
})
test('normalizeRequirementFields default template renders system as single-select 安卓/苹果', () => {
const fields = normalizeRequirementFields([])
const system = fields.find((field) => field.key === 'system')
assert.equal(system?.type, 'select')
assert.equal(system?.required, true)
assert.deepEqual(system?.options, ['安卓', '苹果'])
})
test('normalizeRequirementFields default template marks serverZone optional with QQ区/微信区 options', () => {
const fields = normalizeRequirementFields([])
const serverZone = fields.find((field) => field.key === 'serverZone')
assert.equal(serverZone?.type, 'select')
assert.equal(serverZone?.required, false)
assert.deepEqual(serverZone?.options, ['QQ区', '微信区'])
})
function buildWorkOrderRow(
overrides: Partial<WorkOrderRow> = {},
): WorkOrderRow {
@@ -1,7 +1,8 @@
import { PlusOutlined } from '@ant-design/icons'
import { App, Typography, Upload } from 'antd'
import type { UploadFile, UploadProps } from 'antd'
import type { ClipboardEvent, DragEvent } from 'react'
import { useEffect, useRef } from 'react'
import type { ClipboardEvent, DragEvent, FocusEvent, MouseEvent } from 'react'
import { uploadFile } from '@/services/files'
import type { UploadedFile } from '@/types/worker-platform'
@@ -13,6 +14,42 @@ type ImageUploadProps = {
scope: 'admin' | 'worker' | 'collect'
maxCount?: number
showPasteHint?: boolean
/** 开启后,页面任意位置粘贴剪贴板截图都会上传到本组件(多实例时以最近点击/挂载的为准) */
captureGlobalPaste?: boolean
}
type GlobalPasteTarget = {
id: number
uploadImages: (files: File[]) => void
}
let nextTargetId = 1
const globalPasteTargets: GlobalPasteTarget[] = []
let globalPasteListenerAttached = false
function handleDocumentPaste(event: DocumentEventMap['paste']) {
const files = Array.from(event.clipboardData?.files || [])
const images = files.filter((file) => file.type.startsWith('image/'))
if (images.length === 0) return
const target = globalPasteTargets[globalPasteTargets.length - 1]
if (!target || event.defaultPrevented) return
event.preventDefault()
target.uploadImages(images)
}
function ensureGlobalPasteListener() {
if (globalPasteListenerAttached) return
globalPasteListenerAttached = true
document.addEventListener('paste', handleDocumentPaste)
}
function registerGlobalPasteTarget(target: GlobalPasteTarget): () => void {
globalPasteTargets.push(target)
ensureGlobalPasteListener()
return () => {
const index = globalPasteTargets.findIndex((item) => item.id === target.id)
if (index >= 0) globalPasteTargets.splice(index, 1)
}
}
export default function ImageUpload({
@@ -22,8 +59,13 @@ export default function ImageUpload({
scope,
maxCount = 6,
showPasteHint = false,
captureGlobalPaste = false,
}: ImageUploadProps) {
const { message } = App.useApp()
const targetIdRef = useRef(0)
const valueRef = useRef(value)
valueRef.current = value
const fileList: UploadFile[] = value.map((file) => ({
uid: file.objectKey || file.url,
name: file.filename || '图片',
@@ -32,15 +74,13 @@ export default function ImageUpload({
thumbUrl: file.thumbnailUrl || file.url,
}))
async function uploadFiles(files: File[]) {
const images = files.filter((file) => file.type.startsWith('image/'))
if (images.length === 0) return
const remaining = maxCount - value.length
async function uploadImages(images: File[]) {
const remaining = maxCount - valueRef.current.length
if (remaining <= 0) {
message.warning(`最多上传 ${maxCount} 张图片`)
return
}
const next = [...value]
const next = [...valueRef.current]
for (const file of images.slice(0, remaining)) {
try {
const uploaded = await uploadFile(file, scene, scope)
@@ -49,7 +89,13 @@ export default function ImageUpload({
message.error(error instanceof Error ? error.message : '上传失败')
}
}
if (next.length > value.length) onChange?.(next)
if (next.length > valueRef.current.length) onChange?.(next)
}
async function uploadFiles(files: File[]) {
const images = files.filter((file) => file.type.startsWith('image/'))
if (images.length === 0) return
await uploadImages(images)
}
function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
@@ -65,6 +111,33 @@ export default function ImageUpload({
if (files.length > 0) void uploadFiles(files)
}
function activateGlobalPasteTarget() {
const index = globalPasteTargets.findIndex((item) => item.id === targetIdRef.current)
if (index < 0) return
const [target] = globalPasteTargets.splice(index, 1)
globalPasteTargets.push(target)
}
useEffect(() => {
if (!captureGlobalPaste) return
const targetId = nextTargetId++
targetIdRef.current = targetId
const unregister = registerGlobalPasteTarget({
id: targetId,
uploadImages: (files) => void uploadImages(files),
})
return unregister
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [captureGlobalPaste])
function handleWrapperFocus(event: FocusEvent<HTMLDivElement>) {
if (captureGlobalPaste) activateGlobalPasteTarget()
}
function handleWrapperClick(event: MouseEvent<HTMLDivElement>) {
if (captureGlobalPaste) activateGlobalPasteTarget()
}
const customRequest: UploadProps['customRequest'] = async (options) => {
try {
const uploaded = await uploadFile(options.file as File, scene, scope)
@@ -82,6 +155,8 @@ export default function ImageUpload({
onPaste={handlePaste}
onDrop={handleDrop}
onDragOver={(event) => event.preventDefault()}
onFocusCapture={handleWrapperFocus}
onClickCapture={handleWrapperClick}
>
<Upload
accept="image/jpeg,image/png,image/webp"
@@ -106,7 +181,7 @@ export default function ImageUpload({
</Upload>
{showPasteHint ? (
<Typography.Text type="secondary" className="image-upload-hint">
Ctrl+V / V
</Typography.Text>
) : null}
</div>
@@ -22,6 +22,7 @@ import {
Input,
InputNumber,
Modal,
Radio,
Select,
Space,
Statistic,
@@ -1116,7 +1117,7 @@ export default function WorkOrdersPanel() {
}
>
{field.type === 'select' && (field.options || []).length > 0 ? (
<Select
<Radio.Group
options={(field.options || []).map((option) => ({
label: option,
value: option,
@@ -1132,6 +1133,8 @@ export default function WorkOrdersPanel() {
scene="collect-material"
scope="admin"
maxCount={5}
showPasteHint
captureGlobalPaste
value={materialScreenshots}
onChange={setMaterialScreenshots}
/>
@@ -9,6 +9,14 @@ import { formatAdminDateTime } from '@/utils/admin-time'
export { DeadlineCountdown, formatTimeoutPolicyLabel }
type MappedRequirementField = {
key: string
label: string
required: boolean
type: 'text' | 'select'
options: string[]
}
export function getRequirementFields(order: WorkOrder | null): CollectField[] {
if (!order) return []
const rawFields = Array.isArray(order.requirement?.fields)
@@ -23,9 +31,13 @@ export function getRequirementFields(order: WorkOrder | null): CollectField[] {
key,
label: String(source.label || key).trim(),
required: source.required !== false,
type: source.type === 'select' ? ('select' as const) : ('text' as const),
options: Array.isArray(source.options)
? source.options.map((option) => String(option || '').trim()).filter(Boolean)
: [],
}
})
.filter((item): item is CollectField => Boolean(item))
.filter((item): item is MappedRequirementField => Boolean(item))
}
export type DetailFieldItem = {
@@ -122,6 +122,8 @@ export default function CollectPage() {
scene="collect-material"
scope="collect"
maxCount={5}
showPasteHint
captureGlobalPaste
value={screenshotsByOrder[currentOrder.workOrderId] || []}
onChange={(files) =>
setScreenshotsByOrder((prev) => ({