feat(order-service): 优化客户订单状态与验收展示

This commit is contained in:
yml2213
2026-11-04 16:36:29 +08:00
parent c31fa7db76
commit e3a66309a0
5 changed files with 517 additions and 74 deletions
@@ -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,
@@ -94,6 +94,7 @@ export type OrderServicePublicItem = {
screenshotsRule?: OrderServiceProfileConfig['screenshots']
guideImage?: Record<string, unknown>
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<string, unknown>
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<OrderServicePubl
status: order.status,
acceptanceJson: order.acceptance_json,
})
const acceptanceVideo = resolveOrderServiceAcceptanceVideo({
status: order.status,
acceptanceJson: order.acceptance_json,
})
const screenshots = normalizeUploadedFiles(collect.screenshots || [])
.map(refreshUploadedFileUrls)
.filter(Boolean)
@@ -457,21 +511,11 @@ async function mapPublicOrderItem(order: WorkOrderRow): Promise<OrderServicePubl
...(acceptanceSubmittedAt ? { acceptanceSubmittedAt } : {}),
...(order.accepted_at ? { acceptedAt: String(order.accepted_at) } : {}),
...(acceptanceImageUrls.length > 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<OrderServicePubl
label: '买家主页截图',
helpText: '',
},
statusSteps:
currentPresentationProfile?.config.statusSteps ||
profile?.statusSteps ||
DEFAULT_ORDER_SERVICE_STATUS_STEPS,
}
if (!state.canSubmit) return base
return {
...base,
guideImage: profile?.guideImage || {},
}
}
@@ -585,6 +639,11 @@ function normalizeOrderNo(value: unknown) {
return normalized
}
function normalizeFiniteNumber(value: unknown): number | null {
const parsed = Number(value)
return Number.isFinite(parsed) && parsed >= 0 ? parsed : null
}
function notFound() {
return createHttpError('订单号无效,请确认订单号是否正确', {
statusCode: 404,
@@ -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<string, number> = {
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<string, (typeof STATUS_STEP_ORDER)[number]> = {
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 (
<main className="collect-link-page">
@@ -313,9 +317,14 @@ export default function OrderServicePage() {
<div className="collect-link-heading">
<div>
<Title level={4}>{pageConfig?.title || '订单服务'}</Title>
<Text type="secondary">订单号:{info.platformOrderId}</Text>
<div className="collect-link-order-meta">
<span>订单号:{info.platformOrderId}</span>
<span>
购买物品:<strong>{purchaseSummary}</strong>
</span>
</div>
{pageConfig?.description ? (
<div style={{ marginTop: 6 }}>
<div className="collect-link-description">
<Text type="secondary">{pageConfig.description}</Text>
</div>
) : 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 (
<Card size="small" className="collect-link-status-card">
@@ -409,37 +420,24 @@ function OrderServiceStatusCard({ item }: { item: OrderServiceItem }) {
<Tag color={STATUS_TAG_COLORS[item.state] || 'default'}>{item.stateLabel}</Tag>
</div>
{showSteps ? (
<Steps
className="collect-link-status-steps"
current={currentStep}
items={[
{
title: statusSteps.submitted.title,
description: statusSteps.submitted.description || undefined,
},
{
title: statusSteps.scheduled.title,
description: statusSteps.scheduled.description || undefined,
},
{
title: statusSteps.processing.title,
description: statusSteps.processing.description || undefined,
},
{
title: statusSteps.completed.title,
description: statusSteps.completed.description || undefined,
},
]}
responsive
size="small"
status={
item.state === 'accepted' ? 'finish' : item.state === 'problem' ? 'error' : 'process'
}
/>
) : null}
<SubmittedMaterialSummary item={item} />
<div className={`collect-link-status-message is-${item.state}`}>{item.message}</div>
{currentStep && currentStepIndex >= 0 ? (
<div className={`collect-link-current-status is-${item.state}`}>
<span className="collect-link-current-status-index">{currentStepIndex + 1}</span>
<div className="collect-link-current-status-copy">
<div>
<Text strong>{currentStep.title}</Text>
<Text type="secondary">
当前进度 {currentStepIndex + 1}/{STATUS_STEP_ORDER.length}
</Text>
</div>
<p>{currentStatusText}</p>
</div>
</div>
) : (
<div className={`collect-link-status-message is-${item.state}`}>{item.message}</div>
)}
{times.length > 0 ? (
<div className="collect-link-status-times">
@@ -451,19 +449,60 @@ function OrderServiceStatusCard({ item }: { item: OrderServiceItem }) {
</div>
) : null}
{acceptanceImageUrls.length > 0 ? (
{acceptanceImageUrls.length > 0 || acceptanceVideo ? (
<div className="collect-link-acceptance">
<div>
<Text strong>交付验收图片</Text>
<Text type="secondary">点击图片可查看大图</Text>
<Text strong>交付验收</Text>
<Text type="secondary">
{acceptanceVideo && acceptanceImageUrls.length > 0
? '图片可点击查看大图,视频可直接播放'
: acceptanceVideo
? '可直接播放并确认交付内容'
: '点击图片可查看大图'}
</Text>
</div>
<Image.PreviewGroup>
<div className="collect-link-acceptance-images">
{acceptanceImageUrls.map((url, index) => (
<Image key={url} alt={`验收图片 ${index + 1}`} src={url} width={92} height={92} />
))}
{acceptanceVideo ? (
<div className="collect-link-acceptance-video">
<video
controls
playsInline
preload="metadata"
poster={acceptanceVideo.thumbnailUrl || undefined}
src={acceptanceVideo.url}
>
您的浏览器暂不支持视频播放,请
<a href={acceptanceVideo.url} rel="noreferrer" target="_blank">
点击下载验收视频
</a>
</video>
<div>
<Text type="secondary">
{acceptanceVideo.filename || '验收视频'}
{acceptanceVideo.durationSeconds
? ` · ${Math.ceil(acceptanceVideo.durationSeconds)} 秒`
: ''}
</Text>
<a href={acceptanceVideo.url} rel="noreferrer" target="_blank">
打开原视频
</a>
</div>
</div>
</Image.PreviewGroup>
) : null}
{acceptanceImageUrls.length > 0 ? (
<Image.PreviewGroup>
<div className="collect-link-acceptance-images">
{acceptanceImageUrls.map((url, index) => (
<Image
key={url}
alt={`验收图片 ${index + 1}`}
src={url}
width={92}
height={92}
/>
))}
</div>
</Image.PreviewGroup>
) : null}
</div>
) : null}
</Space>
@@ -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 (
<section className="collect-link-submitted">
<div className="collect-link-submitted-head">
<span>
<Text strong>买家提交的资料</Text>
<Text type="secondary">交付时会按以下信息核对</Text>
</span>
{item.submittedAt ? (
<Text type="secondary">提交于 {formatOrderServiceTime(item.submittedAt)}</Text>
) : null}
</div>
{filledRows.length > 0 ? (
<div className="collect-link-submitted-fields">
{filledRows.map((row) => (
<div key={row.key}>
<span>{row.label}</span>
<strong>{row.value}</strong>
</div>
))}
</div>
) : null}
{screenshots.length > 0 ? (
<div className="collect-link-submitted-screenshots">
<span>
{item.screenshotsRule?.label || '买家截图'}:{screenshots.length} 张
</span>
<Image.PreviewGroup>
<div>
{screenshots.map((file, index) => (
<Image
key={file.objectKey || file.url}
alt={file.filename || `买家截图 ${index + 1}`}
src={file.thumbnailUrl || file.url}
width={68}
height={68}
/>
))}
</div>
</Image.PreviewGroup>
</div>
) : null}
</section>
)
}
function OrderServiceForm({
item,
code,
+9
View File
@@ -614,6 +614,15 @@ export type OrderServiceItem = {
screenshotsRule?: OrderServiceScreenshots
guideImage?: UploadedFile | Record<string, unknown>
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
+241 -9
View File
@@ -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) ===== */