From e3a66309a0d9246dd101df878aa138a0f3bdc575 Mon Sep 17 00:00:00 2001 From: yml2213 Date: Wed, 4 Nov 2026 16:36:29 +0800 Subject: [PATCH] =?UTF-8?q?feat(order-service):=20=E4=BC=98=E5=8C=96?= =?UTF-8?q?=E5=AE=A2=E6=88=B7=E8=AE=A2=E5=8D=95=E7=8A=B6=E6=80=81=E4=B8=8E?= =?UTF-8?q?=E9=AA=8C=E6=94=B6=E5=B1=95=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../order-service-public-service.test.ts | 38 +++ .../order-service-public-service.ts | 83 +++++- .../src/pages/public/OrderServicePage.tsx | 211 +++++++++++---- apps/frontend/src/services/worker.ts | 9 + apps/frontend/src/styles/client.css | 250 +++++++++++++++++- 5 files changed, 517 insertions(+), 74 deletions(-) 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 a5478360..5955dfa2 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 @@ -4,6 +4,7 @@ import test from 'node:test' import { WORK_ORDER_STATUS } from '../../domain/work-order-status.js' import { resolveOrderServiceAcceptanceImageUrls, + resolveOrderServiceAcceptanceVideo, resolveOrderServiceStatusLabel, resolveOrderServiceStatusMessage, } from './order-service-public-service.js' @@ -42,6 +43,43 @@ test('客户页验收图片:仅交付相关状态展示图片,并按文件 ) }) +test('客户页验收视频:交付相关状态返回视频地址,未交付状态不返回', () => { + const acceptanceJson = { + video: { + url: '/api/v1/files/object?key=worker-acceptance-video%2Fdemo.webm', + thumbnailUrl: '', + filename: 'acceptance.webm', + contentType: 'video/webm', + durationSeconds: 8.8, + width: 808, + height: 720, + objectKey: 'worker-acceptance-video/demo.webm', + }, + } + assert.deepEqual( + resolveOrderServiceAcceptanceVideo({ + status: WORK_ORDER_STATUS.ACCEPTED, + acceptanceJson, + }), + { + url: '/api/v1/files/object?key=worker-acceptance-video%2Fdemo.webm', + thumbnailUrl: '', + filename: 'acceptance.webm', + contentType: 'video/webm', + durationSeconds: 8.8, + width: 808, + height: 720, + }, + ) + assert.equal( + resolveOrderServiceAcceptanceVideo({ + status: WORK_ORDER_STATUS.IN_PROGRESS, + acceptanceJson, + }), + null, + ) +}) + 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 421d0b16..6299ab70 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 @@ -94,6 +94,7 @@ export type OrderServicePublicItem = { screenshotsRule?: OrderServiceProfileConfig['screenshots'] guideImage?: Record acceptanceImageUrls?: string[] + acceptanceVideo?: OrderServiceAcceptanceVideo submittedAt?: string acceptanceSubmittedAt?: string acceptedAt?: string @@ -163,6 +164,55 @@ export function resolveOrderServiceAcceptanceImageUrls(input: { return urls } +export type OrderServiceAcceptanceVideo = { + url: string + thumbnailUrl: string + filename: string + contentType: string + durationSeconds: number | null + width: number | null + height: number | null +} + +export function resolveOrderServiceAcceptanceVideo(input: { + status: string + acceptanceJson: unknown +}): OrderServiceAcceptanceVideo | null { + if (!PUBLIC_ACCEPTANCE_IMAGE_STATUSES.has(String(input.status || '').trim())) return null + + const rawAcceptance = safeParseJson(input.acceptanceJson) + const acceptance = mapAcceptanceForResponse(input.acceptanceJson) + const candidates = [ + rawAcceptance.video, + ...(Array.isArray(acceptance.files) ? acceptance.files : []), + ] + + for (const candidate of candidates) { + if (!candidate || typeof candidate !== 'object' || Array.isArray(candidate)) continue + const source = candidate as Record + const contentType = String(source.contentType || '') + .trim() + .toLowerCase() + const objectKey = String( + source.objectKey || extractObjectKeyFromFileUrl(String(source.url || '')) || '', + ).trim() + const videoLike = contentType.startsWith('video/') || /\.(?:mp4|webm|mov|m4v)$/i.test(objectKey) + if (!videoLike) continue + const url = refreshStoredFileUrl(String(source.url || '').trim()) + if (!url) continue + return { + url, + thumbnailUrl: refreshStoredFileUrl(String(source.thumbnailUrl || '').trim()), + filename: String(source.filename || '').trim(), + contentType, + durationSeconds: normalizeFiniteNumber(source.durationSeconds), + width: normalizeFiniteNumber(source.width), + height: normalizeFiniteNumber(source.height), + } + } + return null +} + export async function getOrderServiceByPlatformOrderId(platformOrderIdInput: unknown) { const platformOrderId = normalizeOrderNo(platformOrderIdInput) const orders = await listWorkOrdersByPlatformOrderId(platformOrderId) @@ -428,6 +478,10 @@ async function mapPublicOrderItem(order: WorkOrderRow): Promise 0 ? { acceptanceImageUrls } : {}), + ...(acceptanceVideo ? { acceptanceVideo } : {}), ...(profile?.materialConfirmation ? { materialConfirmation: profile.materialConfirmation } : {}), - statusSteps: - currentPresentationProfile?.config.statusSteps || - profile?.statusSteps || - DEFAULT_ORDER_SERVICE_STATUS_STEPS, - } - - if (!state.canSubmit) return base - - const fields = profile?.fields || [] - return { - ...base, - fields, + fields: profile?.fields || [], values, screenshots, screenshotsRule: profile?.screenshots || { @@ -482,6 +526,16 @@ async function mapPublicOrderItem(order: WorkOrderRow): Promise= 0 ? parsed : null +} + function notFound() { return createHttpError('订单号无效,请确认订单号是否正确', { statusCode: 404, diff --git a/apps/frontend/src/pages/public/OrderServicePage.tsx b/apps/frontend/src/pages/public/OrderServicePage.tsx index 8ad5abe5..e98590a2 100644 --- a/apps/frontend/src/pages/public/OrderServicePage.tsx +++ b/apps/frontend/src/pages/public/OrderServicePage.tsx @@ -19,7 +19,6 @@ import { Select, Space, Spin, - Steps, Tag, Typography, } from 'antd' @@ -42,14 +41,15 @@ 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: 2, - pending_acceptance: 3, - accepted: 3, - problem: 2, +const STATUS_STEP_ORDER = ['submitted', 'scheduled', 'processing', 'completed'] as const +const STATUS_STEP_KEY: Record = { + pending_material: 'submitted', + unassigned: 'scheduled', + open: 'processing', + in_progress: 'processing', + pending_acceptance: 'completed', + accepted: 'completed', + problem: 'processing', } const DEFAULT_STATUS_STEPS: OrderServiceItem['statusSteps'] = { submitted: { title: '提交资料', description: '' }, @@ -292,6 +292,10 @@ export default function OrderServicePage() { const pageConfig = info?.items[0] const pendingItems = (info?.items || []).filter((item) => item.canSubmit) + const purchaseSummary = + [ + ...new Set(info?.items.map((item) => item.productName || item.categoryName).filter(Boolean)), + ].join('、') || '订单商品' return (
@@ -313,9 +317,14 @@ export default function OrderServicePage() {
{pageConfig?.title || '订单服务'} - 订单号:{info.platformOrderId} +
+ 订单号:{info.platformOrderId} + + 购买物品:{purchaseSummary} + +
{pageConfig?.description ? ( -
+
{pageConfig.description}
) : null} @@ -387,9 +396,7 @@ 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, @@ -398,8 +405,12 @@ function OrderServiceStatusCard({ item }: { item: OrderServiceItem }) { : null, ].filter((row): row is { label: string; value: string } => Boolean(row?.value)) const acceptanceImageUrls = item.acceptanceImageUrls || [] - const showSteps = item.state !== 'cancelled' + const acceptanceVideo = item.acceptanceVideo const statusSteps = item.statusSteps || DEFAULT_STATUS_STEPS + const statusStepKey = STATUS_STEP_KEY[item.state] + const currentStepIndex = statusStepKey ? STATUS_STEP_ORDER.indexOf(statusStepKey) : -1 + const currentStep = statusStepKey ? statusSteps[statusStepKey] : null + const currentStatusText = currentStep?.description?.trim() || item.message return ( @@ -409,37 +420,24 @@ function OrderServiceStatusCard({ item }: { item: OrderServiceItem }) { {item.stateLabel}
- {showSteps ? ( - - ) : null} + -
{item.message}
+ {currentStep && currentStepIndex >= 0 ? ( +
+ {currentStepIndex + 1} +
+
+ {currentStep.title} + + 当前进度 {currentStepIndex + 1}/{STATUS_STEP_ORDER.length} + +
+

{currentStatusText}

+
+
+ ) : ( +
{item.message}
+ )} {times.length > 0 ? (
@@ -451,19 +449,60 @@ function OrderServiceStatusCard({ item }: { item: OrderServiceItem }) {
) : null} - {acceptanceImageUrls.length > 0 ? ( + {acceptanceImageUrls.length > 0 || acceptanceVideo ? (
- 交付验收图片 - 点击图片可查看大图 + 交付验收 + + {acceptanceVideo && acceptanceImageUrls.length > 0 + ? '图片可点击查看大图,视频可直接播放' + : acceptanceVideo + ? '可直接播放并确认交付内容' + : '点击图片可查看大图'} +
- -
- {acceptanceImageUrls.map((url, index) => ( - {`验收图片 - ))} + {acceptanceVideo ? ( +
+ +
+ + {acceptanceVideo.filename || '验收视频'} + {acceptanceVideo.durationSeconds + ? ` · ${Math.ceil(acceptanceVideo.durationSeconds)} 秒` + : ''} + + + 打开原视频 + +
- + ) : null} + {acceptanceImageUrls.length > 0 ? ( + +
+ {acceptanceImageUrls.map((url, index) => ( + {`验收图片 + ))} +
+
+ ) : null}
) : null} @@ -471,6 +510,72 @@ function OrderServiceStatusCard({ item }: { item: OrderServiceItem }) { ) } +function SubmittedMaterialSummary({ item }: { item: OrderServiceItem }) { + const values = item.values || {} + const rows = + item.fields && item.fields.length > 0 + ? item.fields.map((field) => ({ + key: field.key, + label: field.label, + value: String(values[field.key] || '').trim(), + })) + : Object.entries(values).map(([key, value]) => ({ + key, + label: key, + value: String(value || '').trim(), + })) + const filledRows = rows.filter((row) => row.value) + const screenshots = item.screenshots || [] + + if (filledRows.length === 0 && screenshots.length === 0) return null + + return ( +
+
+ + 买家提交的资料 + 交付时会按以下信息核对 + + {item.submittedAt ? ( + 提交于 {formatOrderServiceTime(item.submittedAt)} + ) : null} +
+ + {filledRows.length > 0 ? ( +
+ {filledRows.map((row) => ( +
+ {row.label} + {row.value} +
+ ))} +
+ ) : null} + + {screenshots.length > 0 ? ( +
+ + {item.screenshotsRule?.label || '买家截图'}:{screenshots.length} 张 + + +
+ {screenshots.map((file, index) => ( + {file.filename + ))} +
+
+
+ ) : null} +
+ ) +} + function OrderServiceForm({ item, code, diff --git a/apps/frontend/src/services/worker.ts b/apps/frontend/src/services/worker.ts index 0f723b17..3eb02b6c 100644 --- a/apps/frontend/src/services/worker.ts +++ b/apps/frontend/src/services/worker.ts @@ -614,6 +614,15 @@ export type OrderServiceItem = { screenshotsRule?: OrderServiceScreenshots guideImage?: UploadedFile | Record acceptanceImageUrls?: string[] + acceptanceVideo?: { + url: string + thumbnailUrl: string + filename: string + contentType: string + durationSeconds: number | null + width: number | null + height: number | null + } submittedAt?: string acceptanceSubmittedAt?: string acceptedAt?: string diff --git a/apps/frontend/src/styles/client.css b/apps/frontend/src/styles/client.css index 2b131cd3..537d83e0 100644 --- a/apps/frontend/src/styles/client.css +++ b/apps/frontend/src/styles/client.css @@ -526,19 +526,216 @@ margin-inline-end: 0; } -.collect-link-status-steps { - padding: 4px 2px 8px; +.collect-link-order-meta { + display: flex; + flex-wrap: wrap; + gap: 4px 18px; + color: #64748b; + font-size: 13px; } -.collect-link-status-steps .ant-steps-item-title { - font-size: 12px !important; +.collect-link-order-meta strong { + color: #0f172a; + font-weight: 600; } -.collect-link-status-steps .ant-steps-item-description { - max-width: 140px; - font-size: 11px !important; - line-height: 1.45; - white-space: normal; +.collect-link-description { + margin-top: 6px; + font-size: 12px; +} + +.collect-link-submitted { + display: grid; + gap: 10px; + padding: 12px; + background: #f8fafc; + border: 1px solid #e7edf5; + border-radius: 10px; +} + +.collect-link-submitted-head { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 5px 14px; +} + +.collect-link-submitted-head > span { + display: grid; + gap: 1px; +} + +.collect-link-submitted-head .ant-typography:last-child { + font-size: 11px; +} + +.collect-link-submitted-head > .ant-typography { + font-size: 11px; +} + +.collect-link-submitted-fields { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 7px; +} + +.collect-link-submitted-fields > div { + display: grid; + gap: 2px; + min-width: 0; + padding: 8px 10px; + background: #ffffff; + border: 1px solid #edf1f6; + border-radius: 8px; +} + +.collect-link-submitted-fields span, +.collect-link-submitted-screenshots > span { + color: #94a3b8; + font-size: 11px; +} + +.collect-link-submitted-fields strong { + color: #1e293b; + font-size: 13px; + font-weight: 500; + line-height: 1.5; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.collect-link-submitted-screenshots { + display: grid; + gap: 7px; +} + +.collect-link-submitted-screenshots > div { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.collect-link-submitted-screenshots .ant-image { + overflow: hidden; + border: 1px solid #dbe3ee; + border-radius: 8px; +} + +.collect-link-submitted-screenshots .ant-image-img { + object-fit: cover; +} + +.collect-link-current-status { + display: grid; + grid-template-columns: auto minmax(0, 1fr); + gap: 12px; + align-items: flex-start; + padding: 13px; + color: #475569; + background: #f8fafc; + border: 1px solid #e8edf4; + border-left: 4px solid #94a3b8; + border-radius: 9px; +} + +.collect-link-current-status-index { + display: inline-flex; + align-items: center; + justify-content: center; + width: 30px; + height: 30px; + color: #ffffff; + font-size: 13px; + font-weight: 700; + background: #64748b; + border-radius: 999px; +} + +.collect-link-current-status-copy { + display: grid; + gap: 5px; + min-width: 0; +} + +.collect-link-current-status-copy > div { + display: flex; + flex-wrap: wrap; + gap: 3px 12px; + align-items: baseline; + justify-content: space-between; +} + +.collect-link-current-status-copy > div .ant-typography:last-child { + font-size: 11px; +} + +.collect-link-current-status-copy p { + margin: 0; + color: inherit; + font-size: 13px; + line-height: 1.7; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.collect-link-current-status.is-open, +.collect-link-current-status.is-pending_material { + color: #8a4b00; + background: #fffaf0; + border-color: #ffe7ba; + border-left-color: #faad14; +} + +.collect-link-current-status.is-open .collect-link-current-status-index, +.collect-link-current-status.is-pending_material .collect-link-current-status-index { + background: #faad14; +} + +.collect-link-current-status.is-in_progress, +.collect-link-current-status.is-unassigned { + color: #0958d9; + background: #f0f7ff; + border-color: #d6e8ff; + border-left-color: #1677ff; +} + +.collect-link-current-status.is-in_progress .collect-link-current-status-index, +.collect-link-current-status.is-unassigned .collect-link-current-status-index { + background: #1677ff; +} + +.collect-link-current-status.is-pending_acceptance { + color: #531dab; + background: #f9f0ff; + border-color: #efdbff; + border-left-color: #722ed1; +} + +.collect-link-current-status.is-pending_acceptance .collect-link-current-status-index { + background: #722ed1; +} + +.collect-link-current-status.is-accepted { + color: #237804; + background: #f6ffed; + border-color: #d9f7be; + border-left-color: #52c41a; +} + +.collect-link-current-status.is-accepted .collect-link-current-status-index { + background: #52c41a; +} + +.collect-link-current-status.is-problem { + color: #a8071a; + background: #fff1f0; + border-color: #ffccc7; + border-left-color: #ff4d4f; +} + +.collect-link-current-status.is-problem .collect-link-current-status-index { + background: #ff4d4f; } .collect-link-status-message { @@ -626,6 +823,32 @@ object-fit: cover; } +.collect-link-acceptance-video { + display: grid; + gap: 8px; +} + +.collect-link-acceptance-video video { + width: min(100%, 520px); + max-height: 360px; + background: #0f172a; + border: 1px solid #dbe3ee; + border-radius: 10px; +} + +.collect-link-acceptance-video > div { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + align-items: center; + justify-content: space-between; + font-size: 12px; +} + +.collect-link-acceptance-video a { + color: #1677ff; +} + .collect-link-title { margin: 0 0 4px !important; } @@ -898,6 +1121,15 @@ grid-template-columns: 88px minmax(0, 1fr); padding: 8px 10px; } + + .collect-link-order-meta, + .collect-link-submitted-fields { + grid-template-columns: 1fr; + } + + .collect-link-order-meta { + display: grid; + } } /* ===== 兑换中心 (Redeem Page) ===== */