diff --git a/apps/backend/src/services/worker-platform/order-service-public-service.test.ts b/apps/backend/src/services/worker-platform/order-service-public-service.test.ts index 1b3a2b6c..a5478360 100644 --- a/apps/backend/src/services/worker-platform/order-service-public-service.test.ts +++ b/apps/backend/src/services/worker-platform/order-service-public-service.test.ts @@ -3,10 +3,45 @@ import test from 'node:test' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { + resolveOrderServiceAcceptanceImageUrls, resolveOrderServiceStatusLabel, resolveOrderServiceStatusMessage, } from './order-service-public-service.js' +test('客户页验收图片:仅交付相关状态展示图片,并按文件对象去重', () => { + const acceptanceJson = { + files: [ + { + objectKey: 'worker-acceptance/a.webp', + url: '/api/v1/files/object?key=worker-acceptance%2Fa.webp', + thumbnailUrl: '/api/v1/files/object?key=worker-acceptance%2Fa.thumb.jpg', + mediumUrl: '/api/v1/files/object?key=worker-acceptance%2Fa.medium.jpg', + contentType: 'image/webp', + }, + { + objectKey: 'worker-acceptance/a-video.mp4', + url: 'https://example.com/a-video.mp4', + contentType: 'video/mp4', + }, + ], + imageUrls: ['/api/v1/files/object?key=worker-acceptance%2Fa.webp'], + } + assert.deepEqual( + resolveOrderServiceAcceptanceImageUrls({ + status: WORK_ORDER_STATUS.ACCEPTED, + acceptanceJson, + }), + ['/api/v1/files/object?key=worker-acceptance%2Fa.medium.jpg'], + ) + assert.deepEqual( + resolveOrderServiceAcceptanceImageUrls({ + status: WORK_ORDER_STATUS.IN_PROGRESS, + acceptanceJson, + }), + [], + ) +}) + test('订单服务状态文案:待抢单提示高峰期等待', () => { const message = resolveOrderServiceStatusMessage({ status: WORK_ORDER_STATUS.OPEN, diff --git a/apps/backend/src/services/worker-platform/order-service-public-service.ts b/apps/backend/src/services/worker-platform/order-service-public-service.ts index e226a381..c411b57d 100644 --- a/apps/backend/src/services/worker-platform/order-service-public-service.ts +++ b/apps/backend/src/services/worker-platform/order-service-public-service.ts @@ -21,7 +21,9 @@ import { normalizeSubmittedFields, normalizeUploadedFiles } from './worker-input import { ORDER_SERVICE_SCREENSHOT_CONTENT_TYPES, buildFileUrl, + extractObjectKeyFromFileUrl, refreshUploadedFileUrls, + refreshStoredFileUrl, type UploadedFileDto, } from '../file-storage/file-storage-service.js' import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js' @@ -43,6 +45,7 @@ import { enqueueOrderServiceAutoPublish, processDueOrderServiceAutoPublishJobs, } from './order-service-auto-publish-service.js' +import { mapAcceptanceForResponse } from './work-order-material-mappers.js' const PUBLIC_STATUS_MESSAGES: Record = { [WORK_ORDER_STATUS.PENDING_MATERIAL]: '请补充订单资料。', @@ -87,8 +90,18 @@ export type OrderServicePublicItem = { screenshots?: Array> screenshotsRule?: OrderServiceProfileConfig['screenshots'] guideImage?: Record + acceptanceImageUrls?: string[] + submittedAt?: string + acceptanceSubmittedAt?: string + acceptedAt?: string } +const PUBLIC_ACCEPTANCE_IMAGE_STATUSES = new Set([ + WORK_ORDER_STATUS.PENDING_ACCEPTANCE, + WORK_ORDER_STATUS.ACCEPTED, + WORK_ORDER_STATUS.PROBLEM, +]) + export function resolveOrderServiceStatusLabel(status: string, fallback = '处理中') { return PUBLIC_STATUS_LABELS[String(status || '').trim()] || fallback } @@ -106,6 +119,47 @@ export function resolveOrderServiceStatusMessage(input: { ) } +export function resolveOrderServiceAcceptanceImageUrls(input: { + status: string + acceptanceJson: unknown +}) { + if (!PUBLIC_ACCEPTANCE_IMAGE_STATUSES.has(String(input.status || '').trim())) return [] + const acceptance = mapAcceptanceForResponse(input.acceptanceJson) + const files = Array.isArray(acceptance.files) ? acceptance.files : [] + const imageUrls = Array.isArray(acceptance.imageUrls) ? acceptance.imageUrls : [] + const seenObjects = new Set() + const urls: string[] = [] + + for (const file of files) { + const source = + file && typeof file === 'object' && !Array.isArray(file) + ? (file as Record) + : {} + const objectKey = String(source.objectKey || '').trim() + const url = String(source.url || '').trim() + const contentType = String(source.contentType || '').trim() + const imageLike = + contentType.startsWith('image/') || + /\.(?:jpe?g|png|webp|gif)$/i.test(objectKey || extractObjectKeyFromFileUrl(url)) + if (!imageLike) continue + const identity = objectKey || extractObjectKeyFromFileUrl(url) || url + if (!identity || seenObjects.has(identity)) continue + seenObjects.add(identity) + const displayUrl = String(source.mediumUrl || source.thumbnailUrl || url).trim() + if (displayUrl) urls.push(displayUrl) + } + + for (const rawUrl of imageUrls) { + const url = refreshStoredFileUrl(String(rawUrl || '').trim()) + if (!url) continue + const identity = extractObjectKeyFromFileUrl(url) || url + if (seenObjects.has(identity)) continue + seenObjects.add(identity) + urls.push(url) + } + return urls +} + export async function getOrderServiceByPlatformOrderId(platformOrderIdInput: unknown) { const platformOrderId = normalizeOrderNo(platformOrderIdInput) const orders = await listWorkOrdersByPlatformOrderId(platformOrderId) @@ -361,6 +415,13 @@ async function mapPublicOrderItem(order: WorkOrderRow): Promise 0 ? { acceptanceImageUrls } : {}), ...(profile?.materialConfirmation ? { materialConfirmation: profile.materialConfirmation } : {}), diff --git a/apps/frontend/src/pages/public/OrderServicePage.tsx b/apps/frontend/src/pages/public/OrderServicePage.tsx index 4ad6d940..4d866182 100644 --- a/apps/frontend/src/pages/public/OrderServicePage.tsx +++ b/apps/frontend/src/pages/public/OrderServicePage.tsx @@ -11,6 +11,7 @@ import { Card, DatePicker, Form, + Image, Input, InputNumber, Radio, @@ -18,6 +19,7 @@ import { Select, Space, Spin, + Steps, Tag, Typography, } from 'antd' @@ -40,6 +42,32 @@ import { useOrderServicePageMeta } from './use-order-service-page-meta' const { Text, Title } = Typography const NOT_FOUND_MESSAGE = '订单号无效,请确认订单号是否正确' const STATUS_REFRESH_INTERVAL_MS = 15_000 +const STATUS_STEP_INDEX: Record = { + pending_material: 0, + unassigned: 1, + open: 2, + in_progress: 3, + pending_acceptance: 4, + accepted: 4, + problem: 3, +} +const STATUS_STEP_ITEMS = [ + { title: '提交资料' }, + { title: '客服安排' }, + { title: '发布接单' }, + { title: '订单处理' }, + { title: '交付完成' }, +] +const STATUS_TAG_COLORS: Record = { + pending_material: 'orange', + unassigned: 'blue', + open: 'gold', + in_progress: 'processing', + pending_acceptance: 'purple', + accepted: 'success', + problem: 'error', + cancelled: 'default', +} function resolveErrorCode(error: unknown) { return String((error as { errorCode?: string })?.errorCode || '').trim() @@ -345,17 +373,7 @@ export default function OrderServicePage() { onSubmit={(values) => void handleSubmit(item, values)} /> ) : ( - - - - {item.productName || item.categoryName || '订单商品'} - - {item.stateLabel} - - - {item.message} - - + ), )} @@ -369,6 +387,73 @@ export default function OrderServicePage() { ) } +function OrderServiceStatusCard({ item }: { item: OrderServiceItem }) { + const currentStep = STATUS_STEP_INDEX[item.state] ?? 0 + const times = [ + item.submittedAt ? { label: '资料提交时间', value: item.submittedAt } : null, + item.state === 'pending_acceptance' && item.acceptanceSubmittedAt + ? { label: '凭证提交时间', value: item.acceptanceSubmittedAt } + : null, + item.state === 'accepted' && item.acceptedAt + ? { label: '完成时间', value: item.acceptedAt } + : null, + ].filter((row): row is { label: string; value: string } => Boolean(row?.value)) + const acceptanceImageUrls = item.acceptanceImageUrls || [] + const showSteps = item.state !== 'cancelled' + + return ( + + +
+ {item.productName || item.categoryName || '订单商品'} + {item.stateLabel} +
+ + {showSteps ? ( + + ) : null} + +
{item.message}
+ + {times.length > 0 ? ( +
+ {times.map((row) => ( + + {row.label}:{formatOrderServiceTime(row.value)} + + ))} +
+ ) : null} + + {acceptanceImageUrls.length > 0 ? ( +
+
+ 交付验收图片 + 点击图片可查看大图 +
+ +
+ {acceptanceImageUrls.map((url, index) => ( + {`验收图片 + ))} +
+
+
+ ) : null} +
+
+ ) +} + function OrderServiceForm({ item, code, @@ -619,3 +704,8 @@ function normalizeFormFieldValues(item: OrderServiceItem, values: Record + acceptanceImageUrls?: string[] + submittedAt?: string + acceptanceSubmittedAt?: string + acceptedAt?: string } export type OrderServiceInfo = { diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index 657e4c39..0ccf2136 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -504,6 +504,121 @@ padding: 16px; } +.collect-link-status-card { + background: linear-gradient(180deg, #ffffff 0%, #fbfdff 100%); +} + +.collect-link-status-card .ant-card-body { + padding: 16px; +} + +.collect-link-status-head { + display: flex; + align-items: center; + justify-content: space-between; + flex-wrap: wrap; + gap: 8px; + color: #0f172a; + font-size: 15px; +} + +.collect-link-status-head .ant-tag { + margin-inline-end: 0; +} + +.collect-link-status-steps { + padding: 4px 2px 8px; +} + +.collect-link-status-steps .ant-steps-item-title { + font-size: 12px !important; +} + +.collect-link-status-message { + padding: 10px 12px; + color: #475569; + font-size: 13px; + line-height: 1.65; + background: #f8fafc; + border-left: 3px solid #94a3b8; + border-radius: 0 8px 8px 0; +} + +.collect-link-status-message.is-open, +.collect-link-status-message.is-pending_material { + color: #8a4b00; + background: #fffaf0; + border-left-color: #faad14; +} + +.collect-link-status-message.is-in_progress, +.collect-link-status-message.is-unassigned { + color: #0958d9; + background: #f0f7ff; + border-left-color: #1677ff; +} + +.collect-link-status-message.is-pending_acceptance { + color: #531dab; + background: #f9f0ff; + border-left-color: #722ed1; +} + +.collect-link-status-message.is-accepted { + color: #237804; + background: #f6ffed; + border-left-color: #52c41a; +} + +.collect-link-status-message.is-problem { + color: #a8071a; + background: #fff1f0; + border-left-color: #ff4d4f; +} + +.collect-link-status-times { + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + color: #94a3b8; + font-size: 12px; +} + +.collect-link-acceptance { + display: grid; + gap: 10px; + padding-top: 12px; + border-top: 1px solid #eef2f7; +} + +.collect-link-acceptance > div:first-child { + display: flex; + align-items: baseline; + justify-content: space-between; + flex-wrap: wrap; + gap: 4px 12px; +} + +.collect-link-acceptance > div:first-child .ant-typography:last-child { + font-size: 12px; +} + +.collect-link-acceptance-images { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +.collect-link-acceptance-images .ant-image { + overflow: hidden; + border: 1px solid #dbe3ee; + border-radius: 9px; +} + +.collect-link-acceptance-images .ant-image-img { + object-fit: cover; +} + .collect-link-title { margin: 0 0 4px !important; }