feat(order-service): 客户页展示验收图片与订单进度
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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<string, string> = {
|
||||
[WORK_ORDER_STATUS.PENDING_MATERIAL]: '请补充订单资料。',
|
||||
@@ -87,8 +90,18 @@ export type OrderServicePublicItem = {
|
||||
screenshots?: Array<Record<string, unknown>>
|
||||
screenshotsRule?: OrderServiceProfileConfig['screenshots']
|
||||
guideImage?: Record<string, unknown>
|
||||
acceptanceImageUrls?: string[]
|
||||
submittedAt?: string
|
||||
acceptanceSubmittedAt?: string
|
||||
acceptedAt?: string
|
||||
}
|
||||
|
||||
const PUBLIC_ACCEPTANCE_IMAGE_STATUSES = new Set<string>([
|
||||
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<string>()
|
||||
const urls: string[] = []
|
||||
|
||||
for (const file of files) {
|
||||
const source =
|
||||
file && typeof file === 'object' && !Array.isArray(file)
|
||||
? (file as Record<string, unknown>)
|
||||
: {}
|
||||
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<OrderServicePubl
|
||||
const material = safeParseJson(order.material_json)
|
||||
const collect = material.collect && typeof material.collect === 'object' ? material.collect : {}
|
||||
const values = normalizeSubmittedFields(collect.fields || {})
|
||||
const rawAcceptance = safeParseJson(order.acceptance_json)
|
||||
const submittedAt = String(collect.submittedAt || '').trim()
|
||||
const acceptanceSubmittedAt = String(rawAcceptance.submittedAt || '').trim()
|
||||
const acceptanceImageUrls = resolveOrderServiceAcceptanceImageUrls({
|
||||
status: order.status,
|
||||
acceptanceJson: order.acceptance_json,
|
||||
})
|
||||
const screenshots = normalizeUploadedFiles(collect.screenshots || [])
|
||||
.map(refreshUploadedFileUrls)
|
||||
.filter(Boolean)
|
||||
@@ -383,6 +444,10 @@ async function mapPublicOrderItem(order: WorkOrderRow): Promise<OrderServicePubl
|
||||
}),
|
||||
canSubmit: state.canSubmit,
|
||||
blockedReason: state.blockedReason,
|
||||
...(submittedAt ? { submittedAt } : {}),
|
||||
...(acceptanceSubmittedAt ? { acceptanceSubmittedAt } : {}),
|
||||
...(order.accepted_at ? { acceptedAt: String(order.accepted_at) } : {}),
|
||||
...(acceptanceImageUrls.length > 0 ? { acceptanceImageUrls } : {}),
|
||||
...(profile?.materialConfirmation
|
||||
? { materialConfirmation: profile.materialConfirmation }
|
||||
: {}),
|
||||
|
||||
@@ -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<string, number> = {
|
||||
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<string, string> = {
|
||||
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)}
|
||||
/>
|
||||
) : (
|
||||
<Card key={item.workOrderId} size="small">
|
||||
<Space direction="vertical" size={6} className="full-width">
|
||||
<Space size={8} wrap>
|
||||
<strong>{item.productName || item.categoryName || '订单商品'}</strong>
|
||||
<Tag color={item.state === 'accepted' ? 'green' : 'blue'}>
|
||||
{item.stateLabel}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text type="secondary">{item.message}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
<OrderServiceStatusCard key={item.workOrderId} item={item} />
|
||||
),
|
||||
)}
|
||||
|
||||
@@ -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 (
|
||||
<Card size="small" className="collect-link-status-card">
|
||||
<Space direction="vertical" size={12} className="full-width">
|
||||
<div className="collect-link-status-head">
|
||||
<strong>{item.productName || item.categoryName || '订单商品'}</strong>
|
||||
<Tag color={STATUS_TAG_COLORS[item.state] || 'default'}>{item.stateLabel}</Tag>
|
||||
</div>
|
||||
|
||||
{showSteps ? (
|
||||
<Steps
|
||||
className="collect-link-status-steps"
|
||||
current={currentStep}
|
||||
items={STATUS_STEP_ITEMS}
|
||||
responsive
|
||||
size="small"
|
||||
status={
|
||||
item.state === 'accepted' ? 'finish' : item.state === 'problem' ? 'error' : 'process'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<div className={`collect-link-status-message is-${item.state}`}>{item.message}</div>
|
||||
|
||||
{times.length > 0 ? (
|
||||
<div className="collect-link-status-times">
|
||||
{times.map((row) => (
|
||||
<span key={row.label}>
|
||||
{row.label}:{formatOrderServiceTime(row.value)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{acceptanceImageUrls.length > 0 ? (
|
||||
<div className="collect-link-acceptance">
|
||||
<div>
|
||||
<Text strong>交付验收图片</Text>
|
||||
<Text type="secondary">点击图片可查看大图</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} />
|
||||
))}
|
||||
</div>
|
||||
</Image.PreviewGroup>
|
||||
</div>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function OrderServiceForm({
|
||||
item,
|
||||
code,
|
||||
@@ -619,3 +704,8 @@ function normalizeFormFieldValues(item: OrderServiceItem, values: Record<string,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
function formatOrderServiceTime(value: string) {
|
||||
const parsed = dayjs(value)
|
||||
return parsed.isValid() ? parsed.format('YYYY-MM-DD HH:mm') : value
|
||||
}
|
||||
|
||||
@@ -605,6 +605,10 @@ export type OrderServiceItem = {
|
||||
screenshots?: UploadedFile[]
|
||||
screenshotsRule?: OrderServiceScreenshots
|
||||
guideImage?: UploadedFile | Record<string, unknown>
|
||||
acceptanceImageUrls?: string[]
|
||||
submittedAt?: string
|
||||
acceptanceSubmittedAt?: string
|
||||
acceptedAt?: string
|
||||
}
|
||||
|
||||
export type OrderServiceInfo = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user