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

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,470 @@
import { ReloadOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Alert,
Button,
Card,
Form,
Input,
InputNumber,
Modal,
Select,
Space,
Table,
Tag,
Typography,
} from 'antd'
import { forwardRef, useImperativeHandle, useState } from 'react'
import ImageUpload from '@/components/files/ImageUpload'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import {
createAdminAfterSalesCase,
fetchAdminAfterSalesCases,
fetchAdminWorkOrderSharing,
resolveAdminAfterSalesCase,
} from '@/services/admin'
import type { UploadedFile, WorkerAfterSalesCase, WorkOrder } from '@/types/worker-platform'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatAdminDateTime } from '@/utils/admin-time'
import { asRecord, formatMoney } from './shared'
type AfterSalesCreateFormValues = {
shareId?: number
problemType?: string
complaintNote?: string
}
type AfterSalesResolutionFormValues = {
action?: 'not_upheld' | 'remedied' | 'upheld'
resolutionNote?: string
recoveryAmount?: number
}
export type AfterSalesPanelHandle = {
openCreateModal: (row: WorkOrder) => void
}
const AfterSalesPanel = forwardRef<AfterSalesPanelHandle>(function AfterSalesPanel(_, ref) {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [afterSalesOrder, setAfterSalesOrder] = useState<WorkOrder | null>(null)
const [afterSalesCase, setAfterSalesCase] = useState<WorkerAfterSalesCase | null>(null)
const [afterSalesFiles, setAfterSalesFiles] = useState<UploadedFile[]>([])
const [afterSalesPage, setAfterSalesPage] = useState(1)
const [afterSalesPageSize, setAfterSalesPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
const [afterSalesCreateForm] = Form.useForm<AfterSalesCreateFormValues>()
const [afterSalesResolutionForm] = Form.useForm<AfterSalesResolutionFormValues>()
const afterSalesSharingQuery = useQuery({
queryKey: ['admin-worker-platform-after-sales-sharing', afterSalesOrder?.workOrderId],
queryFn: () => fetchAdminWorkOrderSharing(Number(afterSalesOrder?.workOrderId)),
enabled: Boolean(afterSalesOrder?.sharing?.enabled),
})
const afterSalesCasesQuery = useQuery({
queryKey: ['admin-worker-platform-after-sales-cases', afterSalesPage, afterSalesPageSize],
queryFn: () =>
fetchAdminAfterSalesCases({ page: afterSalesPage, pageSize: afterSalesPageSize }),
})
useImperativeHandle(ref, () => ({
openCreateModal: (row: WorkOrder) => {
setAfterSalesOrder(row)
setAfterSalesFiles([])
afterSalesCreateForm.setFieldsValue({
shareId: undefined,
problemType: 'fake_evidence',
complaintNote: '',
})
},
}))
async function runAction<T>(
action: () => Promise<T>,
successMessage: string | ((result: T) => string),
) {
try {
const result = await action()
message.success(
typeof successMessage === 'function' ? successMessage(result) : successMessage,
)
await queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-after-sales-cases'],
})
return true
} catch (error) {
message.error(error instanceof Error ? error.message : '操作失败')
return false
}
}
async function submitAfterSalesCreate(values: AfterSalesCreateFormValues) {
if (!afterSalesOrder) return
const succeeded = await runAction(
() =>
createAdminAfterSalesCase(afterSalesOrder.workOrderId, {
shareId: values.shareId || null,
problemType: values.problemType || 'fake_evidence',
complaintNote: String(values.complaintNote || '').trim(),
files: afterSalesFiles,
}),
'售后问题单已创建,已通知责任打手说明',
)
if (!succeeded) return
setAfterSalesOrder(null)
setAfterSalesFiles([])
afterSalesCreateForm.resetFields()
}
function openAfterSalesResolutionModal(item: WorkerAfterSalesCase) {
setAfterSalesCase(item)
afterSalesResolutionForm.setFieldsValue({
action: 'upheld',
resolutionNote: '',
recoveryAmount: Number(item.recoveryAmount || 0) / 100,
})
}
async function submitAfterSalesResolution(values: AfterSalesResolutionFormValues) {
const action = values.action
if (!afterSalesCase || !action) return
const succeeded = await runAction(
() =>
resolveAdminAfterSalesCase(afterSalesCase.caseId, {
action,
resolutionNote: String(values.resolutionNote || '').trim(),
recoveryAmount: action === 'upheld' ? Number(values.recoveryAmount || 0) : 0,
}),
'售后问题单已处置',
)
if (!succeeded) return
setAfterSalesCase(null)
afterSalesResolutionForm.resetFields()
}
return (
<>
<Card
title="验收后售后问题单"
extra={
<Button
icon={<ReloadOutlined />}
loading={afterSalesCasesQuery.isFetching}
onClick={() => afterSalesCasesQuery.refetch()}
>
</Button>
}
bordered={false}
>
<Table<WorkerAfterSalesCase>
rowKey="caseId"
loading={afterSalesCasesQuery.isLoading}
dataSource={afterSalesCasesQuery.data?.data.items || []}
pagination={buildAdminTablePagination({
current: afterSalesCasesQuery.data?.data.pagination?.page || afterSalesPage,
pageSize: afterSalesCasesQuery.data?.data.pagination?.pageSize || afterSalesPageSize,
total: afterSalesCasesQuery.data?.data.pagination?.total || 0,
onChange: (nextPage, nextPageSize) => {
setAfterSalesPage(nextPage)
setAfterSalesPageSize(nextPageSize)
},
})}
scroll={{ x: 1160 }}
columns={[
{
title: '售后单',
width: 190,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text strong>{item.caseNo}</Typography.Text>
<Typography.Text type="secondary">{item.workOrderNo || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatAdminDateTime(item.createdAt)}
</Typography.Text>
</div>
),
},
{
title: '责任打手',
width: 150,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text>{item.workerName || `打手#${item.workerId}`}</Typography.Text>
{item.shareQuantity ? (
<Tag color="purple"> {item.shareQuantity} </Tag>
) : null}
</div>
),
},
{
title: '投诉与证据',
width: 260,
render: (_, item) => (
<div className="cell-stack">
<Tag color="red">{formatAfterSalesProblemType(item.problemType)}</Tag>
<Typography.Text ellipsis={{ tooltip: item.complaintNote }}>
{item.complaintNote || '-'}
</Typography.Text>
{item.complaintFiles.length > 0 ? (
<ImagePreviewList files={item.complaintFiles} size={36} maxVisible={3} />
) : null}
{getAfterSalesSnapshotFiles(item).length > 0 ? (
<Space size={6} align="center">
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<ImagePreviewList
files={getAfterSalesSnapshotFiles(item)}
size={36}
maxVisible={3}
/>
</Space>
) : null}
</div>
),
},
{
title: '打手说明',
width: 250,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text ellipsis={{ tooltip: item.workerResponse }}>
{item.workerResponse || '暂未提交'}
</Typography.Text>
{item.workerResponseFiles.length > 0 ? (
<ImagePreviewList files={item.workerResponseFiles} size={36} maxVisible={3} />
) : null}
{item.respondedAt ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatAdminDateTime(item.respondedAt)}
</Typography.Text>
) : null}
</div>
),
},
{
title: '处置与追缴',
width: 240,
render: (_, item) => (
<div className="cell-stack">
<Tag color={resolveAfterSalesStatusColor(item.status)}>
{formatAfterSalesStatus(item.status)}
</Tag>
{item.resolutionAction ? (
<Typography.Text type="secondary" ellipsis={{ tooltip: item.resolutionNote }}>
{formatAfterSalesResolution(item.resolutionAction)} ·{' '}
{item.resolutionNote || '-'}
</Typography.Text>
) : null}
{item.resolutionAction === 'upheld' ? (
<Typography.Text type={item.debtAmount > 0 ? 'danger' : undefined}>
{formatMoney(item.recoveryAmount)} {formatMoney(item.debtAmount)}
</Typography.Text>
) : null}
</div>
),
},
{
title: '操作',
width: 120,
render: (_, item) =>
['awaiting_worker_response', 'under_review'].includes(item.status) ? (
<Button
type="primary"
size="small"
onClick={() => openAfterSalesResolutionModal(item)}
>
</Button>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
]}
/>
</Card>
<Modal
title={`发起验收后售后 · ${afterSalesOrder ? String(afterSalesOrder.platformOrderId || '').trim() || '-' : ''}`}
open={Boolean(afterSalesOrder)}
onCancel={() => {
setAfterSalesOrder(null)
setAfterSalesFiles([])
afterSalesCreateForm.resetFields()
}}
onOk={() => afterSalesCreateForm.submit()}
destroyOnHidden
>
{afterSalesOrder ? (
<Form form={afterSalesCreateForm} layout="vertical" onFinish={submitAfterSalesCreate}>
<Alert
showIcon
type="warning"
message="售后不会回退已验收工单;原验收资料会在创建时固化。"
style={{ marginBottom: 16 }}
/>
{(afterSalesSharingQuery.data?.data.shares || []).some(
(share) => share.status === 'accepted',
) ? (
<Form.Item
label="责任拼单份额"
name="shareId"
rules={[{ required: true, message: '请选择责任打手的已验收份额' }]}
>
<Select
loading={afterSalesSharingQuery.isLoading}
placeholder="请选择责任拼单份额"
options={(afterSalesSharingQuery.data?.data.shares || [])
.filter((share) => share.status === 'accepted')
.map((share) => ({
value: share.shareId,
label: `${share.worker?.displayName || share.worker?.username || `打手#${share.workerId}`} · ${share.quantity}`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label="问题类型" name="problemType" rules={[{ required: true }]}>
<Select
options={[
{ value: 'fake_evidence', label: '疑似 P 图 / 伪造验收' },
{ value: 'result_not_met', label: '实际结果未达标' },
{ value: 'other', label: '其他售后问题' },
]}
/>
</Form.Item>
<Form.Item
label="用户投诉说明"
name="complaintNote"
rules={[{ required: true, message: '请填写用户投诉说明' }]}
>
<Input.TextArea rows={4} placeholder="记录用户反馈、核查结论及需打手说明的事项" />
</Form.Item>
<Form.Item label="用户证据图片">
<ImageUpload
scene="collect-material"
scope="admin"
value={afterSalesFiles}
onChange={setAfterSalesFiles}
maxCount={10}
showPasteHint
/>
</Form.Item>
</Form>
) : null}
</Modal>
<Modal
title={`处置售后问题单 · ${afterSalesCase?.caseNo || ''}`}
open={Boolean(afterSalesCase)}
onCancel={() => {
setAfterSalesCase(null)
afterSalesResolutionForm.resetFields()
}}
onOk={() => afterSalesResolutionForm.submit()}
destroyOnHidden
>
{afterSalesCase ? (
<Form
form={afterSalesResolutionForm}
layout="vertical"
onFinish={submitAfterSalesResolution}
>
<Form.Item
label="处置结论"
name="action"
rules={[{ required: true, message: '请选择处置结论' }]}
>
<Select
options={[
{ value: 'upheld', label: '问题成立,执行追缴' },
{ value: 'remedied', label: '已补救完成,不追缴' },
{ value: 'not_upheld', label: '问题不成立,关闭售后' },
]}
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(previous, current) => previous.action !== current.action}
>
{({ getFieldValue }) =>
getFieldValue('action') === 'upheld' ? (
<Form.Item
label="追缴金额"
name="recoveryAmount"
rules={[{ required: true, message: '请输入追缴金额' }]}
extra="系统依次扣减该责任打手的本单待解冻押金、可用余额;不足部分转为欠款,并从后续验收报酬自动抵扣。"
>
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item
label="处置备注"
name="resolutionNote"
rules={[{ required: true, message: '请填写处置备注' }]}
>
<Input.TextArea rows={4} />
</Form.Item>
</Form>
) : null}
</Modal>
</>
)
})
function formatAfterSalesProblemType(value: string): string {
if (value === 'fake_evidence') return '疑似 P 图 / 伪造验收'
if (value === 'result_not_met') return '实际结果未达标'
return '其他售后问题'
}
function formatAfterSalesStatus(value: string): string {
if (value === 'awaiting_worker_response') return '待打手说明'
if (value === 'under_review') return '客服处理中'
if (value === 'recovery_pending') return '追缴中'
return '已关闭'
}
function resolveAfterSalesStatusColor(value: string): string {
if (value === 'awaiting_worker_response') return 'orange'
if (value === 'under_review') return 'blue'
if (value === 'recovery_pending') return 'red'
return 'green'
}
function formatAfterSalesResolution(value: string): string {
if (value === 'upheld') return '问题成立'
if (value === 'remedied') return '补救完成'
if (value === 'not_upheld') return '问题不成立'
return value || '-'
}
function getAfterSalesSnapshotFiles(item: WorkerAfterSalesCase): UploadedFile[] {
const snapshot = asRecord(item.acceptanceSnapshot)
const acceptance = asRecord(snapshot.acceptance)
const files = Array.isArray(acceptance.files) ? acceptance.files : []
const imageUrls = Array.isArray(acceptance.imageUrls) ? acceptance.imageUrls : []
return [
...files.map((value) => asRecord(value)),
...imageUrls.map((url) => asRecord({ url: String(url || '') })),
]
.map((file) => {
const url = String(file.url || '').trim()
if (!url) return null
return {
objectKey: String(file.objectKey || file.object_key || ''),
url,
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || url),
mediumUrl: String(file.mediumUrl || file.medium_url || url),
filename: String(file.filename || '验收图片'),
contentType: String(file.contentType || ''),
size: Number(file.size || 0),
} satisfies UploadedFile
})
.filter((file): file is UploadedFile => file !== null)
}
export default AfterSalesPanel
@@ -43,6 +43,7 @@ import JsonPreview from '@/components/admin/JsonPreview'
import WorkOrderEventTimeline from '@/components/WorkOrderEventTimeline'
import ImageUpload from '@/components/files/ImageUpload'
import ImagePreviewList from '@/components/files/ImagePreviewList'
import AfterSalesPanel, { type AfterSalesPanelHandle } from './AfterSalesPanel'
import {
acceptAdminWorkOrder,
acceptAdminWorkOrderShare,
@@ -51,12 +52,10 @@ import {
cancelAdminWorkOrder,
cancelAdminWorkOrderShare,
createAdminMockWorkOrder,
createAdminAfterSalesCase,
deductAdminWorkOrderPendingDeposit,
deleteAdminWorkOrder,
fetchAdminWorkCategories,
fetchAdminWorkOrderSharing,
fetchAdminAfterSalesCases,
fetchAdminWorkerLevels,
fetchAdminWorkerUsers,
fetchAdminWorkOrders,
@@ -65,7 +64,6 @@ import {
publishAdminWorkOrder,
reopenAdminCancelledWorkOrder,
resolveAdminProblemWorkOrder,
resolveAdminAfterSalesCase,
submitAdminWorkOrderMaterial,
unpublishAdminWorkOrder,
updateAdminWorkOrder,
@@ -78,7 +76,6 @@ import type {
WorkOrder,
WorkOrderShare,
WorkOrderStatistics,
WorkerAfterSalesCase,
} from '@/types/worker-platform'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { hasAdminRole } from '@/utils/admin-auth'
@@ -133,23 +130,12 @@ type CancelShareFormValues = {
reason?: string
}
type AfterSalesCreateFormValues = {
shareId?: number
problemType?: string
complaintNote?: string
}
type AfterSalesResolutionFormValues = {
action?: 'not_upheld' | 'remedied' | 'upheld'
resolutionNote?: string
recoveryAmount?: number
}
export default function WorkOrdersPanel() {
const { message, modal } = App.useApp()
const navigate = useNavigate()
const [searchParams, setSearchParams] = useSearchParams()
const queryClient = useQueryClient()
const afterSalesPanelRef = useRef<AfterSalesPanelHandle>(null)
const canEditRequirementFields = hasAdminRole('operator')
const canViewStatistics = hasAdminRole('operator')
const [statuses, setStatuses] = useState<string[]>([])
@@ -176,17 +162,12 @@ export default function WorkOrdersPanel() {
const [editOrder, setEditOrder] = useState<WorkOrder | null>(null)
const [deductOrder, setDeductOrder] = useState<WorkOrder | null>(null)
const [cancelOrder, setCancelOrder] = useState<WorkOrder | null>(null)
const [afterSalesOrder, setAfterSalesOrder] = useState<WorkOrder | null>(null)
const [afterSalesCase, setAfterSalesCase] = useState<WorkerAfterSalesCase | null>(null)
const [afterSalesFiles, setAfterSalesFiles] = useState<UploadedFile[]>([])
const [problemForm] = Form.useForm()
const [resolutionForm] = Form.useForm()
const [materialForm] = Form.useForm<MaterialFormValues>()
const [cancelForm] = Form.useForm<CancelOrderFormValues>()
const [cancelShareForm] = Form.useForm<CancelShareFormValues>()
const [deductForm] = Form.useForm<{ amount?: number; note?: string }>()
const [afterSalesCreateForm] = Form.useForm<AfterSalesCreateFormValues>()
const [afterSalesResolutionForm] = Form.useForm<AfterSalesResolutionFormValues>()
const [editForm] = Form.useForm<{
productName?: string
platformOrderId?: string
@@ -279,15 +260,6 @@ export default function WorkOrdersPanel() {
queryFn: () => fetchAdminWorkOrderSharing(Number(sharingOrder?.workOrderId)),
enabled: Boolean(sharingOrder),
})
const afterSalesSharingQuery = useQuery({
queryKey: ['admin-worker-platform-after-sales-sharing', afterSalesOrder?.workOrderId],
queryFn: () => fetchAdminWorkOrderSharing(Number(afterSalesOrder?.workOrderId)),
enabled: Boolean(afterSalesOrder?.sharing?.enabled),
})
const afterSalesCasesQuery = useQuery({
queryKey: ['admin-worker-platform-after-sales-cases'],
queryFn: () => fetchAdminAfterSalesCases({ page: 1, pageSize: 100 }),
})
const assignWorkersQuery = useQuery({
queryKey: ['admin-worker-platform-workers', 'assign', assignWorkerKeyword],
queryFn: () =>
@@ -444,9 +416,6 @@ export default function WorkOrdersPanel() {
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-summary'],
}),
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-after-sales-cases'],
}),
])
}
@@ -658,60 +627,6 @@ export default function WorkOrdersPanel() {
})
}
function openAfterSalesCreateModal(row: WorkOrder) {
setAfterSalesOrder(row)
setAfterSalesFiles([])
afterSalesCreateForm.setFieldsValue({
shareId: undefined,
problemType: 'fake_evidence',
complaintNote: '',
})
}
async function submitAfterSalesCreate(values: AfterSalesCreateFormValues) {
if (!afterSalesOrder) return
const succeeded = await runAction(
() =>
createAdminAfterSalesCase(afterSalesOrder.workOrderId, {
shareId: values.shareId || null,
problemType: values.problemType || 'fake_evidence',
complaintNote: String(values.complaintNote || '').trim(),
files: afterSalesFiles,
}),
'售后问题单已创建,已通知责任打手说明',
)
if (!succeeded) return
setAfterSalesOrder(null)
setAfterSalesFiles([])
afterSalesCreateForm.resetFields()
}
function openAfterSalesResolutionModal(item: WorkerAfterSalesCase) {
setAfterSalesCase(item)
afterSalesResolutionForm.setFieldsValue({
action: 'upheld',
resolutionNote: '',
recoveryAmount: Number(item.recoveryAmount || 0) / 100,
})
}
async function submitAfterSalesResolution(values: AfterSalesResolutionFormValues) {
const action = values.action
if (!afterSalesCase || !action) return
const succeeded = await runAction(
() =>
resolveAdminAfterSalesCase(afterSalesCase.caseId, {
action,
resolutionNote: String(values.resolutionNote || '').trim(),
recoveryAmount: action === 'upheld' ? Number(values.recoveryAmount || 0) : 0,
}),
'售后问题单已处置',
)
if (!succeeded) return
setAfterSalesCase(null)
afterSalesResolutionForm.resetFields()
}
async function submitDeduct(values: { amount?: number; note?: string }) {
if (!deductOrder) return
const succeeded = await runAction(
@@ -1074,7 +989,7 @@ export default function WorkOrdersPanel() {
<Button
icon={<WarningOutlined />}
danger
onClick={() => openAfterSalesCreateModal(row)}
onClick={() => afterSalesPanelRef.current?.openCreateModal(row)}
>
</Button>
@@ -1250,138 +1165,7 @@ export default function WorkOrdersPanel() {
scroll={{ x: 1240 }}
/>
</Card>
<Card
title="验收后售后问题单"
extra={
<Button
icon={<ReloadOutlined />}
loading={afterSalesCasesQuery.isFetching}
onClick={() => afterSalesCasesQuery.refetch()}
>
</Button>
}
bordered={false}
>
<Table<WorkerAfterSalesCase>
rowKey="caseId"
loading={afterSalesCasesQuery.isLoading}
dataSource={afterSalesCasesQuery.data?.data.items || []}
pagination={false}
scroll={{ x: 1160 }}
columns={[
{
title: '售后单',
width: 190,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text strong>{item.caseNo}</Typography.Text>
<Typography.Text type="secondary">{item.workOrderNo || '-'}</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatAdminDateTime(item.createdAt)}
</Typography.Text>
</div>
),
},
{
title: '责任打手',
width: 150,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text>{item.workerName || `打手#${item.workerId}`}</Typography.Text>
{item.shareQuantity ? (
<Tag color="purple"> {item.shareQuantity} </Tag>
) : null}
</div>
),
},
{
title: '投诉与证据',
width: 260,
render: (_, item) => (
<div className="cell-stack">
<Tag color="red">{formatAfterSalesProblemType(item.problemType)}</Tag>
<Typography.Text ellipsis={{ tooltip: item.complaintNote }}>
{item.complaintNote || '-'}
</Typography.Text>
{item.complaintFiles.length > 0 ? (
<ImagePreviewList files={item.complaintFiles} size={36} maxVisible={3} />
) : null}
{getAfterSalesSnapshotFiles(item).length > 0 ? (
<Space size={6} align="center">
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
<ImagePreviewList
files={getAfterSalesSnapshotFiles(item)}
size={36}
maxVisible={3}
/>
</Space>
) : null}
</div>
),
},
{
title: '打手说明',
width: 250,
render: (_, item) => (
<div className="cell-stack">
<Typography.Text ellipsis={{ tooltip: item.workerResponse }}>
{item.workerResponse || '暂未提交'}
</Typography.Text>
{item.workerResponseFiles.length > 0 ? (
<ImagePreviewList files={item.workerResponseFiles} size={36} maxVisible={3} />
) : null}
{item.respondedAt ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatAdminDateTime(item.respondedAt)}
</Typography.Text>
) : null}
</div>
),
},
{
title: '处置与追缴',
width: 240,
render: (_, item) => (
<div className="cell-stack">
<Tag color={resolveAfterSalesStatusColor(item.status)}>
{formatAfterSalesStatus(item.status)}
</Tag>
{item.resolutionAction ? (
<Typography.Text type="secondary" ellipsis={{ tooltip: item.resolutionNote }}>
{formatAfterSalesResolution(item.resolutionAction)} ·{' '}
{item.resolutionNote || '-'}
</Typography.Text>
) : null}
{item.resolutionAction === 'upheld' ? (
<Typography.Text type={item.debtAmount > 0 ? 'danger' : undefined}>
{formatMoney(item.recoveryAmount)} {formatMoney(item.debtAmount)}
</Typography.Text>
) : null}
</div>
),
},
{
title: '操作',
width: 120,
render: (_, item) =>
['awaiting_worker_response', 'under_review'].includes(item.status) ? (
<Button
type="primary"
size="small"
onClick={() => openAfterSalesResolutionModal(item)}
>
</Button>
) : (
<Typography.Text type="secondary"></Typography.Text>
),
},
]}
/>
</Card>
<AfterSalesPanel ref={afterSalesPanelRef} />
<Drawer
className="worker-order-detail-drawer"
title={
@@ -1714,132 +1498,6 @@ export default function WorkOrdersPanel() {
</Form>
</Modal>
<Modal
title={`发起验收后售后 · ${getDisplayOrderNo(afterSalesOrder)}`}
open={Boolean(afterSalesOrder)}
onCancel={() => {
setAfterSalesOrder(null)
setAfterSalesFiles([])
afterSalesCreateForm.resetFields()
}}
onOk={() => afterSalesCreateForm.submit()}
destroyOnHidden
>
{afterSalesOrder ? (
<Form form={afterSalesCreateForm} layout="vertical" onFinish={submitAfterSalesCreate}>
<Alert
showIcon
type="warning"
message="售后不会回退已验收工单;原验收资料会在创建时固化。"
style={{ marginBottom: 16 }}
/>
{(afterSalesSharingQuery.data?.data.shares || []).some(
(share) => share.status === 'accepted',
) ? (
<Form.Item
label="责任拼单份额"
name="shareId"
rules={[{ required: true, message: '请选择责任打手的已验收份额' }]}
>
<Select
loading={afterSalesSharingQuery.isLoading}
placeholder="请选择责任拼单份额"
options={(afterSalesSharingQuery.data?.data.shares || [])
.filter((share) => share.status === 'accepted')
.map((share) => ({
value: share.shareId,
label: `${share.worker?.displayName || share.worker?.username || `打手#${share.workerId}`} · ${share.quantity}`,
}))}
/>
</Form.Item>
) : null}
<Form.Item label="问题类型" name="problemType" rules={[{ required: true }]}>
<Select
options={[
{ value: 'fake_evidence', label: '疑似 P 图 / 伪造验收' },
{ value: 'result_not_met', label: '实际结果未达标' },
{ value: 'other', label: '其他售后问题' },
]}
/>
</Form.Item>
<Form.Item
label="用户投诉说明"
name="complaintNote"
rules={[{ required: true, message: '请填写用户投诉说明' }]}
>
<Input.TextArea rows={4} placeholder="记录用户反馈、核查结论及需打手说明的事项" />
</Form.Item>
<Form.Item label="用户证据图片">
<ImageUpload
scene="collect-material"
scope="admin"
value={afterSalesFiles}
onChange={setAfterSalesFiles}
maxCount={10}
showPasteHint
/>
</Form.Item>
</Form>
) : null}
</Modal>
<Modal
title={`处置售后问题单 · ${afterSalesCase?.caseNo || ''}`}
open={Boolean(afterSalesCase)}
onCancel={() => {
setAfterSalesCase(null)
afterSalesResolutionForm.resetFields()
}}
onOk={() => afterSalesResolutionForm.submit()}
destroyOnHidden
>
{afterSalesCase ? (
<Form
form={afterSalesResolutionForm}
layout="vertical"
onFinish={submitAfterSalesResolution}
>
<Form.Item
label="处置结论"
name="action"
rules={[{ required: true, message: '请选择处置结论' }]}
>
<Select
options={[
{ value: 'upheld', label: '问题成立,执行追缴' },
{ value: 'remedied', label: '已补救完成,不追缴' },
{ value: 'not_upheld', label: '问题不成立,关闭售后' },
]}
/>
</Form.Item>
<Form.Item
noStyle
shouldUpdate={(previous, current) => previous.action !== current.action}
>
{({ getFieldValue }) =>
getFieldValue('action') === 'upheld' ? (
<Form.Item
label="追缴金额"
name="recoveryAmount"
rules={[{ required: true, message: '请输入追缴金额' }]}
extra="系统依次扣减该责任打手的本单待解冻押金、可用余额;不足部分转为欠款,并从后续验收报酬自动抵扣。"
>
<InputNumber min={0} precision={2} addonAfter="元" className="full-width" />
</Form.Item>
) : null
}
</Form.Item>
<Form.Item
label="处置备注"
name="resolutionNote"
rules={[{ required: true, message: '请填写处置备注' }]}
>
<Input.TextArea rows={4} />
</Form.Item>
</Form>
) : null}
</Modal>
<Modal
title="处置问题单"
open={Boolean(resolutionOrder)}
@@ -2648,58 +2306,6 @@ function formatRequirementFieldsText(fields: CollectField[]): string {
.join('\n')
}
function formatAfterSalesProblemType(value: string): string {
if (value === 'fake_evidence') return '疑似 P 图 / 伪造验收'
if (value === 'result_not_met') return '实际结果未达标'
return '其他售后问题'
}
function formatAfterSalesStatus(value: string): string {
if (value === 'awaiting_worker_response') return '待打手说明'
if (value === 'under_review') return '客服处理中'
if (value === 'recovery_pending') return '追缴中'
return '已关闭'
}
function resolveAfterSalesStatusColor(value: string): string {
if (value === 'awaiting_worker_response') return 'orange'
if (value === 'under_review') return 'blue'
if (value === 'recovery_pending') return 'red'
return 'green'
}
function formatAfterSalesResolution(value: string): string {
if (value === 'upheld') return '问题成立'
if (value === 'remedied') return '补救完成'
if (value === 'not_upheld') return '问题不成立'
return value || '-'
}
function getAfterSalesSnapshotFiles(item: WorkerAfterSalesCase): UploadedFile[] {
const snapshot = asRecord(item.acceptanceSnapshot)
const acceptance = asRecord(snapshot.acceptance)
const files = Array.isArray(acceptance.files) ? acceptance.files : []
const imageUrls = Array.isArray(acceptance.imageUrls) ? acceptance.imageUrls : []
return [
...files.map((value) => asRecord(value)),
...imageUrls.map((url) => asRecord({ url: String(url || '') })),
]
.map((file) => {
const url = String(file.url || '').trim()
if (!url) return null
return {
objectKey: String(file.objectKey || file.object_key || ''),
url,
thumbnailUrl: String(file.thumbnailUrl || file.thumbnail_url || url),
mediumUrl: String(file.mediumUrl || file.medium_url || url),
filename: String(file.filename || '验收图片'),
contentType: String(file.contentType || ''),
size: Number(file.size || 0),
} satisfies UploadedFile
})
.filter((file): file is UploadedFile => Boolean(file))
}
function MockWorkOrderForm({ onCreated }: { onCreated: () => Promise<void> }) {
const { message } = App.useApp()
const [loading, setLoading] = useState(false)
@@ -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
})
}