增加售后相关的

This commit is contained in:
yml2213
2026-08-21 14:30:00 +08:00
parent 874b093a4b
commit f4d32a7c07
18 changed files with 1903 additions and 3 deletions
@@ -51,10 +51,12 @@ import {
cancelAdminWorkOrder,
cancelAdminWorkOrderShare,
createAdminMockWorkOrder,
createAdminAfterSalesCase,
deductAdminWorkOrderPendingDeposit,
deleteAdminWorkOrder,
fetchAdminWorkCategories,
fetchAdminWorkOrderSharing,
fetchAdminAfterSalesCases,
fetchAdminWorkerLevels,
fetchAdminWorkerUsers,
fetchAdminWorkOrders,
@@ -63,6 +65,7 @@ import {
publishAdminWorkOrder,
reopenAdminCancelledWorkOrder,
resolveAdminProblemWorkOrder,
resolveAdminAfterSalesCase,
submitAdminWorkOrderMaterial,
unpublishAdminWorkOrder,
updateAdminWorkOrder,
@@ -75,6 +78,7 @@ 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'
@@ -129,6 +133,18 @@ 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()
@@ -160,12 +176,17 @@ 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
@@ -258,6 +279,15 @@ 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: () =>
@@ -414,6 +444,9 @@ export default function WorkOrdersPanel() {
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-summary'],
}),
queryClient.invalidateQueries({
queryKey: ['admin-worker-platform-after-sales-cases'],
}),
])
}
@@ -625,6 +658,60 @@ 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(
@@ -983,6 +1070,15 @@ export default function WorkOrdersPanel() {
</Button>
) : null}
{row.status === 'accepted' ? (
<Button
icon={<WarningOutlined />}
danger
onClick={() => openAfterSalesCreateModal(row)}
>
</Button>
) : null}
{row.capabilities?.canMarkProblem ? (
<Button icon={<WarningOutlined />} danger onClick={() => setProblemOrder(row)}>
@@ -1154,6 +1250,138 @@ 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>
<Drawer
className="worker-order-detail-drawer"
title={
@@ -1486,6 +1714,132 @@ 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)}
@@ -2294,6 +2648,58 @@ 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)