修复验收图片多选暂存
This commit is contained in:
@@ -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)
|
||||
: []
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user