diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index a9ddabea..98f3d1c7 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -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区' }, ] } diff --git a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts index 45f96eda..96aaa788 100644 --- a/apps/backend/src/services/worker-platform/worker-platform-service.test.ts +++ b/apps/backend/src/services/worker-platform/worker-platform-service.test.ts @@ -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 { 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 { diff --git a/apps/frontend/src/components/files/ImageUpload.tsx b/apps/frontend/src/components/files/ImageUpload.tsx index 4beb6c8d..fc9b0f10 100644 --- a/apps/frontend/src/components/files/ImageUpload.tsx +++ b/apps/frontend/src/components/files/ImageUpload.tsx @@ -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) { @@ -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) { + if (captureGlobalPaste) activateGlobalPasteTarget() + } + + function handleWrapperClick(event: MouseEvent) { + 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} > {showPasteHint ? ( - 支持点击选择、粘贴剪贴板截图或拖拽图片上传 + 点击选择图片上传;截图后可直接按 Ctrl+V / ⌘V 粘贴,或拖拽图片到此处 ) : null} diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index c0511262..e5a5abee 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -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 ? ( -