feat: 订单资料图片完整展示与打手快捷复制
- 修复资料截图 URL 过期问题:读取工单时统一重签截图访问链接(后台/打手端/收集页) - 修复后台手动补资料截图从未保存的问题(提交真实上传列表) - 收集页提交后回显已上传截图,补资料弹窗重开回显已有截图 - 打手端我的订单新增快捷复制:复制信息(多行标签:值)+ 一键复制图片(JPEG 自动转 PNG) - 快捷复制列与后台资料列同款展示:全字段逐行 + 截图缩略图 - 后台接单工单资料列优化:显示全部字段与截图缩略图 - 详情抽屉移除冗余的资料要求卡片
This commit is contained in:
@@ -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) {
|
export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
||||||
return {
|
return {
|
||||||
workOrderId: Number(workOrder.id),
|
workOrderId: Number(workOrder.id),
|
||||||
@@ -369,7 +388,7 @@ export function mapWorkOrderAdmin(workOrder: WorkOrderRow) {
|
|||||||
depositThresholdAmount: Number(
|
depositThresholdAmount: Number(
|
||||||
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
workOrder.deposit_threshold_amount || DEFAULT_DEPOSIT_THRESHOLD_AMOUNT,
|
||||||
),
|
),
|
||||||
material: safeParseJson(workOrder.material_json),
|
material: mapWorkOrderMaterial(workOrder.material_json),
|
||||||
requirement: {
|
requirement: {
|
||||||
fields: resolveRequirementFields(workOrder),
|
fields: resolveRequirementFields(workOrder),
|
||||||
},
|
},
|
||||||
@@ -448,10 +467,15 @@ export function mapWorkOrderPublic(workOrder: WorkOrderRow) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function mapCollectLookupOrder(workOrder: WorkOrderRow) {
|
export function mapCollectLookupOrder(workOrder: WorkOrderRow) {
|
||||||
|
const material = mapWorkOrderMaterial(workOrder.material_json)
|
||||||
return {
|
return {
|
||||||
...mapWorkOrderPublic(workOrder),
|
...mapWorkOrderPublic(workOrder),
|
||||||
fields: resolveRequirementFields(workOrder),
|
fields: resolveRequirementFields(workOrder),
|
||||||
values: resolveCollectSubmittedFieldValues(workOrder),
|
values: resolveCollectSubmittedFieldValues(workOrder),
|
||||||
|
screenshots:
|
||||||
|
material.collect && Array.isArray(material.collect.screenshots)
|
||||||
|
? material.collect.screenshots
|
||||||
|
: [],
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -102,7 +102,6 @@ import {
|
|||||||
getRequirementFields,
|
getRequirementFields,
|
||||||
renderDetailFieldValues,
|
renderDetailFieldValues,
|
||||||
renderMaterialSummary,
|
renderMaterialSummary,
|
||||||
renderRequirementFieldValues,
|
|
||||||
asRecord,
|
asRecord,
|
||||||
formatMoney,
|
formatMoney,
|
||||||
formatSharingShareStatus,
|
formatSharingShareStatus,
|
||||||
@@ -332,17 +331,14 @@ export default function WorkOrdersPanel() {
|
|||||||
resolutionForm.resetFields()
|
resolutionForm.resetFields()
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitMaterial(values: {
|
async function submitMaterial(values: { fields?: Record<string, string> }) {
|
||||||
fields?: Record<string, string>
|
|
||||||
screenshots?: UploadedFile[]
|
|
||||||
}) {
|
|
||||||
if (!materialOrder) return
|
if (!materialOrder) return
|
||||||
const succeeded = await runAction(
|
const succeeded = await runAction(
|
||||||
() =>
|
() =>
|
||||||
submitAdminWorkOrderMaterial(
|
submitAdminWorkOrderMaterial(
|
||||||
materialOrder.workOrderId,
|
materialOrder.workOrderId,
|
||||||
values.fields || {},
|
values.fields || {},
|
||||||
values.screenshots || [],
|
materialScreenshots,
|
||||||
),
|
),
|
||||||
'订单资料已保存',
|
'订单资料已保存',
|
||||||
)
|
)
|
||||||
@@ -367,7 +363,7 @@ export default function WorkOrdersPanel() {
|
|||||||
function openMaterialModal(row: WorkOrder) {
|
function openMaterialModal(row: WorkOrder) {
|
||||||
setMaterialOrder(row)
|
setMaterialOrder(row)
|
||||||
materialForm.setFieldsValue({ fields: getMaterialFields(row) })
|
materialForm.setFieldsValue({ fields: getMaterialFields(row) })
|
||||||
setMaterialScreenshots([])
|
setMaterialScreenshots(getMaterialScreenshots(row))
|
||||||
}
|
}
|
||||||
|
|
||||||
function openDetailDrawer(row: WorkOrder) {
|
function openDetailDrawer(row: WorkOrder) {
|
||||||
@@ -590,7 +586,7 @@ export default function WorkOrdersPanel() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '资料',
|
title: '资料',
|
||||||
width: 160,
|
width: 240,
|
||||||
render: (_, row) => renderMaterialSummary(row),
|
render: (_, row) => renderMaterialSummary(row),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -929,10 +925,6 @@ export default function WorkOrdersPanel() {
|
|||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="资料要求" size="small">
|
|
||||||
{renderRequirementFieldValues(detailOrder)}
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
<Card title="验收信息" size="small">
|
<Card title="验收信息" size="small">
|
||||||
<div className="worker-order-detail-stack">
|
<div className="worker-order-detail-stack">
|
||||||
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
|
||||||
@@ -1190,6 +1182,7 @@ export default function WorkOrdersPanel() {
|
|||||||
setMaterialOrder(null)
|
setMaterialOrder(null)
|
||||||
setMaterialPasteText('')
|
setMaterialPasteText('')
|
||||||
materialForm.resetFields()
|
materialForm.resetFields()
|
||||||
|
setMaterialScreenshots([])
|
||||||
}}
|
}}
|
||||||
onOk={() => materialForm.submit()}
|
onOk={() => materialForm.submit()}
|
||||||
destroyOnHidden
|
destroyOnHidden
|
||||||
|
|||||||
@@ -142,21 +142,20 @@ export function renderMaterialSummary(order: WorkOrder) {
|
|||||||
const entries = Object.entries(fields).filter(([, value]) =>
|
const entries = Object.entries(fields).filter(([, value]) =>
|
||||||
String(value || '').trim(),
|
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 <Typography.Text type="secondary">-</Typography.Text>
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="cell-stack">
|
<div className="cell-stack">
|
||||||
{entries.slice(0, 2).map(([key, value]) => (
|
{entries.map(([key, value]) => (
|
||||||
<Typography.Text key={key} ellipsis>
|
<Typography.Text key={key} style={{ fontSize: 12 }} ellipsis>
|
||||||
{fieldLabelMap.get(key) || key}: {value}
|
{fieldLabelMap.get(key) || key}: {value}
|
||||||
</Typography.Text>
|
</Typography.Text>
|
||||||
))}
|
))}
|
||||||
{entries.length > 2 ? (
|
{screenshots.length > 0 ? (
|
||||||
<Typography.Text type="secondary">
|
<ImagePreviewList files={screenshots} size={36} />
|
||||||
还有 {entries.length - 2} 项
|
|
||||||
</Typography.Text>
|
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -124,7 +124,11 @@ export default function CollectPage() {
|
|||||||
maxCount={5}
|
maxCount={5}
|
||||||
showPasteHint
|
showPasteHint
|
||||||
captureGlobalPaste
|
captureGlobalPaste
|
||||||
value={screenshotsByOrder[currentOrder.workOrderId] || []}
|
value={
|
||||||
|
screenshotsByOrder[currentOrder.workOrderId] ??
|
||||||
|
currentOrder.screenshots ??
|
||||||
|
[]
|
||||||
|
}
|
||||||
onChange={(files) =>
|
onChange={(files) =>
|
||||||
setScreenshotsByOrder((prev) => ({
|
setScreenshotsByOrder((prev) => ({
|
||||||
...prev,
|
...prev,
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
import {
|
import {
|
||||||
CheckOutlined,
|
CheckOutlined,
|
||||||
|
CopyOutlined,
|
||||||
EyeOutlined,
|
EyeOutlined,
|
||||||
|
PictureOutlined,
|
||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
SearchOutlined,
|
SearchOutlined,
|
||||||
} from '@ant-design/icons'
|
} 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) {
|
function openAcceptanceModal(order: WorkOrder) {
|
||||||
setSubmittingOrder(order)
|
setSubmittingOrder(order)
|
||||||
setAcceptanceFiles([])
|
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: '操作',
|
title: '操作',
|
||||||
width: 260,
|
width: 260,
|
||||||
@@ -459,10 +551,14 @@ export default function WorkerOrdersPage() {
|
|||||||
getMaterialDetailItems(detailOrder),
|
getMaterialDetailItems(detailOrder),
|
||||||
'当前还没有资料内容',
|
'当前还没有资料内容',
|
||||||
)}
|
)}
|
||||||
</Card>
|
{getMaterialScreenshots(detailOrder).length > 0 ? (
|
||||||
|
<div style={{ marginTop: 12 }}>
|
||||||
<Card title="资料要求" size="small">
|
<Typography.Text type="secondary">买家主页截图</Typography.Text>
|
||||||
{renderRequirementFieldValues(detailOrder)}
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<ImagePreviewList files={getMaterialScreenshots(detailOrder)} size={72} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card title="验收信息" size="small">
|
<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[] {
|
function getMaterialDetailItems(order: WorkOrder): DetailFieldItem[] {
|
||||||
const values = getMaterialFields(order)
|
const values = getMaterialFields(order)
|
||||||
const requiredFields = getRequirementFields(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[] {
|
function getAcceptanceFiles(row: WorkOrder): UploadedFile[] {
|
||||||
return Array.isArray(row.acceptance?.files) ? row.acceptance.files : []
|
return Array.isArray(row.acceptance?.files) ? row.acceptance.files : []
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -295,6 +295,7 @@ export type CollectLookupOrder = {
|
|||||||
status: string
|
status: string
|
||||||
fields: CollectField[]
|
fields: CollectField[]
|
||||||
values?: Record<string, string>
|
values?: Record<string, string>
|
||||||
|
screenshots?: UploadedFile[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export type CollectLookupResponse = {
|
export type CollectLookupResponse = {
|
||||||
|
|||||||
Reference in New Issue
Block a user