拆分打手平台售后与订单模块

This commit is contained in:
yml2213
2026-08-21 15:17:28 +08:00
parent f4d32a7c07
commit 798ea583f8
24 changed files with 4473 additions and 4213 deletions
@@ -0,0 +1,178 @@
import { ReloadOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { App, Alert, Button, Card, Form, Input, Modal, Space, Tag, Typography } from 'antd'
import { useState } from 'react'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import ImageUpload from '@/components/files/ImageUpload'
import { fetchWorkerAfterSalesCases, submitWorkerAfterSalesCaseResponse } from '@/services/worker'
import type { UploadedFile, WorkerAfterSalesCase } from '@/types/worker-platform'
type AfterSalesResponseFormValues = {
response?: string
}
function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
function formatWorkerAfterSalesStatus(status: string) {
if (status === 'awaiting_worker_response') return '待说明'
if (status === 'under_review') return '处理中'
if (status === 'recovery_pending') return '追缴中'
return '已关闭'
}
function resolveWorkerAfterSalesStatusColor(status: string) {
if (status === 'awaiting_worker_response') return 'orange'
if (status === 'under_review') return 'blue'
if (status === 'recovery_pending') return 'red'
return 'green'
}
export default function WorkerAfterSalesSection({ isMobile }: { isMobile: boolean }) {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [afterSalesCase, setAfterSalesCase] = useState<WorkerAfterSalesCase | null>(null)
const [afterSalesResponseFiles, setAfterSalesResponseFiles] = useState<UploadedFile[]>([])
const [afterSalesResponseForm] = Form.useForm<AfterSalesResponseFormValues>()
const afterSalesCasesQuery = useQuery({
queryKey: ['worker-after-sales-cases'],
queryFn: fetchWorkerAfterSalesCases,
retry: false,
})
const afterSalesCases = afterSalesCasesQuery.data?.data.items || []
function openAfterSalesResponseModal(item: WorkerAfterSalesCase) {
setAfterSalesCase(item)
setAfterSalesResponseFiles([])
afterSalesResponseForm.resetFields()
}
async function submitAfterSalesResponse(values: AfterSalesResponseFormValues) {
if (!afterSalesCase) return
try {
await submitWorkerAfterSalesCaseResponse(afterSalesCase.caseId, {
response: String(values.response || '').trim(),
files: afterSalesResponseFiles,
})
message.success('售后说明已提交,等待客服处理')
await queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] })
setAfterSalesCase(null)
setAfterSalesResponseFiles([])
afterSalesResponseForm.resetFields()
} catch (error) {
message.error(error instanceof Error ? error.message : '提交失败')
}
}
if (afterSalesCases.length === 0) return null
return (
<>
<Card
className="worker-after-sales-card"
title="售后问题单"
size="small"
extra={
<Button
size="small"
icon={<ReloadOutlined />}
loading={afterSalesCasesQuery.isFetching}
onClick={() => afterSalesCasesQuery.refetch()}
>
</Button>
}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{afterSalesCases.map((item) => (
<div key={item.caseId} className="worker-order-detail-value">
<Space wrap size={[8, 6]}>
<Tag color={resolveWorkerAfterSalesStatusColor(item.status)}>
{formatWorkerAfterSalesStatus(item.status)}
</Tag>
<Typography.Text strong>
{item.productName || item.workOrderNo || item.caseNo}
</Typography.Text>
<Typography.Text type="secondary">{item.caseNo}</Typography.Text>
</Space>
<Typography.Paragraph style={{ margin: '8px 0 4px' }}>
{item.complaintNote || '-'}
</Typography.Paragraph>
{item.complaintFiles.length > 0 ? (
<ImagePreviewList files={item.complaintFiles} size={52} maxVisible={4} />
) : null}
{item.status === 'awaiting_worker_response' ? (
<Button
type="primary"
size="small"
style={{ marginTop: 8 }}
onClick={() => openAfterSalesResponseModal(item)}
>
</Button>
) : item.status === 'under_review' ? (
<Typography.Text type="secondary"></Typography.Text>
) : item.status === 'recovery_pending' ? (
<Typography.Text type="danger">
{formatMoney(item.debtAmount)}
</Typography.Text>
) : item.resolutionNote ? (
<Typography.Text type="secondary">{item.resolutionNote}</Typography.Text>
) : null}
</div>
))}
</Space>
</Card>
<Modal
title={`售后说明 · ${afterSalesCase?.caseNo || ''}`}
open={Boolean(afterSalesCase)}
width={isMobile ? '92%' : 560}
onCancel={() => {
setAfterSalesCase(null)
setAfterSalesResponseFiles([])
afterSalesResponseForm.resetFields()
}}
onOk={() => afterSalesResponseForm.submit()}
okText="提交说明"
destroyOnHidden
>
{afterSalesCase ? (
<>
<Alert
type="warning"
showIcon
message="请针对售后问题说明情况或提交补救材料,原验收资料不可在此修改。"
style={{ marginBottom: 16 }}
/>
<Typography.Paragraph type="secondary">
{afterSalesCase.complaintNote}
</Typography.Paragraph>
<Form
form={afterSalesResponseForm}
layout="vertical"
onFinish={submitAfterSalesResponse}
>
<Form.Item label="说明" name="response">
<Input.TextArea rows={4} maxLength={2000} showCount />
</Form.Item>
<Form.Item label="补救材料图片">
<ImageUpload
scene="worker-acceptance"
scope="worker"
value={afterSalesResponseFiles}
onChange={setAfterSalesResponseFiles}
maxCount={10}
showPasteHint
/>
</Form.Item>
</Form>
</>
) : null}
</Modal>
</>
)
}
@@ -10,7 +10,6 @@ import {
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Alert,
Button,
Card,
Descriptions,
@@ -37,7 +36,6 @@ import ImagePreviewList from '@/components/files/ImagePreviewList'
import ImageUpload from '@/components/files/ImageUpload'
import {
fetchWorkerCancelRequests,
fetchWorkerAfterSalesCases,
fetchWorkerOrderFeedbacks,
fetchWorkerMyOrderOverview,
fetchWorkerMyOrders,
@@ -49,20 +47,41 @@ import {
supplementWorkerAcceptedOrderEvidence,
submitWorkerAcceptance,
submitWorkerCancelRequest,
submitWorkerAfterSalesCaseResponse,
submitWorkerOrderFeedback,
} from '@/services/worker'
import type {
CollectField,
UploadedFile,
WorkOrder,
WorkerAcceptance,
WorkerAfterSalesCase,
} from '@/types/worker-platform'
import type { UploadedFile, WorkOrder } from '@/types/worker-platform'
import { formatDateTime } from '@/utils/date-time'
import { getImageIdentity } from '@/utils/image-identity'
import { useIsMobile } from '@/utils/use-is-mobile'
import { buildWorkerOrderQuery } from '@/utils/worker-order-query'
import WorkerAfterSalesSection from './WorkerAfterSalesSection'
import {
asRecord,
canAutoAcceptOrder,
canEditAcceptanceImages,
canRemindAcceptance,
canRequestCancel,
canSubmitAcceptance,
canSubmitFeedback,
canSupplementAcceptedEvidence,
formatMoney,
formatStatus,
getAcceptanceSubmittedAt,
getCopyOrderInfoItems,
getDisplayOrderNo,
getMaterialDetailItems,
getMaterialScreenshots,
getSettlementLabel,
getStatusHint,
getUniqueAcceptanceFiles,
getWorkerAcceptance,
getWorkerDraft,
hasSubmittedAcceptance,
isVipEvidencePending,
patchWorkOrderReadState,
renderDetailFieldValues,
resolveStatusColor,
toPngBlob,
} from './worker-order-utils'
const STATUS_OPTIONS = [
{ value: 'in_progress', label: '代练中' },
@@ -91,10 +110,6 @@ type FeedbackFormValues = {
content?: string
}
type AfterSalesResponseFormValues = {
response?: string
}
type NoteTheme = 'success' | 'processing' | 'danger' | 'warning' | 'purple' | 'default'
const PRESET_NOTE_TAGS: Array<{ label: string; theme: NoteTheme }> = [
@@ -188,13 +203,6 @@ function resolveNoteTheme(note?: string): NoteTheme {
return 'default'
}
type DetailFieldItem = {
key: string
label: string
required: boolean
value: string
}
export default function WorkerOrdersPage() {
const isMobile = useIsMobile()
const { message } = App.useApp()
@@ -209,14 +217,11 @@ export default function WorkerOrdersPage() {
const [noteOrder, setNoteOrder] = useState<WorkOrder | null>(null)
const [cancelRequestOrder, setCancelRequestOrder] = useState<WorkOrder | null>(null)
const [feedbackOrder, setFeedbackOrder] = useState<WorkOrder | null>(null)
const [afterSalesCase, setAfterSalesCase] = useState<WorkerAfterSalesCase | null>(null)
const [acceptanceFiles, setAcceptanceFiles] = useState<UploadedFile[]>([])
const [afterSalesResponseFiles, setAfterSalesResponseFiles] = useState<UploadedFile[]>([])
const [form] = Form.useForm<AcceptanceFormValues>()
const [noteForm] = Form.useForm<WorkerNoteFormValues>()
const [cancelRequestForm] = Form.useForm<CancelRequestFormValues>()
const [feedbackForm] = Form.useForm<FeedbackFormValues>()
const [afterSalesResponseForm] = Form.useForm<AfterSalesResponseFormValues>()
const ordersQuery = useQuery({
queryKey: ['worker-my-orders', status, keyword, page, pageSize],
@@ -251,15 +256,9 @@ export default function WorkerOrdersPage() {
queryFn: fetchWorkerOrderFeedbacks,
retry: false,
})
const afterSalesCasesQuery = useQuery({
queryKey: ['worker-after-sales-cases'],
queryFn: fetchWorkerAfterSalesCases,
retry: false,
})
const overviewByStatus = new Map(
(overviewQuery.data?.data.items || []).map((item) => [item.status, item]),
)
const afterSalesCases = afterSalesCasesQuery.data?.data.items || []
async function refreshAll() {
await Promise.all([
@@ -267,7 +266,6 @@ export default function WorkerOrdersPage() {
queryClient.invalidateQueries({ queryKey: ['worker-my-orders-overview'] }),
queryClient.invalidateQueries({ queryKey: ['worker-cancel-requests'] }),
queryClient.invalidateQueries({ queryKey: ['worker-order-feedbacks'] }),
queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] }),
])
}
@@ -395,29 +393,6 @@ export default function WorkerOrdersPage() {
}
}
function openAfterSalesResponseModal(item: WorkerAfterSalesCase) {
setAfterSalesCase(item)
setAfterSalesResponseFiles([])
afterSalesResponseForm.resetFields()
}
async function submitAfterSalesResponse(values: AfterSalesResponseFormValues) {
if (!afterSalesCase) return
try {
await submitWorkerAfterSalesCaseResponse(afterSalesCase.caseId, {
response: String(values.response || '').trim(),
files: afterSalesResponseFiles,
})
message.success('售后说明已提交,等待客服处理')
setAfterSalesCase(null)
setAfterSalesResponseFiles([])
afterSalesResponseForm.resetFields()
await refreshAll()
} catch (error) {
message.error(error instanceof Error ? error.message : '提交售后说明失败')
}
}
async function saveOrderNote(values: WorkerNoteFormValues) {
if (!noteOrder) return
try {
@@ -768,65 +743,7 @@ export default function WorkerOrdersPage() {
return (
<>
{afterSalesCases.length > 0 ? (
<Card
className="worker-after-sales-card"
title="售后问题单"
size="small"
extra={
<Button
size="small"
icon={<ReloadOutlined />}
loading={afterSalesCasesQuery.isFetching}
onClick={() => afterSalesCasesQuery.refetch()}
>
</Button>
}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
{afterSalesCases.map((item) => (
<div key={item.caseId} className="worker-order-detail-value">
<Space wrap size={[8, 6]}>
<Tag color={resolveWorkerAfterSalesStatusColor(item.status)}>
{formatWorkerAfterSalesStatus(item.status)}
</Tag>
<Typography.Text strong>
{item.productName || item.workOrderNo || item.caseNo}
</Typography.Text>
<Typography.Text type="secondary">{item.caseNo}</Typography.Text>
</Space>
<Typography.Paragraph style={{ margin: '8px 0 4px' }}>
{item.complaintNote || '-'}
</Typography.Paragraph>
{item.complaintFiles.length > 0 ? (
<ImagePreviewList files={item.complaintFiles} size={52} maxVisible={4} />
) : null}
{item.status === 'awaiting_worker_response' ? (
<Button
type="primary"
size="small"
style={{ marginTop: 8 }}
onClick={() => openAfterSalesResponseModal(item)}
>
</Button>
) : item.status === 'under_review' ? (
<Typography.Text type="secondary"></Typography.Text>
) : item.status === 'recovery_pending' ? (
<Typography.Text type="danger">
{formatMoney(item.debtAmount)}
</Typography.Text>
) : item.resolutionNote ? (
<Typography.Text type="secondary">
{item.resolutionNote}
</Typography.Text>
) : null}
</div>
))}
</Space>
</Card>
) : null}
<WorkerAfterSalesSection isMobile={isMobile} />
{isMobile ? (
<div className="worker-orders-mobile-page">
{/* 顶部水平滑动状态切换胶囊条 */}
@@ -1506,52 +1423,6 @@ export default function WorkerOrdersPage() {
</Form.Item>
</Form>
</Modal>
<Modal
title={`售后说明 · ${afterSalesCase?.caseNo || ''}`}
open={Boolean(afterSalesCase)}
width={isMobile ? '92%' : 560}
onCancel={() => {
setAfterSalesCase(null)
setAfterSalesResponseFiles([])
afterSalesResponseForm.resetFields()
}}
onOk={() => afterSalesResponseForm.submit()}
okText="提交说明"
destroyOnHidden
>
{afterSalesCase ? (
<>
<Alert
type="warning"
showIcon
message="请针对售后问题说明情况或提交补救材料,原验收资料不可在此修改。"
style={{ marginBottom: 16 }}
/>
<Typography.Paragraph type="secondary">
{afterSalesCase.complaintNote}
</Typography.Paragraph>
<Form
form={afterSalesResponseForm}
layout="vertical"
onFinish={submitAfterSalesResponse}
>
<Form.Item label="说明" name="response">
<Input.TextArea rows={4} maxLength={2000} showCount />
</Form.Item>
<Form.Item label="补救材料图片">
<ImageUpload
scene="worker-acceptance"
scope="worker"
value={afterSalesResponseFiles}
onChange={setAfterSalesResponseFiles}
maxCount={10}
showPasteHint
/>
</Form.Item>
</Form>
</>
) : null}
</Modal>
<Modal
title={`订单备注 · ${getDisplayOrderNo(noteOrder)}`}
open={Boolean(noteOrder)}
@@ -1672,338 +1543,3 @@ export default function WorkerOrdersPage() {
</>
)
}
function patchWorkOrderReadState(current: unknown, workOrderId: number) {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
return {
...current,
data: {
...current.data,
items: current.data.items.map((item) =>
isRecord(item) && Number(item.workOrderId) === workOrderId
? { ...item, isUnread: false }
: item,
),
},
}
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function canSubmitAcceptance(order: WorkOrder) {
if (order.myShare) {
return order.myShare.status === 'joined' && order.status !== 'accepted'
}
return ['in_progress', 'problem'].includes(order.status)
}
function canRemindAcceptance(order: WorkOrder) {
return !order.myShare && order.status === 'pending_acceptance'
}
function canRequestCancel(order: WorkOrder) {
if (order.myShare) {
return (
['joined', 'submitted'].includes(order.myShare.status) &&
['open', 'pending_acceptance'].includes(order.status)
)
}
return order.status === 'in_progress'
}
function canSubmitFeedback(order: WorkOrder) {
if (order.myShare) return order.myShare.status !== 'cancelled'
return Boolean(order.assignedAt || order.worker)
}
function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) {
if (!workerCanAutoAcceptWithoutEvidence) return false
if (order.myShare) return order.myShare.status === 'joined' && order.status !== 'accepted'
return order.sharing?.enabled !== true && ['in_progress', 'problem'].includes(order.status)
}
function canSupplementAcceptedEvidence(order: WorkOrder) {
return order.status === 'accepted'
}
function isVipEvidencePending(order: WorkOrder) {
const acceptance = getWorkerAcceptance(order)
return acceptance?.reviewMode === 'vip_auto' && acceptance?.evidenceStatus === 'pending'
}
/** 是否可编辑验收图片:未提交时可暂存,已提交(待验收)时可补充/修改 */
function canEditAcceptanceImages(order: WorkOrder) {
if (order.myShare) {
return (
['joined', 'submitted'].includes(order.myShare.status) || canSupplementAcceptedEvidence(order)
)
}
return (
['in_progress', 'problem', 'pending_acceptance'].includes(order.status) ||
canSupplementAcceptedEvidence(order)
)
}
/** 是否已提交验收(整单 pending_acceptance / 拼单份额 submitted */
function hasSubmittedAcceptance(order: WorkOrder) {
if (order.myShare) {
return order.myShare.status === 'submitted'
}
return order.status === 'pending_acceptance' || order.status === 'accepted'
}
function getDisplayOrderNo(order: Pick<WorkOrder, 'platformOrderId'> | null | undefined): string {
return String(order?.platformOrderId || '').trim() || '-'
}
function getRequirementFields(order: WorkOrder | null): CollectField[] {
if (!order) return []
const rawFields = Array.isArray(order.requirement?.fields) ? order.requirement.fields : []
return rawFields
.map((item) => {
const source = asRecord(item)
const key = String(source.key || '').trim()
if (!key) return null
return {
key,
label: String(source.label || key).trim(),
required: source.required !== false,
}
})
.filter((item): item is CollectField => Boolean(item))
}
function getMaterialFields(order: WorkOrder): Record<string, string> {
const material = asRecord(order.material)
const collect = asRecord(material.collect)
const fields = asRecord(collect.fields || material.fields)
return Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, String(value || '')]),
)
}
/** 把任意图片 Blob 转成 PNGChrome 剪贴板写入只支持 image/png */
async function toPngBlob(source: Blob): Promise<Blob> {
const url = URL.createObjectURL(source)
try {
const image = await loadImageElement(url)
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext('2d')
if (!context) return source
context.drawImage(image, 0, 0)
const png = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
return png || source
} finally {
URL.revokeObjectURL(url)
}
}
function loadImageElement(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('图片解码失败'))
image.src = src
})
}
function getMaterialScreenshots(order: WorkOrder): UploadedFile[] {
const material = asRecord(order.material)
const collect = asRecord(material.collect)
const screenshots = Array.isArray(collect.screenshots) ? collect.screenshots : []
return screenshots
.map((item) => {
const file = asRecord(item)
const url = String(file.url || '').trim()
if (!url) return null
return {
objectKey: String(file.objectKey || file.object_key || '').trim(),
filename: String(file.originalFilename || file.filename || '').trim(),
url,
mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(),
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(),
} as UploadedFile
})
.filter((item): item is UploadedFile => Boolean(item))
}
function getMaterialDetailItems(order: WorkOrder): DetailFieldItem[] {
const values = getMaterialFields(order)
const requiredFields = getRequirementFields(order)
const usedKeys = new Set<string>()
const items = requiredFields.map((field) => {
usedKeys.add(field.key)
return {
key: field.key,
label: field.label,
required: field.required,
value: String(values[field.key] || ''),
}
})
Object.entries(values).forEach(([key, value]) => {
if (usedKeys.has(key)) return
items.push({
key,
label: key,
required: false,
value: String(value || ''),
})
})
return items
}
function getCopyOrderInfoItems(order: WorkOrder): Array<Pick<DetailFieldItem, 'label' | 'value'>> {
const productName = String(order.productName || '').trim()
const materialItems = getMaterialDetailItems(order).filter((item) =>
String(item.value || '').trim(),
)
return [...(productName ? [{ label: '物品', value: productName }] : []), ...materialItems]
}
function renderDetailFieldValues(items: DetailFieldItem[], emptyText: string) {
if (items.length === 0) {
return <Typography.Text type="secondary">{emptyText}</Typography.Text>
}
return (
<Descriptions column={1} bordered size="small">
{items.map((item) => (
<Descriptions.Item
key={item.key}
label={
<Space size={6} wrap>
<span>{item.label}</span>
{item.required ? <Tag color="orange"></Tag> : null}
</Space>
}
>
<span className="worker-order-detail-value">
{String(item.value || '').trim() || '-'}
</span>
</Descriptions.Item>
))}
</Descriptions>
)
}
/** 打手自己的验收资料(拼单时取份额自己的) */
function getWorkerAcceptance(order: WorkOrder | null | undefined): WorkerAcceptance | undefined {
if (!order) return undefined
return order.myShare ? order.myShare.acceptance : order.acceptance
}
/** 打手自己的暂存草稿(拼单时取份额自己的) */
function getWorkerDraft(order: WorkOrder | null | undefined): WorkerAcceptance | undefined {
if (!order) return undefined
return order.myShare?.draftAcceptance || order.draftAcceptance
}
function getAcceptanceFilesFromSource(source?: WorkerAcceptance | null): UploadedFile[] {
if (!source || !Array.isArray(source.files)) return []
return source.files
}
/** 合并草稿兼容字段,并按固定图片地址去重,避免同一图片重复回填。 */
function getUniqueAcceptanceFiles(source?: WorkerAcceptance | null): UploadedFile[] {
if (!source) return []
const files = [
...getAcceptanceFilesFromSource(source),
...(Array.isArray(source.imageUrls) ? source.imageUrls : []).map(
(url): UploadedFile => ({
objectKey: '',
url: String(url || ''),
thumbnailUrl: String(url || ''),
mediumUrl: String(url || ''),
filename: '图片',
contentType: '',
size: 0,
}),
),
]
const identities = new Set<string>()
return files.filter((file) => {
const identity = getImageIdentity(file)
if (!identity || identities.has(identity)) return false
identities.add(identity)
return true
})
}
function getAcceptanceSubmittedAt(order: WorkOrder): string | null {
if (order.myShare) return order.myShare.submittedAt
const acceptanceSubmittedAt =
typeof order.acceptance?.submittedAt === 'string' ? order.acceptance.submittedAt : null
return order.submittedAt || acceptanceSubmittedAt || null
}
function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
function getSettlementLabel(status: string) {
if (status === 'accepted' || status === 'vip_evidence_pending') return '已结算'
if (!status) return '结算合计'
return '预计'
}
function formatWorkerAfterSalesStatus(status: string) {
if (status === 'awaiting_worker_response') return '待说明'
if (status === 'under_review') return '处理中'
if (status === 'recovery_pending') return '追缴中'
return '已关闭'
}
function resolveWorkerAfterSalesStatusColor(status: string) {
if (status === 'awaiting_worker_response') return 'orange'
if (status === 'under_review') return 'blue'
if (status === 'recovery_pending') return 'red'
return 'green'
}
function formatStatus(status: string) {
const labels: Record<string, string> = {
open: '已超时',
in_progress: '代练中',
pending_acceptance: '待验收',
problem: '问题单',
accepted: '已验收',
cancelled: '已取消',
}
return labels[status] || status
}
function getStatusHint(status: string) {
const hints: Record<string, string> = {
open: '任务已超时被系统判定失败,如需继续可前往大厅重新抢单。',
in_progress: '订单进行中,完成后请及时提交验收资料。',
pending_acceptance: '已提交验收,等待后台审核。',
problem: '后台已标记问题,请根据备注调整后重新提交。',
accepted: '订单已完成验收,可以在个人中心查看结算记录。',
cancelled: '订单已取消,本次流程已结束。',
}
return hints[status] || '请关注当前订单状态变化。'
}
function resolveStatusColor(status: string) {
if (status === 'accepted') return 'green'
if (status === 'cancelled') return 'default'
if (status === 'problem') return 'red'
if (status === 'pending_acceptance') return 'gold'
if (status === 'open') return 'red'
return 'blue'
}
@@ -0,0 +1,328 @@
import { Descriptions, Space, Tag, Typography } from 'antd'
import type {
CollectField,
UploadedFile,
WorkerAcceptance,
WorkOrder,
} from '@/types/worker-platform'
import { getImageIdentity } from '@/utils/image-identity'
type DetailFieldItem = {
key: string
label: string
required: boolean
value: string
}
export function patchWorkOrderReadState(current: unknown, workOrderId: number) {
if (!isRecord(current) || !isRecord(current.data) || !Array.isArray(current.data.items)) {
return current
}
return {
...current,
data: {
...current.data,
items: current.data.items.map((item) =>
isRecord(item) && Number(item.workOrderId) === workOrderId
? { ...item, isUnread: false }
: item,
),
},
}
}
export function canSubmitAcceptance(order: WorkOrder) {
if (order.myShare) {
return order.myShare.status === 'joined' && order.status !== 'accepted'
}
return ['in_progress', 'problem'].includes(order.status)
}
export function canRemindAcceptance(order: WorkOrder) {
return !order.myShare && order.status === 'pending_acceptance'
}
export function canRequestCancel(order: WorkOrder) {
if (order.myShare) {
return (
['joined', 'submitted'].includes(order.myShare.status) &&
['open', 'pending_acceptance'].includes(order.status)
)
}
return order.status === 'in_progress'
}
export function canSubmitFeedback(order: WorkOrder) {
if (order.myShare) return order.myShare.status !== 'cancelled'
return Boolean(order.assignedAt || order.worker)
}
export function canAutoAcceptOrder(order: WorkOrder, workerCanAutoAcceptWithoutEvidence: boolean) {
if (!workerCanAutoAcceptWithoutEvidence) return false
if (order.myShare) return order.myShare.status === 'joined' && order.status !== 'accepted'
return order.sharing?.enabled !== true && ['in_progress', 'problem'].includes(order.status)
}
export function canSupplementAcceptedEvidence(order: WorkOrder) {
return order.status === 'accepted'
}
export function isVipEvidencePending(order: WorkOrder) {
const acceptance = getWorkerAcceptance(order)
return acceptance?.reviewMode === 'vip_auto' && acceptance?.evidenceStatus === 'pending'
}
/** 是否可编辑验收图片:未提交时可暂存,已提交时可补充或修改。 */
export function canEditAcceptanceImages(order: WorkOrder) {
if (order.myShare) {
return (
['joined', 'submitted'].includes(order.myShare.status) || canSupplementAcceptedEvidence(order)
)
}
return (
['in_progress', 'problem', 'pending_acceptance'].includes(order.status) ||
canSupplementAcceptedEvidence(order)
)
}
/** 是否已提交验收:拼单依据份额状态,整单依据工单状态。 */
export function hasSubmittedAcceptance(order: WorkOrder) {
if (order.myShare) {
return order.myShare.status === 'submitted'
}
return order.status === 'pending_acceptance' || order.status === 'accepted'
}
export function getDisplayOrderNo(
order: Pick<WorkOrder, 'platformOrderId'> | null | undefined,
): string {
return String(order?.platformOrderId || '').trim() || '-'
}
export function getRequirementFields(order: WorkOrder | null): CollectField[] {
if (!order) return []
const rawFields = Array.isArray(order.requirement?.fields) ? order.requirement.fields : []
return rawFields
.map((item) => {
const source = asRecord(item)
const key = String(source.key || '').trim()
if (!key) return null
return {
key,
label: String(source.label || key).trim(),
required: source.required !== false,
}
})
.filter((item): item is CollectField => Boolean(item))
}
export function getMaterialFields(order: WorkOrder): Record<string, string> {
const material = asRecord(order.material)
const collect = asRecord(material.collect)
const fields = asRecord(collect.fields || material.fields)
return Object.fromEntries(
Object.entries(fields).map(([key, value]) => [key, String(value || '')]),
)
}
/** 把任意图片 Blob 转成 PNG,兼容 Chrome 剪贴板 API。 */
export async function toPngBlob(source: Blob): Promise<Blob> {
const url = URL.createObjectURL(source)
try {
const image = await loadImageElement(url)
const canvas = document.createElement('canvas')
canvas.width = image.naturalWidth
canvas.height = image.naturalHeight
const context = canvas.getContext('2d')
if (!context) return source
const png = await new Promise<Blob | null>((resolve) => canvas.toBlob(resolve, 'image/png'))
return png || source
} finally {
URL.revokeObjectURL(url)
}
}
export function getMaterialScreenshots(order: WorkOrder): UploadedFile[] {
const material = asRecord(order.material)
const collect = asRecord(material.collect)
const screenshots = Array.isArray(collect.screenshots) ? collect.screenshots : []
return screenshots
.map((item) => {
const file = asRecord(item)
const url = String(file.url || '').trim()
if (!url) return null
return {
objectKey: String(file.objectKey || file.object_key || '').trim(),
filename: String(file.originalFilename || file.filename || '').trim(),
url,
mediumUrl: String(file.mediumUrl || file.medium_url || '').trim(),
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || '').trim(),
} as UploadedFile
})
.filter((item): item is UploadedFile => Boolean(item))
}
export function getMaterialDetailItems(order: WorkOrder): DetailFieldItem[] {
const values = getMaterialFields(order)
const requiredFields = getRequirementFields(order)
const usedKeys = new Set<string>()
const items = requiredFields.map((field) => {
usedKeys.add(field.key)
return {
key: field.key,
label: field.label,
required: field.required,
value: String(values[field.key] || ''),
}
})
Object.entries(values).forEach(([key, value]) => {
if (usedKeys.has(key)) return
items.push({ key, label: key, required: false, value: String(value || '') })
})
return items
}
export function getCopyOrderInfoItems(
order: WorkOrder,
): Array<Pick<DetailFieldItem, 'label' | 'value'>> {
const productName = String(order.productName || '').trim()
const materialItems = getMaterialDetailItems(order).filter((item) =>
String(item.value || '').trim(),
)
return [...(productName ? [{ label: '物品', value: productName }] : []), ...materialItems]
}
export function renderDetailFieldValues(items: DetailFieldItem[], emptyText: string) {
if (items.length === 0) {
return <Typography.Text type="secondary">{emptyText}</Typography.Text>
}
return (
<Descriptions column={1} bordered size="small">
{items.map((item) => (
<Descriptions.Item
key={item.key}
label={
<Space size={6} wrap>
<span>{item.label}</span>
{item.required ? <Tag color="orange"></Tag> : null}
</Space>
}
>
<span className="worker-order-detail-value">
{String(item.value || '').trim() || '-'}
</span>
</Descriptions.Item>
))}
</Descriptions>
)
}
/** 打手自己的验收资料,拼单时只读取其责任份额。 */
export function getWorkerAcceptance(
order: WorkOrder | null | undefined,
): WorkerAcceptance | undefined {
if (!order) return undefined
return order.myShare ? order.myShare.acceptance : order.acceptance
}
/** 打手自己的暂存草稿,拼单时只读取其责任份额。 */
export function getWorkerDraft(order: WorkOrder | null | undefined): WorkerAcceptance | undefined {
if (!order) return undefined
return order.myShare?.draftAcceptance || order.draftAcceptance
}
export function getUniqueAcceptanceFiles(source?: WorkerAcceptance | null): UploadedFile[] {
if (!source) return []
const files = [
...(Array.isArray(source.files) ? source.files : []),
...(Array.isArray(source.imageUrls) ? source.imageUrls : []).map(
(url): UploadedFile => ({
objectKey: '',
url: String(url || ''),
thumbnailUrl: String(url || ''),
mediumUrl: String(url || ''),
filename: '图片',
contentType: '',
size: 0,
}),
),
]
const identities = new Set<string>()
return files.filter((file) => {
const identity = getImageIdentity(file)
if (!identity || identities.has(identity)) return false
identities.add(identity)
return true
})
}
export function getAcceptanceSubmittedAt(order: WorkOrder): string | null {
if (order.myShare) return order.myShare.submittedAt
const acceptanceSubmittedAt =
typeof order.acceptance?.submittedAt === 'string' ? order.acceptance.submittedAt : null
return order.submittedAt || acceptanceSubmittedAt || null
}
export function asRecord(value: unknown): Record<string, unknown> {
return value && typeof value === 'object' && !Array.isArray(value)
? (value as Record<string, unknown>)
: {}
}
export function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
export function getSettlementLabel(status: string) {
if (status === 'accepted' || status === 'vip_evidence_pending') return '已结算'
if (!status) return '结算合计'
return '预计'
}
export function formatStatus(status: string) {
const labels: Record<string, string> = {
open: '已超时',
in_progress: '代练中',
pending_acceptance: '待验收',
problem: '问题单',
accepted: '已验收',
cancelled: '已取消',
}
return labels[status] || status
}
export function getStatusHint(status: string) {
const hints: Record<string, string> = {
open: '任务已超时被系统判定失败,如需继续可前往大厅重新抢单。',
in_progress: '订单进行中,完成后请及时提交验收资料。',
pending_acceptance: '已提交验收,等待后台审核。',
problem: '后台已标记问题,请根据备注调整后重新提交。',
accepted: '订单已完成验收,可以在个人中心查看结算记录。',
cancelled: '订单已取消,本次流程已结束。',
}
return hints[status] || '请关注当前订单状态变化。'
}
export function resolveStatusColor(status: string) {
if (status === 'accepted') return 'green'
if (status === 'cancelled') return 'default'
if (status === 'problem') return 'red'
if (status === 'pending_acceptance') return 'gold'
if (status === 'open') return 'red'
return 'blue'
}
function isRecord(value: unknown): value is Record<string, unknown> {
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
}
function loadImageElement(src: string): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image()
image.onload = () => resolve(image)
image.onerror = () => reject(new Error('图片解码失败'))
image.src = src
})
}