feat: 订单资料图片完整展示与打手快捷复制
- 修复资料截图 URL 过期问题:读取工单时统一重签截图访问链接(后台/打手端/收集页) - 修复后台手动补资料截图从未保存的问题(提交真实上传列表) - 收集页提交后回显已上传截图,补资料弹窗重开回显已有截图 - 打手端我的订单新增快捷复制:复制信息(多行标签:值)+ 一键复制图片(JPEG 自动转 PNG) - 快捷复制列与后台资料列同款展示:全字段逐行 + 截图缩略图 - 后台接单工单资料列优化:显示全部字段与截图缩略图 - 详情抽屉移除冗余的资料要求卡片
This commit is contained in:
@@ -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 转成 PNG(Chrome 剪贴板写入只支持 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 = {
|
||||
|
||||
Reference in New Issue
Block a user