feat: 订单资料图片完整展示与打手快捷复制

- 修复资料截图 URL 过期问题:读取工单时统一重签截图访问链接(后台/打手端/收集页)
- 修复后台手动补资料截图从未保存的问题(提交真实上传列表)
- 收集页提交后回显已上传截图,补资料弹窗重开回显已有截图
- 打手端我的订单新增快捷复制:复制信息(多行标签:值)+ 一键复制图片(JPEG 自动转 PNG)
- 快捷复制列与后台资料列同款展示:全字段逐行 + 截图缩略图
- 后台接单工单资料列优化:显示全部字段与截图缩略图
- 详情抽屉移除冗余的资料要求卡片
This commit is contained in:
yml2213
2026-08-14 18:13:21 +08:00
parent 85fa8f11fd
commit f2e08a4fbc
6 changed files with 191 additions and 47 deletions
@@ -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<string, string>
screenshots?: UploadedFile[]
}) {
async function submitMaterial(values: { fields?: Record<string, string> }) {
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() {
</div>
</Card>
<Card title="资料要求" size="small">
{renderRequirementFieldValues(detailOrder)}
</Card>
<Card title="验收信息" size="small">
<div className="worker-order-detail-stack">
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
@@ -1190,6 +1182,7 @@ export default function WorkOrdersPanel() {
setMaterialOrder(null)
setMaterialPasteText('')
materialForm.resetFields()
setMaterialScreenshots([])
}}
onOk={() => materialForm.submit()}
destroyOnHidden
@@ -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 <Typography.Text type="secondary">-</Typography.Text>
}
return (
<div className="cell-stack">
{entries.slice(0, 2).map(([key, value]) => (
<Typography.Text key={key} ellipsis>
{entries.map(([key, value]) => (
<Typography.Text key={key} style={{ fontSize: 12 }} ellipsis>
{fieldLabelMap.get(key) || key}: {value}
</Typography.Text>
))}
{entries.length > 2 ? (
<Typography.Text type="secondary">
{entries.length - 2}
</Typography.Text>
{screenshots.length > 0 ? (
<ImagePreviewList files={screenshots} size={36} />
) : null}
</div>
)
@@ -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,
@@ -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 (
<div className="cell-stack">
{items.length > 0 ? (
items.map((item) => (
<Typography.Text key={item.key} style={{ fontSize: 12 }} ellipsis>
{item.label}: {item.value}
</Typography.Text>
))
) : (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
)}
{screenshots.length > 0 ? (
<ImagePreviewList files={screenshots} size={36} />
) : null}
<Space wrap>
<Button
size="small"
icon={<CopyOutlined />}
disabled={items.length === 0}
onClick={() => copyOrderInfo(row)}
>
</Button>
{screenshots.length > 0 ? (
<Button
size="small"
icon={<PictureOutlined />}
onClick={() => copyMaterialImages(row)}
>
</Button>
) : null}
</Space>
</div>
)
},
},
{
title: '操作',
width: 260,
@@ -459,10 +551,14 @@ export default function WorkerOrdersPage() {
getMaterialDetailItems(detailOrder),
'当前还没有资料内容',
)}
</Card>
<Card title="资料要求" size="small">
{renderRequirementFieldValues(detailOrder)}
{getMaterialScreenshots(detailOrder).length > 0 ? (
<div style={{ marginTop: 12 }}>
<Typography.Text type="secondary"></Typography.Text>
<div style={{ marginTop: 8 }}>
<ImagePreviewList files={getMaterialScreenshots(detailOrder)} size={72} />
</div>
</div>
) : null}
</Card>
<Card title="验收信息" size="small">
@@ -578,6 +674,55 @@ function getMaterialFields(order: WorkOrder): Record<string, string> {
)
}
/** 把任意图片 Blob 转成 PNGChrome 剪贴板写入只支持 image/png */
async function toPngBlob(source: Blob): Promise<Blob> {
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<Blob | null>((resolve) =>
canvas.toBlob(resolve, 'image/png'),
)
return png || source
} finally {
URL.revokeObjectURL(url)
}
}
function loadImageElement(src: string): Promise<HTMLImageElement> {
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 <Typography.Text type="secondary"></Typography.Text>
}
return (
<Descriptions column={1} bordered size="small">
{fields.map((field) => (
<Descriptions.Item key={field.key} label={field.label}>
<Space size={6} wrap>
<Typography.Text code>{field.key}</Typography.Text>
<Tag color={field.required ? 'orange' : 'default'}>
{field.required ? '必填' : '选填'}
</Tag>
</Space>
</Descriptions.Item>
))}
</Descriptions>
)
}
function getAcceptanceFiles(row: WorkOrder): UploadedFile[] {
return Array.isArray(row.acceptance?.files) ? row.acceptance.files : []
}
@@ -295,6 +295,7 @@ export type CollectLookupOrder = {
status: string
fields: CollectField[]
values?: Record<string, string>
screenshots?: UploadedFile[]
}
export type CollectLookupResponse = {