- 修复欠款抵扣 SQL 参数类型冲突(debt_amount = $1 与 $1 = 0 推断不一致), 此前手动偿还、验收报酬抵扣、充值/押金解冻/寄卖自动抵扣都会 500 - 追缴顺序改为 待解冻押金 → 提现冻结 → 可用余额 → 欠款: 驳回待审核提现并直接扣走冻结资金,扣不完的部分退回可用余额 - 迁移 109:售后单记录 withdraw_deduction_amount,补 pending 提现部分索引 - 新增流水类型 after_sales_withdraw_deduction,前后端展示与标签同步 - 提现驳回退回余额后补调欠款自动抵扣,与充值/结算/解冻/寄卖路径对齐 - 补追缴分配单测与 SQL 参数类型静态门禁
667 lines
25 KiB
TypeScript
667 lines
25 KiB
TypeScript
import { ReloadOutlined, VideoCameraOutlined } from '@ant-design/icons'
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import {
|
|
App,
|
|
Alert,
|
|
Button,
|
|
Card,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Modal,
|
|
Select,
|
|
Space,
|
|
Tag,
|
|
Typography,
|
|
} from 'antd'
|
|
import PercentTable from '@/components/common/PercentTable'
|
|
import { forwardRef, useImperativeHandle, useState } from 'react'
|
|
|
|
import ImageUpload from '@/components/files/ImageUpload'
|
|
import ImagePreviewList from '@/components/files/ImagePreviewList'
|
|
import {
|
|
createAdminAfterSalesCase,
|
|
fetchAdminAfterSalesCases,
|
|
fetchAdminWorkOrderSharing,
|
|
repayAdminAfterSalesCaseDebt,
|
|
resolveAdminAfterSalesCase,
|
|
} from '@/services/admin'
|
|
import type {
|
|
UploadedFile,
|
|
WorkerAcceptanceVideo,
|
|
WorkerAfterSalesCase,
|
|
WorkOrder,
|
|
} from '@/types/worker-platform'
|
|
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
|
import { normalizeSearchText } from '@/utils/admin-realtime-work-orders'
|
|
import { formatAdminDateTime } from '@/utils/admin-time'
|
|
import { uniqueUploadedFiles } from '@/utils/image-identity'
|
|
import { asRecord, formatMoney } from './shared'
|
|
|
|
type AfterSalesCreateFormValues = {
|
|
shareId?: number
|
|
problemType?: string
|
|
complaintNote?: string
|
|
}
|
|
|
|
type AfterSalesResolutionFormValues = {
|
|
action?: 'not_upheld' | 'remedied' | 'upheld'
|
|
resolutionNote?: string
|
|
recoveryAmount?: number
|
|
}
|
|
|
|
type AfterSalesRepayFormValues = {
|
|
amount?: number
|
|
note?: string
|
|
}
|
|
|
|
/** 售后单状态筛选(与列表标签同一套文案)。 */
|
|
const AFTER_SALES_STATUS_OPTIONS = [
|
|
{ value: 'awaiting_worker_response', label: '待打手说明' },
|
|
{ value: 'under_review', label: '客服处理中' },
|
|
{ value: 'recovery_pending', label: '追缴中' },
|
|
{ value: 'closed', label: '已关闭' },
|
|
]
|
|
|
|
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 [repayCase, setRepayCase] = useState<WorkerAfterSalesCase | null>(null)
|
|
const [afterSalesFiles, setAfterSalesFiles] = useState<UploadedFile[]>([])
|
|
const [afterSalesPage, setAfterSalesPage] = useState(1)
|
|
const [afterSalesPageSize, setAfterSalesPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
|
|
/** 搜索框输入值;点搜索/回车后才写入 afterSalesKeyword,避免每敲一个字就请求。 */
|
|
const [afterSalesKeywordInput, setAfterSalesKeywordInput] = useState('')
|
|
const [afterSalesKeyword, setAfterSalesKeyword] = useState('')
|
|
const [afterSalesStatus, setAfterSalesStatus] = useState('')
|
|
const [afterSalesCreateForm] = Form.useForm<AfterSalesCreateFormValues>()
|
|
const [afterSalesResolutionForm] = Form.useForm<AfterSalesResolutionFormValues>()
|
|
const [afterSalesRepayForm] = Form.useForm<AfterSalesRepayFormValues>()
|
|
|
|
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,
|
|
afterSalesKeyword,
|
|
afterSalesStatus,
|
|
],
|
|
queryFn: () =>
|
|
fetchAdminAfterSalesCases({
|
|
page: afterSalesPage,
|
|
pageSize: afterSalesPageSize,
|
|
...(afterSalesKeyword ? { keyword: afterSalesKeyword } : {}),
|
|
...(afterSalesStatus ? { status: afterSalesStatus } : {}),
|
|
}),
|
|
})
|
|
|
|
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,
|
|
}),
|
|
(result) =>
|
|
(result.data?.refundedTotalAmount || 0) > 0
|
|
? `售后问题单已处置,已退还原追缴 ${formatMoney(result.data.refundedTotalAmount)} 至打手可用余额`
|
|
: '售后问题单已处置',
|
|
)
|
|
if (!succeeded) return
|
|
setAfterSalesCase(null)
|
|
afterSalesResolutionForm.resetFields()
|
|
}
|
|
|
|
function openAfterSalesRepayModal(item: WorkerAfterSalesCase) {
|
|
setRepayCase(item)
|
|
afterSalesRepayForm.setFieldsValue({
|
|
amount: Number(item.debtAmount || 0) / 100,
|
|
note: '后台手动偿还售后欠款',
|
|
})
|
|
}
|
|
|
|
async function submitAfterSalesRepay(values: AfterSalesRepayFormValues) {
|
|
if (!repayCase) return
|
|
const succeeded = await runAction(
|
|
() =>
|
|
repayAdminAfterSalesCaseDebt(repayCase.caseId, {
|
|
amount: Number(values.amount || 0),
|
|
note: String(values.note || '').trim(),
|
|
}),
|
|
(result) =>
|
|
`已偿还 ${formatMoney(result.data?.repaidAmount || 0)},余额剩余 ${formatMoney(result.data?.availableAfter || 0)}`,
|
|
)
|
|
if (!succeeded) return
|
|
setRepayCase(null)
|
|
afterSalesRepayForm.resetFields()
|
|
await queryClient.invalidateQueries({ queryKey: ['admin-worker-platform-workers'] })
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Card
|
|
title="验收后售后问题单"
|
|
extra={
|
|
<Button
|
|
icon={<ReloadOutlined />}
|
|
loading={afterSalesCasesQuery.isFetching}
|
|
onClick={() => afterSalesCasesQuery.refetch()}
|
|
>
|
|
刷新
|
|
</Button>
|
|
}
|
|
bordered={false}
|
|
>
|
|
<div style={{ marginBottom: 16 }}>
|
|
<Space wrap>
|
|
<Input.Search
|
|
allowClear
|
|
value={afterSalesKeywordInput}
|
|
placeholder="售后单号 / 工单号 / 平台号 / 责任打手"
|
|
style={{ width: 340 }}
|
|
onChange={(event) => {
|
|
const nextValue = event.target.value
|
|
setAfterSalesKeywordInput(nextValue)
|
|
// 清空输入立即恢复全量,不用再点一次搜索。
|
|
if (!nextValue.trim()) {
|
|
setAfterSalesKeyword('')
|
|
setAfterSalesPage(1)
|
|
}
|
|
}}
|
|
onSearch={(value) => {
|
|
// 归一化(全角→半角、去零宽字符):粘贴来的单号常夹带不可见字符。
|
|
setAfterSalesKeyword(normalizeSearchText(value))
|
|
setAfterSalesPage(1)
|
|
}}
|
|
/>
|
|
<Select
|
|
allowClear
|
|
value={afterSalesStatus || undefined}
|
|
placeholder="全部状态"
|
|
style={{ width: 160 }}
|
|
options={AFTER_SALES_STATUS_OPTIONS}
|
|
onChange={(value) => {
|
|
setAfterSalesStatus(String(value || ''))
|
|
setAfterSalesPage(1)
|
|
}}
|
|
/>
|
|
</Space>
|
|
</div>
|
|
<PercentTable<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}
|
|
{getAfterSalesSnapshotVideo(item) ? (
|
|
<a
|
|
href={getAfterSalesSnapshotVideo(item)?.url}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
>
|
|
<VideoCameraOutlined /> 查看固化验收视频
|
|
</a>
|
|
) : 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)}
|
|
{Number(item.withdrawDeductionAmount || 0) > 0
|
|
? `(含提现冻结 ${formatMoney(item.withdrawDeductionAmount)},提现已驳回)`
|
|
: ''}
|
|
</Typography.Text>
|
|
) : null}
|
|
{item.refundedAmount > 0 ? (
|
|
<Typography.Text type="success">
|
|
已退还 {formatMoney(item.refundedAmount)}
|
|
</Typography.Text>
|
|
) : null}
|
|
</div>
|
|
),
|
|
},
|
|
{
|
|
title: '操作',
|
|
width: 160,
|
|
render: (_, item) =>
|
|
['awaiting_worker_response', 'under_review'].includes(item.status) ? (
|
|
<Button
|
|
type="primary"
|
|
size="small"
|
|
onClick={() => openAfterSalesResolutionModal(item)}
|
|
>
|
|
处置
|
|
</Button>
|
|
) : item.status === 'recovery_pending' && Number(item.debtAmount || 0) > 0 ? (
|
|
<Button
|
|
type="primary"
|
|
size="small"
|
|
danger
|
|
onClick={() => openAfterSalesRepayModal(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>
|
|
) : (
|
|
<Typography.Paragraph type="secondary" style={{ marginBottom: 16 }}>
|
|
选择补救完成或问题不成立时,系统自动将该工单同一责任打手此前已追缴且未退还的金额退还至其可用余额。
|
|
</Typography.Paragraph>
|
|
)
|
|
}
|
|
</Form.Item>
|
|
<Form.Item
|
|
label="处置备注"
|
|
name="resolutionNote"
|
|
rules={[{ required: true, message: '请填写处置备注' }]}
|
|
>
|
|
<Input.TextArea rows={4} />
|
|
</Form.Item>
|
|
</Form>
|
|
) : null}
|
|
</Modal>
|
|
|
|
<Modal
|
|
title={`手动偿还欠款 · ${repayCase?.caseNo || ''}`}
|
|
open={Boolean(repayCase)}
|
|
onCancel={() => {
|
|
setRepayCase(null)
|
|
afterSalesRepayForm.resetFields()
|
|
}}
|
|
onOk={() => afterSalesRepayForm.submit()}
|
|
okText="确认偿还"
|
|
destroyOnHidden
|
|
>
|
|
{repayCase ? (
|
|
<Form form={afterSalesRepayForm} layout="vertical" onFinish={submitAfterSalesRepay}>
|
|
<Alert
|
|
showIcon
|
|
type="info"
|
|
style={{ marginBottom: 16 }}
|
|
message={`将从打手「${repayCase.workerName || '-'}」可用余额扣减,当前待还 ${formatMoney(repayCase.debtAmount)}。`}
|
|
/>
|
|
<Form.Item
|
|
label="偿还金额"
|
|
name="amount"
|
|
rules={[{ required: true, message: '请输入偿还金额' }]}
|
|
extra="默认偿还全部待还;也可填写部分金额。"
|
|
>
|
|
<InputNumber
|
|
min={0.01}
|
|
max={Number(repayCase.debtAmount || 0) / 100}
|
|
precision={2}
|
|
addonAfter="元"
|
|
className="full-width"
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item
|
|
label="偿还备注"
|
|
name="note"
|
|
rules={[{ required: true, message: '请填写偿还备注' }]}
|
|
>
|
|
<Input.TextArea rows={3} />
|
|
</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 : []
|
|
// 验收资料里同一张图会同时存在于 files 与 imageUrls(历史冗余表示),
|
|
// 必须按图片身份去重,否则固化验收图会把同一张图重复渲染多次。
|
|
return uniqueUploadedFiles(
|
|
[
|
|
...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),
|
|
)
|
|
}
|
|
|
|
/** 售后固化快照里的验收视频(有则给出可点击链接)。 */
|
|
function getAfterSalesSnapshotVideo(item: WorkerAfterSalesCase): WorkerAcceptanceVideo | null {
|
|
const snapshot = asRecord(item.acceptanceSnapshot)
|
|
const acceptance = asRecord(snapshot.acceptance)
|
|
const video = asRecord(acceptance.video)
|
|
const url = String(video.url || '').trim()
|
|
if (!url) return null
|
|
return {
|
|
objectKey: String(video.objectKey || video.object_key || ''),
|
|
url,
|
|
filename: String(video.filename || ''),
|
|
contentType: String(video.contentType || video.content_type || ''),
|
|
size: Number(video.size || 0),
|
|
durationSeconds: Number(video.durationSeconds || video.duration_seconds || 0),
|
|
}
|
|
}
|
|
|
|
export default AfterSalesPanel
|