修复验收图片多选暂存

This commit is contained in:
yml2213
2026-08-15 14:49:48 +08:00
parent 190445319b
commit d433518038
5 changed files with 305 additions and 126 deletions
@@ -1,6 +1,7 @@
import { Image, Typography } from 'antd'
import type { UploadedFile } from '@/types/worker-platform'
import { getImageIdentityFromUrl } from '@/utils/image-identity'
type ImagePreviewListProps = {
files?: UploadedFile[]
@@ -44,15 +45,10 @@ function normalizePreviewItems(files: UploadedFile[], imageUrls: string[]) {
mediumUrl: file.mediumUrl || file.url,
}))
.filter((item) => Boolean(item.url))
const fileUrlSet = new Set(normalizedFiles.map((item) => getImageIdentity(item.url)))
const fileUrlSet = new Set(normalizedFiles.map((item) => getImageIdentityFromUrl(item.url)))
const normalizedUrls = imageUrls
.map((url) => String(url || '').trim())
.filter((url) => url && !fileUrlSet.has(getImageIdentity(url)))
.filter((url) => url && !fileUrlSet.has(getImageIdentityFromUrl(url)))
.map((url) => ({ url, thumbnailUrl: url, mediumUrl: url }))
return [...normalizedFiles, ...normalizedUrls]
}
/** 忽略临时签名参数,避免同一存储对象重复显示。 */
function getImageIdentity(value: string) {
return String(value || '').trim().split(/[?#]/, 1)[0]
}
@@ -89,7 +89,10 @@ export default function ImageUpload({
message.error(error instanceof Error ? error.message : '上传失败')
}
}
if (next.length > valueRef.current.length) onChange?.(next)
if (next.length > valueRef.current.length) {
valueRef.current = next
onChange?.(next)
}
}
async function uploadFiles(files: File[]) {
@@ -141,7 +144,10 @@ export default function ImageUpload({
const customRequest: UploadProps['customRequest'] = async (options) => {
try {
const uploaded = await uploadFile(options.file as File, scene, scope)
onChange?.([...value, uploaded].slice(-maxCount))
// 多文件选择会并发执行 customRequest,必须以 ref 中的最新列表累加,避免后完成的上传覆盖前面的图片。
const next = [...valueRef.current, uploaded].slice(0, maxCount)
valueRef.current = next
onChange?.(next)
options.onSuccess?.(uploaded)
} catch (error) {
message.error(error instanceof Error ? error.message : '上传失败')
@@ -160,14 +166,17 @@ export default function ImageUpload({
>
<Upload
accept="image/jpeg,image/png,image/webp"
multiple
listType="picture-card"
fileList={fileList}
maxCount={maxCount}
customRequest={customRequest}
onRemove={(file) => {
onChange?.(
value.filter((item) => (item.objectKey || item.url) !== file.uid),
const next = valueRef.current.filter(
(item) => (item.objectKey || item.url) !== file.uid,
)
valueRef.current = next
onChange?.(next)
return true
}}
showUploadList={{ showPreviewIcon: false }}
@@ -45,6 +45,7 @@ import type {
WorkerAcceptance,
} from '@/types/worker-platform'
import { formatDateTime } from '@/utils/date-time'
import { getImageIdentity } from '@/utils/image-identity'
const STATUS_OPTIONS = [
{ value: '', label: '全部订单' },
@@ -169,7 +170,7 @@ export default function WorkerOrdersPage() {
? getWorkerAcceptance(order)
: getWorkerDraft(order)
setAcceptanceFiles(getUniqueAcceptanceFiles(source))
form.setFieldsValue({ note: String(source.note || '') })
form.setFieldsValue({ note: String(source?.note || '') })
setDetailOrder(null)
}
@@ -263,38 +264,43 @@ export default function WorkerOrdersPage() {
const columns: TableColumnsType<WorkOrder> = [
{
title: '订单信息',
minWidth: 280,
width: 270,
render: (_, row) => (
<div className="cell-stack">
<Typography.Link onClick={() => setDetailOrder(row)}>
<Typography.Link
strong
onClick={() => setDetailOrder(row)}
title={row.productName || row.workOrderNo}
ellipsis
>
{row.productName || row.workOrderNo}
</Typography.Link>
<Space size={[6, 6]} wrap>
{row.categoryName ? <Tag>{row.categoryName}</Tag> : null}
<Space size={[4, 4]} wrap>
{row.categoryName ? <Tag color="blue">{row.categoryName}</Tag> : null}
{row.myShare ? <Tag color="purple"></Tag> : null}
</Space>
<Typography.Text type="secondary">
{row.workOrderNo} · {row.platformOrderId || '-'}
<Typography.Text type="secondary" style={{ fontSize: 12 }} ellipsis={{ tooltip: `${row.workOrderNo} / ${row.platformOrderId || '-'}` }}>
{row.workOrderNo}
</Typography.Text>
<Typography.Text type="secondary">
{formatDateTime(row.assignedAt)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTime(row.assignedAt)}
</Typography.Text>
</div>
),
},
{
title: '状态',
width: 170,
width: 130,
render: (_, row) => (
<div className="cell-stack">
<Space wrap size={6}>
<Space wrap size={4}>
<Tag color={resolveStatusColor(row.status)}>{formatStatus(row.status)}</Tag>
{row.status === 'in_progress' && row.deadlineAt ? (
<DeadlineCountdown deadlineAt={row.deadlineAt} />
) : null}
</Space>
{row.status === 'problem' && String(row.problemNote || '').trim() ? (
<Typography.Text type="danger" ellipsis>
<Typography.Text type="danger" ellipsis={{ tooltip: row.problemNote }} style={{ fontSize: 12 }}>
{row.problemNote}
</Typography.Text>
) : null}
@@ -303,16 +309,18 @@ export default function WorkerOrdersPage() {
},
{
title: '奖励 / 押金',
width: 150,
width: 140,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong>{formatMoney(row.rewardAmount)}</Typography.Text>
<Typography.Text type="secondary">
{formatMoney(row.freezeDepositAmount)}
<Typography.Text strong style={{ color: '#cf1322' }}>
{formatMoney(row.rewardAmount)}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatMoney(row.freezeDepositAmount)}
</Typography.Text>
{row.myShare ? (
<Typography.Text type="secondary">
{row.myShare.quantity} · {formatMoney(row.myShare.shareReward)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{row.myShare.quantity} · {formatMoney(row.myShare.shareReward)}
</Typography.Text>
) : null}
</div>
@@ -320,40 +328,38 @@ export default function WorkerOrdersPage() {
},
{
title: '验收信息',
minWidth: 200,
width: 220,
render: (_, row) => {
const submittedAt = getAcceptanceSubmittedAt(row)
const hasAcceptance =
getAcceptanceFiles(row).length > 0 || getAcceptanceImageUrls(row).length > 0
const draftFiles = getDraftFiles(row)
const draftUrls = getDraftImageUrls(row)
const draftCount = draftFiles.length + draftUrls.length
const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(row))
const hasAcceptance = acceptanceFiles.length > 0
const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(row))
const draftCount = draftFiles.length
if (!hasAcceptance && !submittedAt && draftCount === 0) {
return <Typography.Text type="secondary"></Typography.Text>
return <Typography.Text type="secondary" style={{ fontSize: 12 }}></Typography.Text>
}
return (
<div className="cell-stack">
{hasAcceptance ? (
<ImagePreviewList
files={getAcceptanceFiles(row)}
imageUrls={getAcceptanceImageUrls(row)}
size={40}
/>
<ImagePreviewList files={acceptanceFiles} size={38} />
) : null}
{submittedAt ? (
<Typography.Text type="secondary">
{formatDateTime(submittedAt)}
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTime(submittedAt)}
</Typography.Text>
) : null}
{draftCount > 0 && !hasSubmittedAcceptance(row) ? (
<>
<Tag color="blue"> {draftCount} </Tag>
<ImagePreviewList files={draftFiles} imageUrls={draftUrls} size={40} />
</>
<div className="acceptance-draft-badge-wrap">
<Tag color="processing" icon={<PictureOutlined />}>
{draftCount}
</Tag>
<ImagePreviewList files={draftFiles} size={38} />
</div>
) : null}
{getWorkerAcceptance(row).note ? (
<Typography.Text type="secondary" ellipsis>
{getWorkerAcceptance(row).note}
{getWorkerAcceptance(row)?.note ? (
<Typography.Text type="secondary" ellipsis={{ tooltip: getWorkerAcceptance(row)?.note }} style={{ fontSize: 12 }}>
{getWorkerAcceptance(row)?.note}
</Typography.Text>
) : null}
</div>
@@ -362,7 +368,7 @@ export default function WorkerOrdersPage() {
},
{
title: '快捷复制',
width: 280,
width: 250,
render: (_, row) => {
const items = getMaterialDetailItems(row).filter((item) =>
String(item.value || '').trim(),
@@ -372,7 +378,7 @@ export default function WorkerOrdersPage() {
<div className="cell-stack">
{items.length > 0 ? (
items.map((item) => (
<Typography.Text key={item.key} style={{ fontSize: 12 }} ellipsis>
<Typography.Text key={item.key} style={{ fontSize: 12 }} ellipsis={{ tooltip: `${item.label}: ${item.value}` }}>
{item.label}: {item.value}
</Typography.Text>
))
@@ -382,9 +388,9 @@ export default function WorkerOrdersPage() {
</Typography.Text>
)}
{screenshots.length > 0 ? (
<ImagePreviewList files={screenshots} size={36} />
<ImagePreviewList files={screenshots} size={34} />
) : null}
<Space wrap>
<Space wrap size={4}>
<Button
size="small"
icon={<CopyOutlined />}
@@ -409,27 +415,39 @@ export default function WorkerOrdersPage() {
},
{
title: '备注',
width: 190,
render: (_, row) => (
<div className="cell-stack">
<Typography.Text
type={row.workerNote ? undefined : 'secondary'}
ellipsis={{ tooltip: row.workerNote || undefined }}
>
{row.workerNote || '未填写'}
</Typography.Text>
<Button size="small" icon={<EditOutlined />} onClick={() => openNoteModal(row)}>
</Button>
width: 180,
render: (_, row) => {
const note = String(row.workerNote || '').trim()
return (
<div className="worker-note-cell">
{note ? (
<div className="worker-note-badge" onClick={() => openNoteModal(row)}>
<span className="worker-note-text" title={note}>
{note}
</span>
<EditOutlined className="worker-note-edit-icon" />
</div>
),
) : (
<Button
type="dashed"
size="small"
icon={<EditOutlined />}
className="worker-note-add-btn"
onClick={() => openNoteModal(row)}
>
</Button>
)}
</div>
)
},
},
{
title: '操作',
width: 190,
width: 150,
render: (_, row) => (
<div className="worker-order-actions">
<Space size={8}>
<Space size={6}>
<Tooltip title="查看详情">
<Button
aria-label="查看详情"
@@ -659,43 +677,45 @@ export default function WorkerOrdersPage() {
<Card title="验收信息" size="small">
<div className="worker-order-detail-stack">
{(() => {
const acceptanceFiles = getUniqueAcceptanceFiles(getWorkerAcceptance(detailOrder))
const draftFiles = getUniqueAcceptanceFiles(getWorkerDraft(detailOrder))
const hasAcceptance = acceptanceFiles.length > 0
const hasDraft = draftFiles.length > 0 && !hasSubmittedAcceptance(detailOrder)
return (
<>
<Descriptions column={{ xs: 1, md: 2 }} bordered size="small">
<Descriptions.Item label="验收图片数量">
{getAcceptanceFiles(detailOrder).length +
getAcceptanceImageUrls(detailOrder).length || 0}
{acceptanceFiles.length}
</Descriptions.Item>
<Descriptions.Item label="提交时间">
{formatDateTime(getAcceptanceSubmittedAt(detailOrder))}
</Descriptions.Item>
<Descriptions.Item label="验收说明" span={2}>
<span className="worker-order-detail-value">
{String(getWorkerAcceptance(detailOrder).note || '').trim() || '-'}
{String(getWorkerAcceptance(detailOrder)?.note || '').trim() || '-'}
</span>
</Descriptions.Item>
</Descriptions>
{getAcceptanceFiles(detailOrder).length ||
getAcceptanceImageUrls(detailOrder).length ? (
<ImagePreviewList
files={getAcceptanceFiles(detailOrder)}
imageUrls={getAcceptanceImageUrls(detailOrder)}
size={76}
/>
{hasAcceptance ? (
<ImagePreviewList files={acceptanceFiles} size={76} />
) : (
<Typography.Text type="secondary"></Typography.Text>
<Typography.Text type="secondary"></Typography.Text>
)}
{!hasSubmittedAcceptance(detailOrder) &&
(getDraftFiles(detailOrder).length || getDraftImageUrls(detailOrder).length) ? (
<>
<Typography.Text type="secondary">
{getDraftFiles(detailOrder).length + getDraftImageUrls(detailOrder).length}
</Typography.Text>
<ImagePreviewList
files={getDraftFiles(detailOrder)}
imageUrls={getDraftImageUrls(detailOrder)}
size={76}
/>
</>
{hasDraft ? (
<div className="acceptance-draft-badge-wrap" style={{ marginTop: 8 }}>
<Tag color="processing" icon={<PictureOutlined />}>
{draftFiles.length}
</Tag>
<ImagePreviewList files={draftFiles} size={76} />
</div>
) : null}
</>
)
})()}
</div>
</Card>
@@ -713,6 +733,7 @@ export default function WorkerOrdersPage() {
<Modal
title={`订单备注 · ${noteOrder?.workOrderNo || ''}`}
open={Boolean(noteOrder)}
width={460}
onCancel={() => {
setNoteOrder(null)
noteForm.resetFields()
@@ -721,10 +742,26 @@ export default function WorkerOrdersPage() {
destroyOnHidden
>
<Form form={noteForm} layout="vertical" onFinish={saveOrderNote}>
<Form.Item label="备注" name="note">
<AutoComplete options={[{ value: '已安排' }, { value: '未安排' }]}>
<Input allowClear maxLength={100} placeholder="输入备注或选择状态" />
</AutoComplete>
<Form.Item label="快捷选择标签" style={{ marginBottom: 12 }}>
<div className="worker-note-preset-tags">
{['已安排', '未安排', '打单中', '账号问题', '排队中', '暂缓做单'].map((tag) => (
<Tag
key={tag}
className="worker-note-preset-tag"
onClick={() => noteForm.setFieldsValue({ note: tag })}
>
{tag}
</Tag>
))}
</div>
</Form.Item>
<Form.Item label="备注内容" name="note">
<Input.TextArea
rows={3}
maxLength={100}
showCount
placeholder="请输入备注说明(如:已联系买家、账号异常中等)"
/>
</Form.Item>
</Form>
</Modal>
@@ -949,21 +986,25 @@ function renderDetailFieldValues(items: DetailFieldItem[], emptyText: string) {
}
/** 打手自己的验收资料(拼单时取份额自己的) */
function getWorkerAcceptance(order: WorkOrder): WorkerAcceptance {
function getWorkerAcceptance(order: WorkOrder | null | undefined): WorkerAcceptance | undefined {
if (!order) return undefined
return order.myShare ? order.myShare.acceptance : order.acceptance
}
/** 打手自己的暂存草稿(拼单时取份额自己的) */
function getWorkerDraft(order: WorkOrder): WorkerAcceptance {
function getWorkerDraft(order: WorkOrder | null | undefined): WorkerAcceptance | undefined {
if (!order) return undefined
return order.myShare?.draftAcceptance || order.draftAcceptance
}
function getAcceptanceFilesFromSource(source: WorkerAcceptance): UploadedFile[] {
return Array.isArray(source.files) ? source.files : []
function getAcceptanceFilesFromSource(source?: WorkerAcceptance | null): UploadedFile[] {
if (!source || !Array.isArray(source.files)) return []
return source.files
}
/** 合并草稿兼容字段,并按固定图片地址去重,避免同一图片重复回填。 */
function getUniqueAcceptanceFiles(source: WorkerAcceptance): UploadedFile[] {
function getUniqueAcceptanceFiles(source?: WorkerAcceptance | null): UploadedFile[] {
if (!source) return []
const files = [
...getAcceptanceFilesFromSource(source),
...(Array.isArray(source.imageUrls) ? source.imageUrls : []).map(
@@ -980,26 +1021,19 @@ function getUniqueAcceptanceFiles(source: WorkerAcceptance): UploadedFile[] {
]
const identities = new Set<string>()
return files.filter((file) => {
const identity = getImageIdentity(file.url || file.mediumUrl || file.thumbnailUrl || file.objectKey)
const identity = getImageIdentity(file)
if (!identity || identities.has(identity)) return false
identities.add(identity)
return true
})
}
/** 去除临时签名参数,确保同一个对象的不同访问链接能匹配。 */
function getImageIdentity(value: string): string {
const url = String(value || '').trim()
if (!url) return ''
return url.split(/[?#]/, 1)[0]
}
function getAcceptanceFiles(row: WorkOrder): UploadedFile[] {
return getAcceptanceFilesFromSource(getWorkerAcceptance(row))
}
function getAcceptanceImageUrls(row: WorkOrder): string[] {
const imageUrls = getWorkerAcceptance(row).imageUrls
const imageUrls = getWorkerAcceptance(row)?.imageUrls
return Array.isArray(imageUrls)
? imageUrls.map((item) => String(item || '').trim()).filter(Boolean)
: []
@@ -1010,7 +1044,7 @@ function getDraftFiles(row: WorkOrder): UploadedFile[] {
}
function getDraftImageUrls(row: WorkOrder): string[] {
const imageUrls = getWorkerDraft(row).imageUrls
const imageUrls = getWorkerDraft(row)?.imageUrls
return Array.isArray(imageUrls)
? imageUrls.map((item) => String(item || '').trim()).filter(Boolean)
: []
+86
View File
@@ -1540,6 +1540,92 @@ select {
margin-bottom: 12px;
}
.acceptance-draft-badge-wrap {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
}
.acceptance-draft-badge-wrap .ant-tag {
margin-inline-end: 0;
display: inline-flex;
align-items: center;
gap: 4px;
}
.worker-note-cell {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.worker-note-badge {
display: inline-flex;
align-items: center;
gap: 6px;
max-width: 100%;
padding: 3px 8px;
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s ease;
font-size: 12px;
color: #24292f;
}
.worker-note-badge:hover {
background: #e6f4ff;
border-color: #91caff;
color: #0958d9;
}
.worker-note-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 110px;
}
.worker-note-edit-icon {
font-size: 12px;
color: #8c8c8c;
transition: color 0.2s;
}
.worker-note-badge:hover .worker-note-edit-icon {
color: #0958d9;
}
.worker-note-add-btn {
border-style: dashed;
color: #595959;
font-size: 12px;
}
.worker-note-preset-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.worker-note-preset-tag {
cursor: pointer;
user-select: none;
padding: 2px 10px;
border-radius: 4px;
transition: all 0.2s ease;
}
.worker-note-preset-tag:hover {
border-color: #1677ff;
color: #1677ff;
background: #e6f4ff;
}
.metric-grid {
display: grid;
gap: 12px;
+54
View File
@@ -0,0 +1,54 @@
import type { UploadedFile } from '@/types/worker-platform'
/**
* 归一化图片身份标识,用于跨来源去重(files 与 imageUrls 可能同时包含同一张图)。
*
* 本项目的图片地址有两种形态:
* - 对象存储签名链接:`/api/v1/files/object?key=<objectKey>&expires=...&signature=...`
* (对象 key 在 query 里,路径对所有文件相同,不能只取路径)
* - 普通外链:`https://cdn.example.com/a.png`
*
* 优先级:objectKey 字段 > URL 中的 key 参数 > 去掉临时签名参数后的完整地址。
*/
export function getImageIdentity(file: {
objectKey?: string
url?: string
mediumUrl?: string
thumbnailUrl?: string
}): string {
const objectKey = String(file?.objectKey || '').trim()
if (objectKey) return objectKey
const url = String(
file?.url || file?.mediumUrl || file?.thumbnailUrl || '',
).trim()
return getImageIdentityFromUrl(url)
}
export function getImageIdentityFromUrl(value: string): string {
const url = String(value || '').trim()
if (!url) return ''
const keyMatch = url.match(/[?&]key=([^&#]+)/)
if (keyMatch?.[1]) {
try {
return decodeURIComponent(keyMatch[1])
} catch {
return keyMatch[1]
}
}
// 普通地址:仅去掉临时签名参数(expires/signature),保留路径与其它参数
return url
.split('#', 1)[0]
.replace(/[?&](expires|signature)=[^&#]*/gi, '')
.replace(/[?&]+$/, '')
}
/** 供 UploadedFile 数组去重:按图片身份去重,保留第一张 */
export function uniqueUploadedFiles(files: UploadedFile[]): UploadedFile[] {
const seen = new Set<string>()
return files.filter((file) => {
const identity = getImageIdentity(file)
if (!identity || seen.has(identity)) return false
seen.add(identity)
return true
})
}