From f2e08a4fbca5f32ed6462d99fd3d83e7297267c1 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 14 Aug 2026 18:13:21 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E8=AE=A2=E5=8D=95=E8=B5=84=E6=96=99?= =?UTF-8?q?=E5=9B=BE=E7=89=87=E5=AE=8C=E6=95=B4=E5=B1=95=E7=A4=BA=E4=B8=8E?= =?UTF-8?q?=E6=89=93=E6=89=8B=E5=BF=AB=E6=8D=B7=E5=A4=8D=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 修复资料截图 URL 过期问题:读取工单时统一重签截图访问链接(后台/打手端/收集页) - 修复后台手动补资料截图从未保存的问题(提交真实上传列表) - 收集页提交后回显已上传截图,补资料弹窗重开回显已有截图 - 打手端我的订单新增快捷复制:复制信息(多行标签:值)+ 一键复制图片(JPEG 自动转 PNG) - 快捷复制列与后台资料列同款展示:全字段逐行 + 截图缩略图 - 后台接单工单资料列优化:显示全部字段与截图缩略图 - 详情抽屉移除冗余的资料要求卡片 --- .../src/services/worker-platform/mappers.ts | 26 ++- .../pages/admin/panels/WorkOrdersPanel.tsx | 17 +- .../src/pages/admin/panels/shared.tsx | 13 +- .../src/pages/collect/CollectPage.tsx | 6 +- .../src/pages/worker/WorkerOrdersPage.tsx | 175 +++++++++++++++--- apps/frontend/src/types/worker-platform.ts | 1 + 6 files changed, 191 insertions(+), 47 deletions(-) diff --git a/apps/backend/src/services/worker-platform/mappers.ts b/apps/backend/src/services/worker-platform/mappers.ts index 84a5ad49..6a22e10e 100644 --- a/apps/backend/src/services/worker-platform/mappers.ts +++ b/apps/backend/src/services/worker-platform/mappers.ts @@ -345,6 +345,25 @@ export function mapFinanceRequest(request: WorkerFinanceRequestRow | null | unde } } +/** 读取工单资料:刷新买家主页截图等文件的访问链接(签名 15 分钟过期,读取时必须重签) */ +export function mapWorkOrderMaterial(value: unknown) { + const material = safeParseJson(value) + const collect = + material.collect && typeof material.collect === 'object' && !Array.isArray(material.collect) + ? (material.collect as JsonObject) + : null + if (!collect) { + return material + } + return { + ...material, + collect: { + ...collect, + screenshots: normalizeUploadedFiles(collect.screenshots).map(refreshUploadedFileUrls), + }, + } +} + export function mapWorkOrderAdmin(workOrder: WorkOrderRow) { return { workOrderId: Number(workOrder.id), @@ -369,7 +388,7 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) { depositThresholdAmount: Number( workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT, ), - material: safeParseJson(workOrder.material_json), + material: mapWorkOrderMaterial(workOrder.material_json), requirement: { fields: resolveRequirementFields(workOrder), }, @@ -448,10 +467,15 @@ export function mapWorkOrderPublic(workOrder: WorkOrderRow) { } export function mapCollectLookupOrder(workOrder: WorkOrderRow) { + const material = mapWorkOrderMaterial(workOrder.material_json) return { ...mapWorkOrderPublic(workOrder), fields: resolveRequirementFields(workOrder), values: resolveCollectSubmittedFieldValues(workOrder), + screenshots: + material.collect && Array.isArray(material.collect.screenshots) + ? material.collect.screenshots + : [], } } diff --git a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx index aca9391b..1f4b70b5 100644 --- a/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/WorkOrdersPanel.tsx @@ -102,7 +102,6 @@ import { getRequirementFields, renderDetailFieldValues, renderMaterialSummary, - renderRequirementFieldValues, asRecord, formatMoney, formatSharingShareStatus, @@ -332,17 +331,14 @@ export default function WorkOrdersPanel() { resolutionForm.resetFields() } - async function submitMaterial(values: { - fields?: Record - screenshots?: UploadedFile[] - }) { + async function submitMaterial(values: { fields?: Record }) { if (!materialOrder) return const succeeded = await runAction( () => submitAdminWorkOrderMaterial( materialOrder.workOrderId, values.fields || {}, - values.screenshots || [], + materialScreenshots, ), '订单资料已保存', ) @@ -367,7 +363,7 @@ export default function WorkOrdersPanel() { function openMaterialModal(row: WorkOrder) { setMaterialOrder(row) materialForm.setFieldsValue({ fields: getMaterialFields(row) }) - setMaterialScreenshots([]) + setMaterialScreenshots(getMaterialScreenshots(row)) } function openDetailDrawer(row: WorkOrder) { @@ -590,7 +586,7 @@ export default function WorkOrdersPanel() { }, { title: '资料', - width: 160, + width: 240, render: (_, row) => renderMaterialSummary(row), }, { @@ -929,10 +925,6 @@ export default function WorkOrdersPanel() { - - {renderRequirementFieldValues(detailOrder)} - -
@@ -1190,6 +1182,7 @@ export default function WorkOrdersPanel() { setMaterialOrder(null) setMaterialPasteText('') materialForm.resetFields() + setMaterialScreenshots([]) }} onOk={() => materialForm.submit()} destroyOnHidden diff --git a/apps/frontend/src/pages/admin/panels/shared.tsx b/apps/frontend/src/pages/admin/panels/shared.tsx index 08aec386..d07a10c3 100644 --- a/apps/frontend/src/pages/admin/panels/shared.tsx +++ b/apps/frontend/src/pages/admin/panels/shared.tsx @@ -142,21 +142,20 @@ export function renderMaterialSummary(order: WorkOrder) { const entries = Object.entries(fields).filter(([, value]) => String(value || '').trim(), ) - if (entries.length === 0) { + const screenshots = getMaterialScreenshots(order) + if (entries.length === 0 && screenshots.length === 0) { return - } return (
- {entries.slice(0, 2).map(([key, value]) => ( - + {entries.map(([key, value]) => ( + {fieldLabelMap.get(key) || key}: {value} ))} - {entries.length > 2 ? ( - - 还有 {entries.length - 2} 项 - + {screenshots.length > 0 ? ( + ) : null}
) diff --git a/apps/frontend/src/pages/collect/CollectPage.tsx b/apps/frontend/src/pages/collect/CollectPage.tsx index dfcc384c..ed8f14b5 100644 --- a/apps/frontend/src/pages/collect/CollectPage.tsx +++ b/apps/frontend/src/pages/collect/CollectPage.tsx @@ -124,7 +124,11 @@ export default function CollectPage() { maxCount={5} showPasteHint captureGlobalPaste - value={screenshotsByOrder[currentOrder.workOrderId] || []} + value={ + screenshotsByOrder[currentOrder.workOrderId] ?? + currentOrder.screenshots ?? + [] + } onChange={(files) => setScreenshotsByOrder((prev) => ({ ...prev, diff --git a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx index 9b797bc6..24e207a3 100644 --- a/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx +++ b/apps/frontend/src/pages/worker/WorkerOrdersPage.tsx @@ -1,6 +1,8 @@ import { CheckOutlined, + CopyOutlined, EyeOutlined, + PictureOutlined, ReloadOutlined, SearchOutlined, } from '@ant-design/icons' @@ -102,6 +104,49 @@ export default function WorkerOrdersPage() { ]) } + function copyOrderInfo(order: WorkOrder) { + const items = getMaterialDetailItems(order).filter((item) => + String(item.value || '').trim(), + ) + if (items.length === 0) { + message.warning('该订单暂无资料信息') + return + } + const text = items.map((item) => `${item.label}: ${item.value}`).join('\n') + navigator.clipboard + .writeText(text) + .then(() => message.success('订单信息已复制')) + .catch(() => message.error('复制失败,请手动复制')) + } + + async function copyMaterialImages(order: WorkOrder) { + const screenshots = getMaterialScreenshots(order) + if (screenshots.length === 0) { + message.warning('该订单暂无买家主页截图') + return + } + if (typeof ClipboardItem === 'undefined') { + message.error('当前浏览器不支持一键复制图片,请手动下载后发送') + return + } + try { + const blobs = await Promise.all( + screenshots.map(async (file) => { + const url = file.mediumUrl || file.url + const response = await fetch(url) + if (!response.ok) throw new Error(`图片加载失败 ${response.status}`) + // Chrome 剪贴板写入仅支持 image/png,统一转成 PNG + return toPngBlob(await response.blob()) + }), + ) + const items = blobs.map((blob) => new ClipboardItem({ 'image/png': blob })) + await navigator.clipboard.write(items) + message.success(`已复制 ${blobs.length} 张图片,可直接粘贴发送`) + } catch (error) { + message.error(error instanceof Error ? error.message : '复制图片失败,请手动下载') + } + } + function openAcceptanceModal(order: WorkOrder) { setSubmittingOrder(order) setAcceptanceFiles([]) @@ -242,6 +287,53 @@ export default function WorkerOrdersPage() { ) }, }, + { + title: '快捷复制', + width: 280, + render: (_, row) => { + const items = getMaterialDetailItems(row).filter((item) => + String(item.value || '').trim(), + ) + const screenshots = getMaterialScreenshots(row) + return ( +
+ {items.length > 0 ? ( + items.map((item) => ( + + {item.label}: {item.value} + + )) + ) : ( + + 暂无资料信息 + + )} + {screenshots.length > 0 ? ( + + ) : null} + + + {screenshots.length > 0 ? ( + + ) : null} + +
+ ) + }, + }, { title: '操作', width: 260, @@ -459,10 +551,14 @@ export default function WorkerOrdersPage() { getMaterialDetailItems(detailOrder), '当前还没有资料内容', )} - - - - {renderRequirementFieldValues(detailOrder)} + {getMaterialScreenshots(detailOrder).length > 0 ? ( +
+ 买家主页截图 +
+ +
+
+ ) : null}
@@ -578,6 +674,55 @@ function getMaterialFields(order: WorkOrder): Record { ) } +/** 把任意图片 Blob 转成 PNG(Chrome 剪贴板写入只支持 image/png) */ +async function toPngBlob(source: Blob): Promise { + const url = URL.createObjectURL(source) + try { + const image = await loadImageElement(url) + const canvas = document.createElement('canvas') + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext('2d') + if (!context) return source + context.drawImage(image, 0, 0) + const png = await new Promise((resolve) => + canvas.toBlob(resolve, 'image/png'), + ) + return png || source + } finally { + URL.revokeObjectURL(url) + } +} + +function loadImageElement(src: string): Promise { + return new Promise((resolve, reject) => { + const image = new Image() + image.onload = () => resolve(image) + image.onerror = () => reject(new Error('图片解码失败')) + image.src = src + }) +} + +function getMaterialScreenshots(order: WorkOrder): UploadedFile[] { + const material = asRecord(order.material) + const collect = asRecord(material.collect) + const screenshots = Array.isArray(collect.screenshots) ? collect.screenshots : [] + return screenshots + .map((item) => { + const file = asRecord(item) + const url = String(file.url || '').trim() + if (!url) return null + return { + objectKey: String(file.objectKey || file.object_key || '').trim(), + filename: String(file.originalFilename || file.filename || '').trim(), + url, + mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(), + thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(), + } as UploadedFile + }) + .filter((item): item is UploadedFile => Boolean(item)) +} + function getMaterialDetailItems(order: WorkOrder): DetailFieldItem[] { const values = getMaterialFields(order) const requiredFields = getRequirementFields(order) @@ -631,28 +776,6 @@ function renderDetailFieldValues(items: DetailFieldItem[], emptyText: string) { ) } -function renderRequirementFieldValues(order: WorkOrder) { - const fields = getRequirementFields(order) - if (fields.length === 0) { - return 当前工单未配置资料要求 - } - - return ( - - {fields.map((field) => ( - - - {field.key} - - {field.required ? '必填' : '选填'} - - - - ))} - - ) -} - function getAcceptanceFiles(row: WorkOrder): UploadedFile[] { return Array.isArray(row.acceptance?.files) ? row.acceptance.files : [] } diff --git a/apps/frontend/src/types/worker-platform.ts b/apps/frontend/src/types/worker-platform.ts index af2838b3..fff18673 100644 --- a/apps/frontend/src/types/worker-platform.ts +++ b/apps/frontend/src/types/worker-platform.ts @@ -295,6 +295,7 @@ export type CollectLookupOrder = { status: string fields: CollectField[] values?: Record + screenshots?: UploadedFile[] } export type CollectLookupResponse = {