feat: 优化接单工单资料补充体验
- 区服改为非必填,保持 QQ区/微信区 单选;游戏编号/昵称保持输入框,系统保持安卓/苹果单选 - 后台工单 API 归一化输出资料字段,后台人工补充资料弹窗与收集页字段一致 - 人工补充资料弹窗单选字段由下拉框改为直接点选(Radio) - 买家主页截图支持全局剪贴板粘贴(Ctrl+V/⌘V),并提示点击/粘贴/拖拽三种上传方式
This commit is contained in:
@@ -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) => ({
|
||||
|
||||
Reference in New Issue
Block a user