优化打手端售后问题单页面展示问题类型处理结果与提交说明

This commit is contained in:
yml2213
2026-08-22 11:25:18 +08:00
parent 6aec4a7b40
commit 67bb5fec15
2 changed files with 377 additions and 81 deletions
@@ -4,7 +4,6 @@ import {
App,
Alert,
Button,
Card,
Empty,
Form,
Input,
@@ -20,6 +19,7 @@ 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'
import { formatDateTime } from '@/utils/date-time'
import { useIsMobile } from '@/utils/use-is-mobile'
type AfterSalesResponseFormValues = {
@@ -41,6 +41,13 @@ const STATUS_FILTERS: Array<{ value: StatusFilter; label: string }> = [
{ value: 'closed', label: '已关闭' },
]
/** 待办优先:待说明 > 处理中 > 追缴中 > 已关闭,同状态按发起时间倒序。 */
const STATUS_WEIGHT: Record<string, number> = {
awaiting_worker_response: 0,
under_review: 1,
recovery_pending: 2,
}
export function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
@@ -59,11 +66,40 @@ export function resolveWorkerAfterSalesStatusColor(status: string) {
return 'green'
}
/** 与后台售后面板保持相同的问题类型口径。 */
export function formatWorkerAfterSalesProblemType(value: string) {
if (value === 'fake_evidence') return '疑似P图 / 伪造验收'
if (value === 'result_not_met') return '实际结果未达标'
return '其他售后问题'
}
function resolveWorkerAfterSalesProblemTypeColor(value: string) {
if (value === 'fake_evidence') return 'red'
if (value === 'result_not_met') return 'volcano'
return 'default'
}
export function formatWorkerAfterSalesResolution(value: string) {
if (value === 'upheld') return '问题成立'
if (value === 'remedied') return '补救完成'
if (value === 'not_upheld') return '问题不成立'
return ''
}
function resolveWorkerAfterSalesResolutionColor(value: string) {
if (value === 'upheld') return 'red'
if (value === 'remedied') return 'gold'
if (value === 'not_upheld') return 'green'
return 'default'
}
function isAfterSalesClosed(status: string) {
return !['awaiting_worker_response', 'under_review', 'recovery_pending'].includes(status)
}
function matchesFilter(item: WorkerAfterSalesCase, filter: StatusFilter) {
if (filter === 'all') return true
if (filter === 'closed') {
return !['awaiting_worker_response', 'under_review', 'recovery_pending'].includes(item.status)
}
if (filter === 'closed') return isAfterSalesClosed(item.status)
return item.status === filter
}
@@ -77,11 +113,26 @@ export default function WorkerAfterSalesPage() {
retry: false,
})
const allCases = afterSalesCasesQuery.data?.data.items || []
const pendingCount = allCases.filter((item) => item.status === 'awaiting_worker_response').length
const countByFilter = useMemo(() => {
const counts: Record<string, number> = { all: allCases.length }
for (const filter of STATUS_FILTERS) {
if (filter.value === 'all') continue
counts[filter.value] = allCases.filter((item) => matchesFilter(item, filter.value)).length
}
return counts
}, [allCases])
const afterSalesCases = useMemo(
() => allCases.filter((item) => matchesFilter(item, statusFilter)),
() =>
allCases
.filter((item) => matchesFilter(item, statusFilter))
.sort(
(a, b) =>
(STATUS_WEIGHT[a.status] ?? 3) - (STATUS_WEIGHT[b.status] ?? 3) ||
String(b.createdAt).localeCompare(String(a.createdAt)),
),
[allCases, statusFilter],
)
const pendingCount = allCases.filter((item) => item.status === 'awaiting_worker_response').length
return (
<section
@@ -89,46 +140,63 @@ export default function WorkerAfterSalesPage() {
isMobile ? 'worker-after-sales-page worker-after-sales-mobile' : 'worker-after-sales-page'
}
>
<Card
className="worker-after-sales-card"
title={
<Space size={8}>
{pendingCount > 0 ? <Tag color="orange">{pendingCount} </Tag> : null}
{pendingCount > 0 ? (
<Alert
className="worker-after-sales-reminder"
type="warning"
showIcon
message={`你有 ${pendingCount} 条售后单待提交说明,请尽快处理,逾期可能影响结算与等级`}
/>
) : null}
<div className="worker-after-sales-toolbar">
<Segmented
options={STATUS_FILTERS.map((filter) => ({
value: filter.value,
label: (
<Space size={4}>
{filter.label}
{countByFilter[filter.value] > 0 ? (
<span className="worker-after-sales-filter-count">
{countByFilter[filter.value]}
</span>
) : null}
</Space>
}
extra={
),
}))}
value={statusFilter}
onChange={(value) => setStatusFilter(value as StatusFilter)}
block={isMobile}
/>
<Button
size="small"
icon={<ReloadOutlined />}
loading={afterSalesCasesQuery.isFetching}
onClick={() => afterSalesCasesQuery.refetch()}
>
</Button>
}
>
<Space direction="vertical" size={12} style={{ width: '100%' }}>
<Segmented
options={STATUS_FILTERS}
value={statusFilter}
onChange={(value) => setStatusFilter(value as StatusFilter)}
block={isMobile}
/>
</div>
{afterSalesCasesQuery.isLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : afterSalesCasesQuery.isError ? (
<Alert
type="error"
showIcon
message="售后问题单加载失败"
description="请刷新后重试;若问题持续存在,请联系客服。"
/>
) : afterSalesCases.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无售后问题单,请保持验收资料真实完整"
/>
) : (
afterSalesCases.map((item) => (
<div className="worker-after-sales-list">
{afterSalesCases.map((item) => (
<WorkerAfterSalesItem key={item.caseId} item={item} isMobile={isMobile} />
))
))}
</div>
)}
</Space>
</Card>
</section>
)
}
@@ -147,9 +215,14 @@ function WorkerAfterSalesItem({
const [form] = Form.useForm<AfterSalesResponseFormValues>()
async function submit(values: AfterSalesResponseFormValues) {
const response = String(values.response || '').trim()
if (!response && files.length === 0) {
message.error('请填写说明或至少上传一张补救材料图片')
return
}
try {
await submitWorkerAfterSalesCaseResponse(item.caseId, {
response: String(values.response || '').trim(),
response,
files,
})
message.success('售后说明已提交,等待客服处理')
@@ -162,31 +235,135 @@ function WorkerAfterSalesItem({
}
}
const resolutionLabel = formatWorkerAfterSalesResolution(item.resolutionAction)
const hasRecoveryDetail =
Number(item.recoveryAmount || 0) > 0 ||
Number(item.pendingDepositDeductionAmount || 0) > 0 ||
Number(item.availableDeductionAmount || 0) > 0 ||
Number(item.debtAmount || 0) > 0
return (
<>
<div className="worker-order-detail-value">
<Space wrap size={[8, 6]}>
<article
className={`worker-after-sales-item${item.status === 'awaiting_worker_response' ? ' is-awaiting' : ''}`}
>
<div className="worker-after-sales-item-head">
<Space size={6} wrap>
<Tag color={resolveWorkerAfterSalesStatusColor(item.status)}>
{formatWorkerAfterSalesStatus(item.status)}
</Tag>
<Typography.Text strong>
{item.productName || item.workOrderNo || item.caseNo}
</Typography.Text>
<Typography.Text type="secondary" copyable={{ text: item.caseNo }}>
{item.caseNo}
</Typography.Text>
<Tag color={resolveWorkerAfterSalesProblemTypeColor(item.problemType)}>
{formatWorkerAfterSalesProblemType(item.problemType)}
</Tag>
</Space>
<Typography.Paragraph style={{ margin: '8px 0 4px' }}>
<Typography.Text strong ellipsis={{ tooltip: item.productName || item.caseNo }}>
{item.productName || item.caseNo}
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTime(item.createdAt)}
</Typography.Text>
</div>
<Space size={12} wrap className="worker-after-sales-item-meta">
<Typography.Text type="secondary" style={{ fontSize: 12 }} copyable>
{item.platformOrderId || item.workOrderNo || item.caseNo}
</Typography.Text>
<Typography.Text
type="secondary"
style={{ fontSize: 12 }}
copyable={{ text: item.caseNo }}
>
{item.caseNo}
</Typography.Text>
{item.shareQuantity ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{item.shareQuantity}
</Typography.Text>
) : null}
</Space>
<div className="worker-after-sales-block">
<div className="worker-after-sales-block-label"></div>
<Typography.Paragraph style={{ marginBottom: item.complaintFiles.length ? 8 : 0 }}>
{item.complaintNote || '-'}
</Typography.Paragraph>
{item.complaintFiles.length > 0 ? (
<ImagePreviewList files={item.complaintFiles} size={52} maxVisible={4} />
<ImagePreviewList files={item.complaintFiles} size={48} maxVisible={5} />
) : null}
<div style={{ marginTop: 8 }}>
</div>
{item.workerResponse || item.workerResponseFiles.length > 0 ? (
<div className="worker-after-sales-block is-mine">
<div className="worker-after-sales-block-label">
{item.respondedAt ? ` · ${formatDateTime(item.respondedAt)}` : ''}
</div>
{item.workerResponse ? (
<Typography.Paragraph
style={{ marginBottom: item.workerResponseFiles.length ? 8 : 0 }}
>
{item.workerResponse}
</Typography.Paragraph>
) : null}
{item.workerResponseFiles.length > 0 ? (
<ImagePreviewList files={item.workerResponseFiles} size={48} maxVisible={5} />
) : null}
</div>
) : null}
{resolutionLabel || item.resolutionNote || hasRecoveryDetail ? (
<div className="worker-after-sales-block is-result">
<div className="worker-after-sales-block-label"></div>
<Space direction="vertical" size={6} style={{ width: '100%' }}>
{resolutionLabel ? (
<Space size={8} wrap>
<Tag color={resolveWorkerAfterSalesResolutionColor(item.resolutionAction)}>
{resolutionLabel}
</Tag>
{item.resolvedAt ? (
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
{formatDateTime(item.resolvedAt)}
</Typography.Text>
) : null}
</Space>
) : null}
{item.resolutionNote ? (
<Typography.Paragraph style={{ marginBottom: 0 }}>
{item.resolutionNote}
</Typography.Paragraph>
) : null}
{hasRecoveryDetail ? (
<Space size={12} wrap>
{Number(item.recoveryAmount || 0) > 0 ? (
<Typography.Text type="danger" strong>
{formatMoney(item.recoveryAmount)}
</Typography.Text>
) : null}
{Number(item.pendingDepositDeductionAmount || 0) > 0 ? (
<Typography.Text type="secondary">
{formatMoney(item.pendingDepositDeductionAmount)}
</Typography.Text>
) : null}
{Number(item.availableDeductionAmount || 0) > 0 ? (
<Typography.Text type="secondary">
{formatMoney(item.availableDeductionAmount)}
</Typography.Text>
) : null}
{Number(item.debtAmount || 0) > 0 ? (
<Typography.Text type="danger" strong>
{formatMoney(item.debtAmount)}
</Typography.Text>
) : null}
</Space>
) : null}
</Space>
</div>
) : item.status === 'under_review' ? (
<Typography.Text type="secondary"></Typography.Text>
) : null}
{item.status === 'awaiting_worker_response' ? (
<div className="worker-after-sales-item-actions">
<Button
type="primary"
size="small"
onClick={() => {
setFiles([])
form.resetFields()
@@ -195,20 +372,12 @@ function WorkerAfterSalesItem({
>
</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>
</div>
) : null}
</div>
</div>
</article>
<Modal
title={`售后说明 · ${item.caseNo}`}
title={`售后说明 · ${item.productName || item.caseNo}`}
open={responseOpen}
width={isMobile ? '92%' : 560}
onCancel={() => setResponseOpen(false)}
@@ -220,12 +389,39 @@ function WorkerAfterSalesItem({
type="warning"
showIcon
message="请针对售后问题说明情况或提交补救材料,原验收资料不可在此修改。"
style={{ marginBottom: 16 }}
style={{ marginBottom: 12 }}
/>
<div className="worker-after-sales-block">
<div className="worker-after-sales-block-label">
· {formatWorkerAfterSalesProblemType(item.problemType)}
</div>
<Typography.Paragraph style={{ marginBottom: item.complaintFiles.length ? 8 : 0 }}>
{item.complaintNote || '-'}
</Typography.Paragraph>
{item.complaintFiles.length > 0 ? (
<ImagePreviewList files={item.complaintFiles} size={44} maxVisible={4} />
) : null}
</div>
<Form form={form} layout="vertical" onFinish={submit} style={{ marginTop: 12 }}>
<Form.Item
label="说明"
name="response"
extra="说明与补救材料至少填写一项"
rules={[
{
validator: (_, value) =>
String(value || '').trim() || files.length > 0
? Promise.resolve()
: Promise.reject(new Error('请填写说明或至少上传一张图片')),
},
]}
>
<Input.TextArea
rows={4}
maxLength={2000}
showCount
placeholder="请说明当时的处理情况"
/>
<Typography.Paragraph type="secondary">{item.complaintNote}</Typography.Paragraph>
<Form form={form} layout="vertical" onFinish={submit}>
<Form.Item label="说明" name="response">
<Input.TextArea rows={4} maxLength={2000} showCount />
</Form.Item>
<Form.Item label="补救材料图片">
<ImageUpload
+100
View File
@@ -2199,3 +2199,103 @@ body {
.finance-summary-item strong {
color: #111827;
}
/* ===== 售后问题单(打手端) ===== */
.worker-after-sales-page {
display: flex;
flex-direction: column;
gap: 12px;
}
.worker-after-sales-reminder {
border-radius: 10px;
}
.worker-after-sales-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.worker-after-sales-filter-count {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 9px;
background: #e6f4ff;
color: #1677ff;
font-size: 11px;
line-height: 1;
}
.worker-after-sales-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.worker-after-sales-item {
background: #ffffff;
border: 1px solid #e2e8f0;
border-left: 3px solid #cbd5e1;
border-radius: 14px;
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.04);
}
.worker-after-sales-item.is-awaiting {
border-left-color: #fa8c16;
background: #fffdf8;
}
.worker-after-sales-item-head {
display: flex;
align-items: center;
gap: 8px 10px;
flex-wrap: wrap;
}
.worker-after-sales-item-meta {
margin-top: -4px;
}
.worker-after-sales-block {
background: #f8fafc;
border: 1px solid #eef2f7;
border-radius: 10px;
padding: 10px 12px;
}
.worker-after-sales-block.is-mine {
background: #f6ffed;
border-color: #e6f4dc;
}
.worker-after-sales-block.is-result {
background: #fff7e6;
border-color: #ffe7ba;
}
.worker-after-sales-block-label {
font-size: 12px;
color: #64748b;
margin-bottom: 6px;
}
.worker-after-sales-item.is-awaiting .worker-after-sales-item-actions {
display: flex;
justify-content: flex-end;
}
.worker-after-sales-mobile .worker-after-sales-toolbar .ant-segmented {
overflow-x: auto;
}