优化售后问题单后台位置

This commit is contained in:
yml2213
2026-08-21 20:03:37 +08:00
parent a5c07ebafc
commit 700bda8250
7 changed files with 338 additions and 192 deletions
+2
View File
@@ -41,6 +41,7 @@ const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage'))
const WorkerLoginPage = lazy(() => import('@/pages/worker/WorkerLoginPage'))
const WorkerOrdersPage = lazy(() => import('@/pages/worker/WorkerOrdersPage'))
const WorkerAfterSalesPage = lazy(() => import('@/pages/worker/WorkerAfterSalesPage'))
const WorkerProfilePage = lazy(() => import('@/pages/worker/WorkerProfilePage'))
function RequireAdmin() {
@@ -139,6 +140,7 @@ function WorkerApplication() {
<Route element={<WorkerLayout />}>
<Route path="hall" element={<WorkerHallPage />} />
<Route path="orders" element={<WorkerOrdersPage />} />
<Route path="orders/after-sales" element={<WorkerAfterSalesPage />} />
<Route path="profile" element={<WorkerProfilePage />} />
</Route>
</Route>
+6 -1
View File
@@ -1,6 +1,7 @@
import {
ContainerOutlined,
LogoutOutlined,
SafetyCertificateOutlined,
ShoppingOutlined,
UserOutlined,
} from '@ant-design/icons'
@@ -19,6 +20,7 @@ import WorkerAnnouncementBanner from '@/components/worker/WorkerAnnouncementBann
const menuItems: MenuProps['items'] = [
{ key: '/hall', icon: <ShoppingOutlined />, label: '抢单大厅' },
{ key: '/orders', icon: <ContainerOutlined />, label: '我的订单' },
{ key: '/orders/after-sales', icon: <SafetyCertificateOutlined />, label: '售后问题单' },
{ key: '/profile', icon: <UserOutlined />, label: '个人中心' },
]
@@ -97,7 +99,9 @@ export default function WorkerLayout() {
<nav className="worker-mobile-tabbar" aria-label="移动端底部导航">
{tabItems.map((item) => {
const Icon = item.icon
const isActive = currentKey === item.key
const isActive =
currentKey === item.key ||
(currentKey === '/orders/after-sales' && item.key === '/orders')
return (
<button
key={item.key}
@@ -354,6 +358,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
}
function resolveSelectedKey(pathname: string) {
if (pathname.startsWith('/orders/after-sales')) return '/orders/after-sales'
if (pathname.startsWith('/orders')) return '/orders'
if (pathname.startsWith('/profile')) return '/profile'
return '/hall'
@@ -1,27 +1,35 @@
import { useQuery } from '@tanstack/react-query'
import { Card, Space, Tabs } from 'antd'
import { useEffect, useState } from 'react'
import { useSearchParams } from 'react-router'
import { useEffect, useRef, useState } from 'react'
import { useLocation, useSearchParams } from 'react-router'
import PageHeader from '@/components/admin/PageHeader'
import type { WorkOrder } from '@/types/worker-platform'
import { fetchAdminWorkerPlatformSummary } from '@/services/admin'
import { getAdminRole } from '@/utils/admin-auth'
import CategoriesPanel from './panels/CategoriesPanel'
import AfterSalesPanel, { type AfterSalesPanelHandle } from './panels/AfterSalesPanel'
import AnnouncementPanel from './panels/AnnouncementPanel'
import CancelRequestsPanel from './panels/CancelRequestsPanel'
import FinancePanel from './panels/FinancePanel'
import CategoriesPanel from './panels/CategoriesPanel'
import FeedbacksPanel from './panels/FeedbacksPanel'
import FinancePanel from './panels/FinancePanel'
import HallConfigPanel from './panels/HallConfigPanel'
import LevelsPanel from './panels/LevelsPanel'
import NotificationsPanel from './panels/NotificationsPanel'
import ProductRulesPanel from './panels/ProductRulesPanel'
import ProductMatchPanel from './panels/ProductMatchPanel'
import ProductRulesPanel from './panels/ProductRulesPanel'
import WorkersPanel from './panels/WorkersPanel'
import WorkOrdersPanel from './panels/WorkOrdersPanel'
type AfterSalesLocationState = {
createOrder?: WorkOrder
}
export default function AdminWorkerPlatformPage() {
const [searchParams] = useSearchParams()
const location = useLocation()
const [activeTab, setActiveTab] = useState(() => loadActiveTab(searchParams.get('tab')))
const afterSalesPanelRef = useRef<AfterSalesPanelHandle>(null)
const role = getAdminRole()
const isAdmin = role === 'admin'
const isSupport = role === 'support'
@@ -34,14 +42,33 @@ export default function AdminWorkerPlatformPage() {
}
}, [searchParams])
useEffect(() => {
const state = location.state as AfterSalesLocationState | null
if (state?.createOrder) {
setActiveTab('after-sales')
afterSalesPanelRef.current?.openCreateModal(state.createOrder)
// 清掉 state,避免刷新或后退时重复弹窗。
window.history.replaceState({}, '')
}
}, [location.state])
function changeTab(nextTab: string) {
setActiveTab(nextTab)
localStorage.setItem(ACTIVE_TAB_STORAGE_KEY, nextTab)
}
const afterSalesTab = {
key: 'after-sales',
label: '售后问题单',
children: <AfterSalesPanel ref={afterSalesPanelRef} />,
}
return (
<section className="page-stack">
<PageHeader title="接单平台" description="管理打手、接单工单、等级权限和本地 Mock 工单。" />
<PageHeader
title="接单平台"
description="管理打手、接单工单、等级权限、售后追责和本地 Mock 工单。"
/>
<SummaryCards />
<Tabs
destroyOnHidden={false}
@@ -53,11 +80,13 @@ export default function AdminWorkerPlatformPage() {
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
{ key: 'cancel-requests', label: '撤单审核', children: <CancelRequestsPanel /> },
{ key: 'feedbacks', label: '问题反馈', children: <FeedbacksPanel /> },
afterSalesTab,
]
: [
{ key: 'orders', label: '接单工单', children: <WorkOrdersPanel /> },
{ key: 'cancel-requests', label: '撤单审核', children: <CancelRequestsPanel /> },
{ key: 'feedbacks', label: '问题反馈', children: <FeedbacksPanel /> },
afterSalesTab,
...(isAdmin
? [{ key: 'announcement', label: '公告管理', children: <AnnouncementPanel /> }]
: []),
@@ -85,6 +114,7 @@ function loadActiveTabPreference(): string {
return saved === 'orders' ||
saved === 'cancel-requests' ||
saved === 'feedbacks' ||
saved === 'after-sales' ||
saved === 'announcement' ||
saved === 'rules' ||
saved === 'product-match' ||
@@ -107,6 +137,7 @@ function isWorkerPlatformTab(tab: string | null): tab is string {
'orders',
'cancel-requests',
'feedbacks',
'after-sales',
'announcement',
'rules',
'product-match',
@@ -42,7 +42,6 @@ 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,
@@ -135,7 +134,6 @@ export default function WorkOrdersPanel() {
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[]>([])
@@ -989,7 +987,11 @@ export default function WorkOrdersPanel() {
<Button
icon={<WarningOutlined />}
danger
onClick={() => afterSalesPanelRef.current?.openCreateModal(row)}
onClick={() =>
navigate('/admin/worker-platform?tab=after-sales', {
state: { createOrder: row },
})
}
>
</Button>
@@ -1165,7 +1167,6 @@ export default function WorkOrdersPanel() {
scroll={{ x: 1240 }}
/>
</Card>
<AfterSalesPanel ref={afterSalesPanelRef} />
<Drawer
className="worker-order-detail-drawer"
title={
@@ -0,0 +1,244 @@
import { ReloadOutlined } from '@ant-design/icons'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import {
App,
Alert,
Button,
Card,
Empty,
Form,
Input,
Modal,
Segmented,
Space,
Tag,
Typography,
} from 'antd'
import { useMemo, 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'
import { useIsMobile } from '@/utils/use-is-mobile'
type AfterSalesResponseFormValues = {
response?: string
}
type StatusFilter =
| 'all'
| 'awaiting_worker_response'
| 'under_review'
| 'recovery_pending'
| 'closed'
const STATUS_FILTERS: Array<{ value: StatusFilter; label: string }> = [
{ value: 'all', label: '全部' },
{ value: 'awaiting_worker_response', label: '待说明' },
{ value: 'under_review', label: '处理中' },
{ value: 'recovery_pending', label: '追缴中' },
{ value: 'closed', label: '已关闭' },
]
export function formatMoney(value: number | undefined) {
return `${((Number(value || 0) || 0) / 100).toFixed(2)}`
}
export function formatWorkerAfterSalesStatus(status: string) {
if (status === 'awaiting_worker_response') return '待说明'
if (status === 'under_review') return '处理中'
if (status === 'recovery_pending') return '追缴中'
return '已关闭'
}
export 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 matchesFilter(item: WorkerAfterSalesCase, filter: StatusFilter) {
if (filter === 'all') return true
if (filter === 'closed') {
return !['awaiting_worker_response', 'under_review', 'recovery_pending'].includes(item.status)
}
return item.status === filter
}
export default function WorkerAfterSalesPage() {
const isMobile = useIsMobile()
const [statusFilter, setStatusFilter] = useState<StatusFilter>('all')
const afterSalesCasesQuery = useQuery({
queryKey: ['worker-after-sales-cases'],
queryFn: fetchWorkerAfterSalesCases,
retry: false,
})
const allCases = afterSalesCasesQuery.data?.data.items || []
const afterSalesCases = useMemo(
() => allCases.filter((item) => matchesFilter(item, statusFilter)),
[allCases, statusFilter],
)
const pendingCount = allCases.filter((item) => item.status === 'awaiting_worker_response').length
return (
<section
className={
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}
</Space>
}
extra={
<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}
/>
{afterSalesCasesQuery.isLoading ? (
<Typography.Text type="secondary"></Typography.Text>
) : afterSalesCases.length === 0 ? (
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="暂无售后问题单,请保持验收资料真实完整"
/>
) : (
afterSalesCases.map((item) => (
<WorkerAfterSalesItem key={item.caseId} item={item} isMobile={isMobile} />
))
)}
</Space>
</Card>
</section>
)
}
function WorkerAfterSalesItem({
item,
isMobile,
}: {
item: WorkerAfterSalesCase
isMobile: boolean
}) {
const { message } = App.useApp()
const queryClient = useQueryClient()
const [responseOpen, setResponseOpen] = useState(false)
const [files, setFiles] = useState<UploadedFile[]>([])
const [form] = Form.useForm<AfterSalesResponseFormValues>()
async function submit(values: AfterSalesResponseFormValues) {
try {
await submitWorkerAfterSalesCaseResponse(item.caseId, {
response: String(values.response || '').trim(),
files,
})
message.success('售后说明已提交,等待客服处理')
await queryClient.invalidateQueries({ queryKey: ['worker-after-sales-cases'] })
setResponseOpen(false)
setFiles([])
form.resetFields()
} catch (error) {
message.error(error instanceof Error ? error.message : '提交失败')
}
}
return (
<>
<div 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" copyable={{ text: item.caseNo }}>
{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}
<div style={{ marginTop: 8 }}>
{item.status === 'awaiting_worker_response' ? (
<Button
type="primary"
size="small"
onClick={() => {
setFiles([])
form.resetFields()
setResponseOpen(true)
}}
>
</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>
</div>
<Modal
title={`售后说明 · ${item.caseNo}`}
open={responseOpen}
width={isMobile ? '92%' : 560}
onCancel={() => setResponseOpen(false)}
onOk={() => form.submit()}
okText="提交说明"
destroyOnHidden
>
<Alert
type="warning"
showIcon
message="请针对售后问题说明情况或提交补救材料,原验收资料不可在此修改。"
style={{ marginBottom: 16 }}
/>
<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
scene="worker-acceptance"
scope="worker"
value={files}
onChange={setFiles}
maxCount={10}
showPasteHint
/>
</Form.Item>
</Form>
</Modal>
</>
)
}
@@ -1,178 +0,0 @@
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>
</>
)
}
@@ -1,8 +1,10 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { App, Form } from 'antd'
import { useNavigate } from 'react-router'
import { App, Badge, Button, Form, Space, Typography } from 'antd'
import { useState } from 'react'
import {
fetchWorkerAfterSalesCases,
fetchWorkerCancelRequests,
fetchWorkerOrderFeedbacks,
fetchWorkerMyOrderOverview,
@@ -20,7 +22,6 @@ import {
import type { UploadedFile, WorkOrder } from '@/types/worker-platform'
import { useIsMobile } from '@/utils/use-is-mobile'
import { buildWorkerOrderQuery } from '@/utils/worker-order-query'
import WorkerAfterSalesSection from './WorkerAfterSalesSection'
import { WorkerOrderDesktopList } from './WorkerOrderDesktopList'
import { WorkerOrderDetailDrawer } from './WorkerOrderDetailDrawer'
import { WorkerOrderMobileList } from './WorkerOrderMobileList'
@@ -144,6 +145,15 @@ export default function WorkerOrdersPage() {
const isMobile = useIsMobile()
const { message } = App.useApp()
const queryClient = useQueryClient()
const navigate = useNavigate()
const afterSalesQuery = useQuery({
queryKey: ['worker-after-sales-cases'],
queryFn: fetchWorkerAfterSalesCases,
retry: false,
})
const afterSalesPendingCount = (afterSalesQuery.data?.data.items || []).filter(
(item) => item.status === 'awaiting_worker_response',
).length
const [status, setStatus] = useState('in_progress')
const [keywordInput, setKeywordInput] = useState('')
const [keyword, setKeyword] = useState('')
@@ -441,7 +451,38 @@ export default function WorkerOrdersPage() {
return (
<>
<WorkerAfterSalesSection isMobile={isMobile} />
<Button
type="text"
block={false}
className="worker-after-sales-entry"
onClick={() => navigate('/orders/after-sales')}
style={{ padding: 0 }}
>
<Space
style={{
width: '100%',
justifyContent: 'space-between',
alignItems: 'center',
background: '#fff',
border: '1px solid #f0f0f0',
borderRadius: 8,
padding: '10px 14px',
marginBottom: 12,
}}
>
<Space size={8}>
<Typography.Text strong></Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12 }}>
</Typography.Text>
</Space>
{afterSalesPendingCount > 0 ? (
<Badge count={afterSalesPendingCount} color="orange" showZero={false} />
) : (
<Typography.Text type="secondary"> &gt;</Typography.Text>
)}
</Space>
</Button>
{isMobile ? (
<WorkerOrderMobileList
statusOptions={STATUS_OPTIONS}