拆分打手平台售后与订单模块
This commit is contained in:
@@ -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 转成 PNG(Chrome 剪贴板写入只支持 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
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user