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,
|
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||||
),
|
),
|
||||||
material: safeParseJson(workOrder.material_json),
|
material: safeParseJson(workOrder.material_json),
|
||||||
requirement: safeParseJson(workOrder.requirement_json),
|
requirement: {
|
||||||
|
fields: resolveRequirementFields(workOrder),
|
||||||
|
},
|
||||||
acceptance: mapAcceptanceForResponse(workOrder.acceptance_json),
|
acceptance: mapAcceptanceForResponse(workOrder.acceptance_json),
|
||||||
problemNote: workOrder.problem_note || '',
|
problemNote: workOrder.problem_note || '',
|
||||||
worker: workOrder.assigned_worker_id
|
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: 'gameId', label: '游戏编号', required: true, type: 'text', options: [], mockValue: 'test_uid' },
|
||||||
{ key: 'gameNickname', label: '游戏昵称', required: true, type: 'text', options: [], mockValue: '测试角色' },
|
{ key: 'gameNickname', label: '游戏昵称', required: true, type: 'text', options: [], mockValue: '测试角色' },
|
||||||
{ key: 'system', label: '系统', required: true, type: 'select', 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 test from 'node:test'
|
||||||
|
|
||||||
import type { WorkOrderRow, WorkerUserRow } from '../../repositories/worker-platform/index.js'
|
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 {
|
function buildWorkerUserRow(overrides: Partial<WorkerUserRow> = {}): WorkerUserRow {
|
||||||
return {
|
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(
|
function buildWorkOrderRow(
|
||||||
overrides: Partial<WorkOrderRow> = {},
|
overrides: Partial<WorkOrderRow> = {},
|
||||||
): WorkOrderRow {
|
): WorkOrderRow {
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { PlusOutlined } from '@ant-design/icons'
|
import { PlusOutlined } from '@ant-design/icons'
|
||||||
import { App, Typography, Upload } from 'antd'
|
import { App, Typography, Upload } from 'antd'
|
||||||
import type { UploadFile, UploadProps } 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 { uploadFile } from '@/services/files'
|
||||||
import type { UploadedFile } from '@/types/worker-platform'
|
import type { UploadedFile } from '@/types/worker-platform'
|
||||||
@@ -13,6 +14,42 @@ type ImageUploadProps = {
|
|||||||
scope: 'admin' | 'worker' | 'collect'
|
scope: 'admin' | 'worker' | 'collect'
|
||||||
maxCount?: number
|
maxCount?: number
|
||||||
showPasteHint?: boolean
|
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({
|
export default function ImageUpload({
|
||||||
@@ -22,8 +59,13 @@ export default function ImageUpload({
|
|||||||
scope,
|
scope,
|
||||||
maxCount = 6,
|
maxCount = 6,
|
||||||
showPasteHint = false,
|
showPasteHint = false,
|
||||||
|
captureGlobalPaste = false,
|
||||||
}: ImageUploadProps) {
|
}: ImageUploadProps) {
|
||||||
const { message } = App.useApp()
|
const { message } = App.useApp()
|
||||||
|
const targetIdRef = useRef(0)
|
||||||
|
const valueRef = useRef(value)
|
||||||
|
valueRef.current = value
|
||||||
|
|
||||||
const fileList: UploadFile[] = value.map((file) => ({
|
const fileList: UploadFile[] = value.map((file) => ({
|
||||||
uid: file.objectKey || file.url,
|
uid: file.objectKey || file.url,
|
||||||
name: file.filename || '图片',
|
name: file.filename || '图片',
|
||||||
@@ -32,15 +74,13 @@ export default function ImageUpload({
|
|||||||
thumbUrl: file.thumbnailUrl || file.url,
|
thumbUrl: file.thumbnailUrl || file.url,
|
||||||
}))
|
}))
|
||||||
|
|
||||||
async function uploadFiles(files: File[]) {
|
async function uploadImages(images: File[]) {
|
||||||
const images = files.filter((file) => file.type.startsWith('image/'))
|
const remaining = maxCount - valueRef.current.length
|
||||||
if (images.length === 0) return
|
|
||||||
const remaining = maxCount - value.length
|
|
||||||
if (remaining <= 0) {
|
if (remaining <= 0) {
|
||||||
message.warning(`最多上传 ${maxCount} 张图片`)
|
message.warning(`最多上传 ${maxCount} 张图片`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const next = [...value]
|
const next = [...valueRef.current]
|
||||||
for (const file of images.slice(0, remaining)) {
|
for (const file of images.slice(0, remaining)) {
|
||||||
try {
|
try {
|
||||||
const uploaded = await uploadFile(file, scene, scope)
|
const uploaded = await uploadFile(file, scene, scope)
|
||||||
@@ -49,7 +89,13 @@ export default function ImageUpload({
|
|||||||
message.error(error instanceof Error ? error.message : '上传失败')
|
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>) {
|
function handlePaste(event: ClipboardEvent<HTMLDivElement>) {
|
||||||
@@ -65,6 +111,33 @@ export default function ImageUpload({
|
|||||||
if (files.length > 0) void uploadFiles(files)
|
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) => {
|
const customRequest: UploadProps['customRequest'] = async (options) => {
|
||||||
try {
|
try {
|
||||||
const uploaded = await uploadFile(options.file as File, scene, scope)
|
const uploaded = await uploadFile(options.file as File, scene, scope)
|
||||||
@@ -82,6 +155,8 @@ export default function ImageUpload({
|
|||||||
onPaste={handlePaste}
|
onPaste={handlePaste}
|
||||||
onDrop={handleDrop}
|
onDrop={handleDrop}
|
||||||
onDragOver={(event) => event.preventDefault()}
|
onDragOver={(event) => event.preventDefault()}
|
||||||
|
onFocusCapture={handleWrapperFocus}
|
||||||
|
onClickCapture={handleWrapperClick}
|
||||||
>
|
>
|
||||||
<Upload
|
<Upload
|
||||||
accept="image/jpeg,image/png,image/webp"
|
accept="image/jpeg,image/png,image/webp"
|
||||||
@@ -106,7 +181,7 @@ export default function ImageUpload({
|
|||||||
</Upload>
|
</Upload>
|
||||||
{showPasteHint ? (
|
{showPasteHint ? (
|
||||||
<Typography.Text type="secondary" className="image-upload-hint">
|
<Typography.Text type="secondary" className="image-upload-hint">
|
||||||
支持点击选择、粘贴剪贴板截图或拖拽图片上传
|
点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
InputNumber,
|
InputNumber,
|
||||||
Modal,
|
Modal,
|
||||||
|
Radio,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
Statistic,
|
Statistic,
|
||||||
@@ -1116,7 +1117,7 @@ export default function WorkOrdersPanel() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{field.type === 'select' && (field.options || []).length > 0 ? (
|
{field.type === 'select' && (field.options || []).length > 0 ? (
|
||||||
<Select
|
<Radio.Group
|
||||||
options={(field.options || []).map((option) => ({
|
options={(field.options || []).map((option) => ({
|
||||||
label: option,
|
label: option,
|
||||||
value: option,
|
value: option,
|
||||||
@@ -1132,6 +1133,8 @@ export default function WorkOrdersPanel() {
|
|||||||
scene="collect-material"
|
scene="collect-material"
|
||||||
scope="admin"
|
scope="admin"
|
||||||
maxCount={5}
|
maxCount={5}
|
||||||
|
showPasteHint
|
||||||
|
captureGlobalPaste
|
||||||
value={materialScreenshots}
|
value={materialScreenshots}
|
||||||
onChange={setMaterialScreenshots}
|
onChange={setMaterialScreenshots}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ import { formatAdminDateTime } from '@/utils/admin-time'
|
|||||||
|
|
||||||
export { DeadlineCountdown, formatTimeoutPolicyLabel }
|
export { DeadlineCountdown, formatTimeoutPolicyLabel }
|
||||||
|
|
||||||
|
type MappedRequirementField = {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
required: boolean
|
||||||
|
type: 'text' | 'select'
|
||||||
|
options: string[]
|
||||||
|
}
|
||||||
|
|
||||||
export function getRequirementFields(order: WorkOrder | null): CollectField[] {
|
export function getRequirementFields(order: WorkOrder | null): CollectField[] {
|
||||||
if (!order) return []
|
if (!order) return []
|
||||||
const rawFields = Array.isArray(order.requirement?.fields)
|
const rawFields = Array.isArray(order.requirement?.fields)
|
||||||
@@ -23,9 +31,13 @@ export function getRequirementFields(order: WorkOrder | null): CollectField[] {
|
|||||||
key,
|
key,
|
||||||
label: String(source.label || key).trim(),
|
label: String(source.label || key).trim(),
|
||||||
required: source.required !== false,
|
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 = {
|
export type DetailFieldItem = {
|
||||||
|
|||||||
@@ -122,6 +122,8 @@ export default function CollectPage() {
|
|||||||
scene="collect-material"
|
scene="collect-material"
|
||||||
scope="collect"
|
scope="collect"
|
||||||
maxCount={5}
|
maxCount={5}
|
||||||
|
showPasteHint
|
||||||
|
captureGlobalPaste
|
||||||
value={screenshotsByOrder[currentOrder.workOrderId] || []}
|
value={screenshotsByOrder[currentOrder.workOrderId] || []}
|
||||||
onChange={(files) =>
|
onChange={(files) =>
|
||||||
setScreenshotsByOrder((prev) => ({
|
setScreenshotsByOrder((prev) => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user