feat(order-service): 重构分类资料收集与订单状态链路
This commit is contained in:
+46
-36
@@ -12,7 +12,11 @@ import {
|
||||
|
||||
import { getAdminRole, hasAdminSession } from '@/utils/admin-auth'
|
||||
import { isClaimPath, isH5ClaimPath } from '@/utils/claim-path'
|
||||
import { isCollectMaterialLinkPath } from '@/utils/collect-link'
|
||||
import {
|
||||
isLegacySupplementPath,
|
||||
isOrderServicePath,
|
||||
resolveLegacySupplementOrderNo,
|
||||
} from '@/utils/order-service-link'
|
||||
import { isRedeemPath } from '@/utils/redeem'
|
||||
import { isWorkerSite, WORKER_SITE_ORIGIN } from '@/utils/site'
|
||||
import { hasWorkerSession } from '@/utils/worker-auth'
|
||||
@@ -43,11 +47,7 @@ const AdminDevMockPage = lazy(() => import('@/pages/admin/AdminDevMockPage'))
|
||||
const AdminWorkerPlatformPage = lazy(() => import('@/pages/admin/AdminWorkerPlatformPage'))
|
||||
const ClaimPage = lazy(() => import('@/pages/claim/ClaimPage'))
|
||||
const H5ClaimEntryPage = lazy(() => import('@/pages/claim/H5ClaimEntryPage'))
|
||||
const CollectPage = lazy(() => import('@/pages/collect/CollectPage'))
|
||||
const CollectMaterialLinkEntryPage = lazy(
|
||||
() => import('@/pages/collect/CollectMaterialLinkEntryPage'),
|
||||
)
|
||||
const CollectMaterialLinkPage = lazy(() => import('@/pages/collect/CollectMaterialLinkPage'))
|
||||
const OrderServicePage = lazy(() => import('@/pages/public/OrderServicePage'))
|
||||
const NotFoundPage = lazy(() => import('@/pages/NotFoundPage'))
|
||||
const RedeemPage = lazy(() => import('@/pages/redeem/RedeemPage'))
|
||||
const WorkerHallPage = lazy(() => import('@/pages/worker/WorkerHallPage'))
|
||||
@@ -125,10 +125,15 @@ export default function App() {
|
||||
return <RedeemApplication />
|
||||
}
|
||||
|
||||
// 补资料页(`/supplement`):路径式公开页,先于站点判断,
|
||||
// 保证在后台域名和打手端域名下都能打开。带订单号直接进补资料页,不带订单号进输入页。
|
||||
if (isCollectMaterialLinkPath(window.location.pathname)) {
|
||||
return <CollectMaterialLinkApplication />
|
||||
// 订单服务页(`/o`):路径式公开页,先于站点判断,
|
||||
// 保证后台域名和打手端域名下都能打开。旧 /supplement 也统一跳到这里。
|
||||
if (isOrderServicePath(window.location.pathname)) {
|
||||
return <OrderServiceApplication />
|
||||
}
|
||||
|
||||
// 旧 /supplement 短期兼容:统一跳到 /o。
|
||||
if (isLegacySupplementPath(window.location.pathname)) {
|
||||
return <CollectRedirectApplication />
|
||||
}
|
||||
|
||||
if (isWorkerSite()) {
|
||||
@@ -138,6 +143,29 @@ export default function App() {
|
||||
return <AdminApplication />
|
||||
}
|
||||
|
||||
function OrderServiceApplication() {
|
||||
useClearStrayHash()
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/o" element={<OrderServicePage />} />
|
||||
<Route path="/o/:orderNo" element={<OrderServicePage />} />
|
||||
<Route path="*" element={<OrderServicePage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
function CollectRedirectApplication() {
|
||||
useEffect(() => {
|
||||
const orderNo = resolveLegacySupplementOrderNo(window.location.pathname)
|
||||
window.location.replace(orderNo ? `/o/${encodeURIComponent(orderNo)}` : '/o')
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
function AdminApplication() {
|
||||
return (
|
||||
<HashRouter>
|
||||
@@ -193,7 +221,7 @@ function AdminApplication() {
|
||||
</Route>
|
||||
</Route>
|
||||
<Route path="/claim/:token" element={<ClaimPage />} />
|
||||
<Route path="/collect" element={<CollectPage />} />
|
||||
<Route path="/collect" element={<CollectHashRedirectApplication />} />
|
||||
<Route path="*" element={<NotFoundPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
@@ -201,6 +229,13 @@ function AdminApplication() {
|
||||
)
|
||||
}
|
||||
|
||||
function CollectHashRedirectApplication() {
|
||||
useEffect(() => {
|
||||
window.location.replace('/o')
|
||||
}, [])
|
||||
return null
|
||||
}
|
||||
|
||||
function RedeemApplication() {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
@@ -239,31 +274,6 @@ function ClaimApplication() {
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 补资料页(`/supplement`)的独立路由树。
|
||||
*
|
||||
* - `/supplement`:输入平台订单号;
|
||||
* - `/supplement/<订单号>`:直接打开对应订单的补资料页;
|
||||
* - 其余子路径:交给补资料页展示「订单号无效」。
|
||||
*
|
||||
* 路径式公开页,不带 hash;不依赖后台会话。
|
||||
*/
|
||||
function CollectMaterialLinkApplication() {
|
||||
useClearStrayHash()
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<RouteLoading />}>
|
||||
<Routes>
|
||||
<Route path="/supplement" element={<CollectMaterialLinkEntryPage />} />
|
||||
<Route path="/supplement/:code" element={<CollectMaterialLinkPage />} />
|
||||
<Route path="*" element={<CollectMaterialLinkPage />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* H5 领取入口(`/h5` 命名空间)的独立路由树。
|
||||
*
|
||||
|
||||
@@ -23,6 +23,7 @@ import FinancePanel from './panels/FinancePanel'
|
||||
import LeaderboardPanel from './panels/LeaderboardPanel'
|
||||
import LevelsPanel from './panels/LevelsPanel'
|
||||
import NotificationsPanel from './panels/NotificationsPanel'
|
||||
import OrderServicePanel from './panels/OrderServicePanel'
|
||||
import ProductMatchPanel from './panels/ProductMatchPanel'
|
||||
import ProductRulesPanel from './panels/ProductRulesPanel'
|
||||
import RiskControlPanel from './panels/RiskControlPanel'
|
||||
@@ -169,6 +170,7 @@ export default function AdminWorkerPlatformPage() {
|
||||
? [{ key: 'announcement', label: '杂项配置', children: <AnnouncementPanel /> }]
|
||||
: []),
|
||||
{ key: 'rules', label: '接单模板', children: <ProductRulesPanel /> },
|
||||
{ key: 'order-service', label: '订单服务', children: <OrderServicePanel /> },
|
||||
{ key: 'product-match', label: '匹配诊断', children: <ProductMatchPanel /> },
|
||||
{ key: 'categories', label: '分类', children: <CategoriesPanel /> },
|
||||
{ key: 'workers', label: '打手', children: <WorkersPanel /> },
|
||||
|
||||
@@ -0,0 +1,689 @@
|
||||
import {
|
||||
ArrowDownOutlined,
|
||||
ArrowUpOutlined,
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
Switch,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import PercentTable from '@/components/common/PercentTable'
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import {
|
||||
archiveAdminOrderServiceProfile,
|
||||
fetchAdminOrderServiceAutoPublishJobs,
|
||||
fetchAdminOrderServiceProfiles,
|
||||
publishAdminOrderServiceProfile,
|
||||
retryAdminOrderServiceAutoPublishJob,
|
||||
saveAdminOrderServiceProfile,
|
||||
type AdminOrderServiceAutoPublishJob,
|
||||
type AdminOrderServiceProfile,
|
||||
type OrderServiceField,
|
||||
} from '@/services/admin/worker-platform'
|
||||
import { uploadFile } from '@/services/files'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
type ScreenshotForm = {
|
||||
enabled: boolean
|
||||
required: boolean
|
||||
minCount: number
|
||||
maxCount: number
|
||||
label: string
|
||||
helpText: string
|
||||
}
|
||||
|
||||
type AutoPublishForm = {
|
||||
enabled: boolean
|
||||
requireVoucherConsume: boolean
|
||||
retryMax: number
|
||||
}
|
||||
|
||||
type OrderServiceFormValues = {
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
submitText: string
|
||||
successText: string
|
||||
screenshots: ScreenshotForm
|
||||
autoPublish: AutoPublishForm
|
||||
}
|
||||
|
||||
const DEFAULT_SCREENSHOTS: ScreenshotForm = {
|
||||
enabled: true,
|
||||
required: true,
|
||||
minCount: 1,
|
||||
maxCount: 5,
|
||||
label: '买家主页截图',
|
||||
helpText: '',
|
||||
}
|
||||
|
||||
const DEFAULT_AUTO_PUBLISH: AutoPublishForm = {
|
||||
enabled: false,
|
||||
requireVoucherConsume: true,
|
||||
retryMax: 3,
|
||||
}
|
||||
|
||||
export default function OrderServicePanel() {
|
||||
const { message } = App.useApp()
|
||||
const queryClient = useQueryClient()
|
||||
const [form] = Form.useForm<OrderServiceFormValues>()
|
||||
const [selectedCategoryId, setSelectedCategoryId] = useState<number | null>(null)
|
||||
const [fields, setFields] = useState<OrderServiceField[]>([])
|
||||
const [guideImage, setGuideImage] = useState<UploadedFile | Record<string, unknown> | null>(null)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [publishing, setPublishing] = useState(false)
|
||||
const [retryingJobId, setRetryingJobId] = useState(0)
|
||||
|
||||
const profilesQuery = useQuery({
|
||||
queryKey: ['admin-order-service-profiles'],
|
||||
queryFn: fetchAdminOrderServiceProfiles,
|
||||
})
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: ['admin-order-service-auto-publish-jobs', 'failed'],
|
||||
queryFn: () => fetchAdminOrderServiceAutoPublishJobs('failed'),
|
||||
})
|
||||
|
||||
const categories = profilesQuery.data?.data.categories || []
|
||||
const profiles = profilesQuery.data?.data.profiles || []
|
||||
const failedJobs = jobsQuery.data?.data.items || []
|
||||
const selectedCategory = categories.find((item) => item.categoryId === selectedCategoryId) || null
|
||||
const profile = profiles.find((item) => item.categoryId === selectedCategoryId) || null
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCategoryId !== null || categories.length === 0) return
|
||||
setSelectedCategoryId(categories[0]?.categoryId || null)
|
||||
}, [categories, selectedCategoryId])
|
||||
|
||||
useEffect(() => {
|
||||
applyProfile(profile)
|
||||
}, [profile, selectedCategoryId])
|
||||
|
||||
function applyProfile(nextProfile: AdminOrderServiceProfile | null) {
|
||||
form.setFieldsValue({
|
||||
name: nextProfile?.name || selectedCategory?.name || '',
|
||||
title: nextProfile?.title || '',
|
||||
description: nextProfile?.description || '',
|
||||
submitText: nextProfile?.submitText || '提交资料',
|
||||
successText: nextProfile?.successText || '资料已提交,请等待处理',
|
||||
screenshots: nextProfile?.screenshots || DEFAULT_SCREENSHOTS,
|
||||
autoPublish: nextProfile?.autoPublish || DEFAULT_AUTO_PUBLISH,
|
||||
})
|
||||
setFields(normalizeFieldOrder(nextProfile?.fields || []))
|
||||
setGuideImage(nextProfile?.guideImage || null)
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-order-service-profiles'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-order-service-auto-publish-jobs'] }),
|
||||
queryClient.invalidateQueries({ queryKey: ['admin-categories'] }),
|
||||
])
|
||||
}
|
||||
|
||||
function addField() {
|
||||
setFields((current) =>
|
||||
normalizeFieldOrder([
|
||||
...current,
|
||||
{
|
||||
key: `field${Date.now().toString(36)}`,
|
||||
label: '新字段',
|
||||
type: 'text',
|
||||
required: true,
|
||||
placeholder: '',
|
||||
helpText: '',
|
||||
options: [],
|
||||
sortOrder: (current.length + 1) * 10,
|
||||
},
|
||||
]),
|
||||
)
|
||||
}
|
||||
|
||||
function updateField(index: number, patch: Partial<OrderServiceField>) {
|
||||
setFields((current) =>
|
||||
current.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)),
|
||||
)
|
||||
}
|
||||
|
||||
function moveField(index: number, direction: -1 | 1) {
|
||||
const next = index + direction
|
||||
if (next < 0 || next >= fields.length) return
|
||||
setFields((current) => {
|
||||
const rows = [...current]
|
||||
const [row] = rows.splice(index, 1)
|
||||
if (!row) return current
|
||||
rows.splice(next, 0, row)
|
||||
return normalizeFieldOrder(rows)
|
||||
})
|
||||
}
|
||||
|
||||
function removeField(index: number) {
|
||||
setFields((current) => normalizeFieldOrder(current.filter((_, rowIndex) => rowIndex !== index)))
|
||||
}
|
||||
|
||||
async function handleSave(values: OrderServiceFormValues) {
|
||||
if (!selectedCategoryId) return
|
||||
const normalizedFields = normalizeFieldOrder(fields)
|
||||
const duplicateKeys = findDuplicateKeys(normalizedFields)
|
||||
if (duplicateKeys.length > 0) {
|
||||
message.error(`字段 Key 不能重复:${duplicateKeys.join('、')}`)
|
||||
return
|
||||
}
|
||||
setSaving(true)
|
||||
try {
|
||||
await saveAdminOrderServiceProfile({
|
||||
categoryId: selectedCategoryId,
|
||||
name: values.name || selectedCategory?.name || '订单服务配置',
|
||||
title: values.title || '',
|
||||
description: values.description || '',
|
||||
guideImage: guideImage || {},
|
||||
fields: normalizedFields,
|
||||
screenshots: values.screenshots,
|
||||
autoPublish: values.autoPublish,
|
||||
submitText: values.submitText,
|
||||
successText: values.successText,
|
||||
})
|
||||
message.success('草稿已保存;点击“发布新版本”后才会影响新工单')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '保存失败')
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!profile) {
|
||||
message.warning('请先保存配置草稿')
|
||||
return
|
||||
}
|
||||
setPublishing(true)
|
||||
try {
|
||||
const response = await publishAdminOrderServiceProfile(profile.id)
|
||||
message.success(`已发布 v${response.data.versionNo}`)
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '发布失败')
|
||||
} finally {
|
||||
setPublishing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive() {
|
||||
if (!profile) return
|
||||
setPublishing(true)
|
||||
try {
|
||||
await archiveAdminOrderServiceProfile(profile.id)
|
||||
message.success('配置已归档;已有工单快照不受影响')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '归档失败')
|
||||
} finally {
|
||||
setPublishing(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRetryJob(job: AdminOrderServiceAutoPublishJob) {
|
||||
setRetryingJobId(job.id)
|
||||
try {
|
||||
await retryAdminOrderServiceAutoPublishJob(job.id)
|
||||
message.success('自动发布任务已重新排队')
|
||||
await refresh()
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '重试失败')
|
||||
} finally {
|
||||
setRetryingJobId(0)
|
||||
}
|
||||
}
|
||||
|
||||
const categoryColumns = useMemo(
|
||||
() => [
|
||||
{ title: '分类', dataIndex: 'name' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (value: string) => (
|
||||
<Tag color={value === 'active' ? 'green' : 'default'}>
|
||||
{value === 'active' ? '启用' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单服务',
|
||||
width: 180,
|
||||
render: (_: unknown, row: { categoryId: number }) => {
|
||||
const rowProfile = profiles.find((item) => item.categoryId === row.categoryId)
|
||||
if (!rowProfile) return <Tag>未配置</Tag>
|
||||
if (rowProfile.status === 'draft') {
|
||||
return (
|
||||
<Tag color={rowProfile.currentVersionNo ? 'orange' : 'default'}>
|
||||
{rowProfile.currentVersionNo
|
||||
? `有未发布修改(线上 v${rowProfile.currentVersionNo})`
|
||||
: '草稿'}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color={rowProfile.status === 'published' ? 'green' : 'default'}>
|
||||
{rowProfile.status === 'published'
|
||||
? `已发布 v${rowProfile.currentVersionNo || 1}`
|
||||
: rowProfile.status}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, row: { categoryId: number }) => (
|
||||
<Button size="small" onClick={() => setSelectedCategoryId(row.categoryId)}>
|
||||
配置
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[profiles],
|
||||
)
|
||||
|
||||
const jobColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '订单',
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
<Text strong>{row.productName || '-'}</Text>
|
||||
<Text type="secondary">{row.platformOrderId || '-'}</Text>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '重试',
|
||||
width: 90,
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) =>
|
||||
`${row.attemptCount}/${row.maxAttempts}`,
|
||||
},
|
||||
{
|
||||
title: '失败原因',
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => (
|
||||
<Text type="danger">{row.lastError || '未知错误'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '更新时间',
|
||||
width: 180,
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) =>
|
||||
row.updatedAt ? formatAdminDateTime(row.updatedAt) : '-',
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 100,
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => (
|
||||
<Button
|
||||
size="small"
|
||||
loading={retryingJobId === row.id}
|
||||
onClick={() => void handleRetryJob(row)}
|
||||
>
|
||||
重试
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[retryingJobId],
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<Card
|
||||
size="small"
|
||||
title="订单服务配置"
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
loading={profilesQuery.isFetching || jobsQuery.isFetching}
|
||||
onClick={() => void refresh()}
|
||||
>
|
||||
刷新
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="一个分类使用一套订单服务配置"
|
||||
description="接单模板仍通过“所属分类”绑定分类;客户资料字段只在这里维护,不再在接单模板里重复配置。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<PercentTable
|
||||
rowKey="categoryId"
|
||||
size="small"
|
||||
loading={profilesQuery.isLoading}
|
||||
dataSource={categories}
|
||||
columns={categoryColumns}
|
||||
pagination={false}
|
||||
rowClassName={(row: { categoryId: number }) =>
|
||||
row.categoryId === selectedCategoryId ? 'ant-table-row-selected' : ''
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{selectedCategory ? (
|
||||
<Card
|
||||
size="small"
|
||||
title={`配置:${selectedCategory.name}`}
|
||||
extra={
|
||||
<Space size={6} wrap>
|
||||
<Tag color={profile?.status === 'published' ? 'green' : 'orange'}>
|
||||
{profile?.status === 'published'
|
||||
? `线上 v${profile.currentVersionNo || 1}`
|
||||
: profile?.currentVersionNo
|
||||
? `线上 v${profile.currentVersionNo},当前有草稿`
|
||||
: '未发布'}
|
||||
</Tag>
|
||||
<Button loading={publishing} onClick={() => void handlePublish()}>
|
||||
发布新版本
|
||||
</Button>
|
||||
{profile ? (
|
||||
<Button danger loading={publishing} onClick={() => void handleArchive()}>
|
||||
归档
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="配置名称" name="name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="客户页标题" name="title">
|
||||
<Input placeholder="例如:蛋仔派对资料填写" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="页面说明" name="description">
|
||||
<Input placeholder="显示在客户页顶部" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label="提交按钮文案" name="submitText">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Form.Item label="提交成功文案" name="successText">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Card size="small" title="用户引导图(一张)" style={{ marginBottom: 16 }}>
|
||||
<ImageUpload
|
||||
scene="order-service"
|
||||
scope="admin"
|
||||
maxCount={1}
|
||||
value={guideImage ? [guideImage as UploadedFile] : []}
|
||||
onChange={(files) => setGuideImage(files[0] || null)}
|
||||
uploader={(file, scene) => uploadFile(file, scene, 'admin')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="资料字段"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={addField} size="small">
|
||||
添加字段
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{fields.length === 0 ? (
|
||||
<Empty description="至少添加一个资料字段后才能发布" />
|
||||
) : (
|
||||
<Space direction="vertical" className="full-width">
|
||||
{fields.map((field, index) => (
|
||||
<Card key={`${field.key}-${index}`} size="small">
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={5}>
|
||||
<Input
|
||||
addonBefore="Key"
|
||||
value={field.key}
|
||||
onChange={(event) => updateField(index, { key: event.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={5}>
|
||||
<Input
|
||||
addonBefore="名称"
|
||||
value={field.label}
|
||||
onChange={(event) => updateField(index, { label: event.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
value={field.type}
|
||||
onChange={(value) => updateField(index, { type: value, options: [] })}
|
||||
options={[
|
||||
{ value: 'text', label: '文本' },
|
||||
{ value: 'textarea', label: '长文本' },
|
||||
{ value: 'select', label: '下拉' },
|
||||
{ value: 'radio', label: '单选' },
|
||||
{ value: 'number', label: '数字' },
|
||||
{ value: 'date', label: '日期' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Space>
|
||||
<Text type="secondary">必填</Text>
|
||||
<Switch
|
||||
checked={field.required}
|
||||
onChange={(value) => updateField(index, { required: value })}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Space>
|
||||
<Button
|
||||
disabled={index === 0}
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={() => moveField(index, -1)}
|
||||
/>
|
||||
<Button
|
||||
disabled={index === fields.length - 1}
|
||||
icon={<ArrowDownOutlined />}
|
||||
onClick={() => moveField(index, 1)}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeField(index)}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Input
|
||||
addonBefore="占位提示"
|
||||
value={field.placeholder || ''}
|
||||
onChange={(event) =>
|
||||
updateField(index, { placeholder: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Input
|
||||
addonBefore="填写说明"
|
||||
value={field.helpText || ''}
|
||||
onChange={(event) =>
|
||||
updateField(index, { helpText: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
{(field.type === 'select' || field.type === 'radio') && (
|
||||
<Col span={24}>
|
||||
<Input
|
||||
addonBefore="选项"
|
||||
placeholder="选项1,选项2,选项3"
|
||||
value={(field.options || []).join(',')}
|
||||
onChange={(event) =>
|
||||
updateField(index, {
|
||||
options: event.target.value
|
||||
.split(/[,,]/)
|
||||
.map((option) => option.trim())
|
||||
.filter(Boolean),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card size="small" title="截图规则">
|
||||
<Form.Item name={['screenshots', 'label']} label="截图标题">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={['screenshots', 'helpText']} label="截图说明">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space size={24} wrap>
|
||||
<Form.Item
|
||||
name={['screenshots', 'enabled']}
|
||||
label="启用"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['screenshots', 'required']}
|
||||
label="必填"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={['screenshots', 'minCount']} label="最少">
|
||||
<Input type="number" style={{ width: 90 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['screenshots', 'maxCount']} label="最多">
|
||||
<Input type="number" style={{ width: 90 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Card size="small" title="自动发布并核销">
|
||||
<Space direction="vertical" className="full-width">
|
||||
<Space size={24} wrap>
|
||||
<Form.Item
|
||||
name={['autoPublish', 'enabled']}
|
||||
label="开启"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name={['autoPublish', 'requireVoucherConsume']}
|
||||
label="要求核销"
|
||||
valuePropName="checked"
|
||||
>
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Form.Item name={['autoPublish', 'retryMax']} label="额外重试次数">
|
||||
<Input type="number" min={0} max={10} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
<Text type="secondary">
|
||||
开启后,客户提交完整资料会按当前工单快照自动发布;要求核销时,核销失败不会发布到大厅。
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
htmlType="button"
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
applyProfile(profile)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button htmlType="submit" loading={saving} type="primary">
|
||||
保存草稿
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
) : (
|
||||
<Card size="small">
|
||||
<Empty description="请选择分类" />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="自动发布失败任务"
|
||||
extra={<Tag color={failedJobs.length > 0 ? 'red' : 'default'}>{failedJobs.length}</Tag>}
|
||||
>
|
||||
<PercentTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={jobsQuery.isLoading}
|
||||
dataSource={failedJobs}
|
||||
columns={jobColumns}
|
||||
pagination={false}
|
||||
/>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeFieldOrder(fields: OrderServiceField[]) {
|
||||
return fields.map((field, index) => ({
|
||||
...field,
|
||||
sortOrder: (index + 1) * 10,
|
||||
}))
|
||||
}
|
||||
|
||||
function findDuplicateKeys(fields: OrderServiceField[]) {
|
||||
const seen = new Set<string>()
|
||||
const duplicates = new Set<string>()
|
||||
for (const field of fields) {
|
||||
const key = String(field.key || '').trim()
|
||||
if (!key) continue
|
||||
if (seen.has(key)) duplicates.add(key)
|
||||
seen.add(key)
|
||||
}
|
||||
return [...duplicates]
|
||||
}
|
||||
@@ -364,7 +364,7 @@ export default function ProductRulesPanel() {
|
||||
? Number(values.sharingTotalQuantity || 0) > 0 ||
|
||||
Number(values.sharingUnitReward || 0) > 0
|
||||
: hasManualSharingInput(values.sharingTotalQuantity, values.sharingUnitReward)
|
||||
const { giftCooldownUnit, giftCooldownValue, ...payload } = values
|
||||
const { giftCooldownUnit, giftCooldownValue, fieldsText: _fieldsText, ...payload } = values
|
||||
const res = await saveAdminWorkProductRule({
|
||||
...payload,
|
||||
ruleId: editingRule?.ruleId,
|
||||
@@ -1148,19 +1148,12 @@ export default function ProductRulesPanel() {
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
{/* 资料字段 */}
|
||||
<Form.Item
|
||||
label="资料字段(工单要求打手提交的资料)"
|
||||
name="fieldsText"
|
||||
extra="格式:key:名称#选项1,选项2(每行一个字段)"
|
||||
>
|
||||
<Input.TextArea
|
||||
rows={2}
|
||||
placeholder={
|
||||
'gameId:游戏编号\ngameNickname:游戏昵称\nsystem:系统#安卓,苹果\nserverZone:区服#QQ区,微信区'
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="客户资料字段已统一到「订单服务」配置"
|
||||
description="接单模板只负责匹配、价格、拼单和时效;请按分类到「接单平台 → 订单服务」维护客户填写字段。"
|
||||
/>
|
||||
</Form>
|
||||
|
||||
<Divider style={{ margin: '14px 0 12px' }} />
|
||||
|
||||
@@ -107,7 +107,7 @@ import dayjs, { ADMIN_DATE_FORMAT } from '@/lib/dayjs'
|
||||
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
|
||||
import { getAdminUsername, hasAdminPermission, hasAdminRole } from '@/utils/admin-auth'
|
||||
import { formatAdminDateTime } from '@/utils/admin-time'
|
||||
import { buildCollectMaterialLinkUrl } from '@/utils/collect-link'
|
||||
import { buildOrderServiceUrl } from '@/utils/order-service-link'
|
||||
import {
|
||||
applyProductNameSourceSuffix,
|
||||
resolveProductNameSourceSuffix,
|
||||
@@ -1673,14 +1673,14 @@ export default function WorkOrdersPanel() {
|
||||
message.error('复制补资料链接失败:缺少平台订单号')
|
||||
return
|
||||
}
|
||||
const url = buildCollectMaterialLinkUrl(window.location.origin, platformOrderId)
|
||||
const url = buildOrderServiceUrl(window.location.origin, platformOrderId)
|
||||
if (!url) {
|
||||
message.error('复制补资料链接失败:链接地址为空')
|
||||
return
|
||||
}
|
||||
const copied = await copyTextToClipboard(url)
|
||||
if (copied) {
|
||||
message.success(`补资料链接已复制(订单号 ${platformOrderId})`)
|
||||
message.success(`订单服务链接已复制(订单号 ${platformOrderId})`)
|
||||
return
|
||||
}
|
||||
// 剪贴板 API 不可用时退回弹窗,让客服手动复制。
|
||||
@@ -2002,9 +2002,9 @@ export default function WorkOrdersPanel() {
|
||||
<Button
|
||||
icon={<LinkOutlined />}
|
||||
onClick={() => void copyMaterialLink(row)}
|
||||
title="复制补资料链接(按平台订单号打开)"
|
||||
title="复制订单服务链接(按平台订单号打开)"
|
||||
>
|
||||
复制补资料链接
|
||||
复制订单服务链接
|
||||
</Button>
|
||||
) : null}
|
||||
{row.capabilities?.canResetMaterial && canUpdateMaterial ? (
|
||||
|
||||
@@ -1,67 +0,0 @@
|
||||
import { ArrowRightOutlined } from '@ant-design/icons'
|
||||
import { App, Button, Card, Form, Input, Space, Typography } from 'antd'
|
||||
import { useNavigate } from 'react-router'
|
||||
|
||||
import { normalizeOrderNoInput } from '@/utils/collect-link'
|
||||
import { useCollectLinkPageMeta } from './use-collect-link-page-meta'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
/**
|
||||
* 补资料入口页(`/supplement/`)。
|
||||
*
|
||||
* 客户只知道平台订单号时,从这里输入订单号再跳到 `/supplement/<订单号>`;
|
||||
* 如果客服已经发了完整链接,可以直接打开链接跳过这一步。
|
||||
*/
|
||||
export default function CollectMaterialLinkEntryPage() {
|
||||
const { message } = App.useApp()
|
||||
const navigate = useNavigate()
|
||||
useCollectLinkPageMeta('订单资料补充')
|
||||
|
||||
function handleFinish(values: { orderNo?: string }) {
|
||||
const orderNo = normalizeOrderNoInput(values.orderNo)
|
||||
if (!orderNo) {
|
||||
message.warning('请输入订单号')
|
||||
return
|
||||
}
|
||||
void navigate(`/supplement/${encodeURIComponent(orderNo)}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="collect-link-page">
|
||||
<Card className="collect-link-card" bordered={false}>
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<div>
|
||||
<Title level={3} className="collect-link-title">
|
||||
订单资料补充
|
||||
</Title>
|
||||
<Text type="secondary">请输入下单时使用的订单号,进入补资料页面。</Text>
|
||||
</div>
|
||||
|
||||
<Form layout="vertical" onFinish={handleFinish}>
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, whitespace: true, message: '请输入订单号' }]}
|
||||
>
|
||||
<Input autoComplete="off" allowClear placeholder="请输入订单号" size="large" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
htmlType="submit"
|
||||
block
|
||||
icon={<ArrowRightOutlined />}
|
||||
>
|
||||
下一步
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
如果客服已经发给你完整链接,直接打开链接即可。
|
||||
</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -1,405 +0,0 @@
|
||||
import { CheckCircleFilled, LoadingOutlined } from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
Form,
|
||||
Input,
|
||||
Radio,
|
||||
Result,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { Link, useParams } from 'react-router'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import { uploadCollectMaterialLinkFile } from '@/services/files'
|
||||
import { fetchCollectMaterialLink, submitCollectMaterialLink } from '@/services/worker'
|
||||
import type {
|
||||
CollectMaterialLinkInfo,
|
||||
CollectMaterialLinkOrder,
|
||||
UploadedFile,
|
||||
} from '@/types/worker-platform'
|
||||
import { useCollectLinkPageMeta } from './use-collect-link-page-meta'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
|
||||
const NOT_FOUND_MESSAGE = '订单号无效,请确认订单号是否正确'
|
||||
|
||||
type LinkLoadError = {
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
function resolveErrorCode(error: unknown): string {
|
||||
return String((error as { errorCode?: string })?.errorCode || '').trim()
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown): string {
|
||||
return error instanceof Error ? error.message : '操作失败,请稍后重试'
|
||||
}
|
||||
|
||||
function resolveErrorTitle(code: string): string {
|
||||
if (code === 'material_link_not_found' || code === 'material_link_work_order_invalid') {
|
||||
return '订单号无效'
|
||||
}
|
||||
if (code === 'material_link_not_pending') return '该订单当前无需补充资料'
|
||||
if (code === 'rate_limited') return '查询过于频繁'
|
||||
if (code === 'material_link_work_order_required') return '请选择要补充的商品'
|
||||
if (code === 'material_link_order_status_invalid') return '订单状态已变化'
|
||||
return '暂时无法打开页面'
|
||||
}
|
||||
|
||||
/** 公开页不展示「待分配」字样:已提交统一用「资料已提交」表达。 */
|
||||
function resolveMaterialOrderMessage(order: CollectMaterialLinkOrder): string {
|
||||
if (order.blockedReason === 'already_submitted') {
|
||||
return '资料已提交;如需修改请联系客服。'
|
||||
}
|
||||
return order.message || '当前不能修改资料,如需修改请联系客服。'
|
||||
}
|
||||
|
||||
/**
|
||||
* 客户侧补资料页。
|
||||
*
|
||||
* 只查询「待完善」工单;其他状态(待分配、已验收等)统一提示无需补充资料,
|
||||
* 不展示商品、订单号等信息。页面按 orders 渲染多张卡片,只有未指派、无份额的
|
||||
* 待完善工单返回表单。提交成功后本地展示成功页;刷新后因工单已变为待分配,
|
||||
* 会提示「该订单当前无需补充资料」。
|
||||
*/
|
||||
export default function CollectMaterialLinkPage() {
|
||||
const { message } = App.useApp()
|
||||
const { code: routeCode } = useParams<{ code?: string }>()
|
||||
const code = String(routeCode || '').trim()
|
||||
|
||||
useCollectLinkPageMeta('订单资料补充')
|
||||
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [info, setInfo] = useState<CollectMaterialLinkInfo | null>(null)
|
||||
const [loadError, setLoadError] = useState<LinkLoadError | null>(null)
|
||||
const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0)
|
||||
const submittingRef = useRef(false)
|
||||
const requestSeqRef = useRef(0)
|
||||
/** React StrictMode 开发环境会重复执行 mount effect,这里按订单号去重,避免刷新时打两次查询接口。 */
|
||||
const loadedCodeRef = useRef<string | null>(null)
|
||||
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
|
||||
const [submittedSuccess, setSubmittedSuccess] = useState(false)
|
||||
const [submittedNames, setSubmittedNames] = useState<string[]>([])
|
||||
|
||||
/**
|
||||
* 读取订单资料状态。
|
||||
*
|
||||
* silent=true 用于提交后刷新:不切整页 loading,避免页面闪烁丢焦点;
|
||||
* 同时用请求序号丢弃过期响应,防止快速重试时旧数据覆盖新数据。
|
||||
*/
|
||||
const loadInfo = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
const seq = requestSeqRef.current + 1
|
||||
requestSeqRef.current = seq
|
||||
|
||||
if (!options.silent) {
|
||||
// 新订单号/刷新页面时清空上一次提交成功状态;提交后不再回查
|
||||
// (严格模式只允许查询待完善订单,回查会变成「无需补充资料」)。
|
||||
setSubmittedSuccess(false)
|
||||
setSubmittedNames([])
|
||||
}
|
||||
|
||||
if (!code) {
|
||||
setInfo(null)
|
||||
setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE })
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
if (!options.silent) setLoading(true)
|
||||
try {
|
||||
const response = await fetchCollectMaterialLink(code)
|
||||
if (seq !== requestSeqRef.current) return
|
||||
const orders = response.data.orders || []
|
||||
if (orders.length === 0) {
|
||||
setInfo(null)
|
||||
setLoadError({ code: 'material_link_not_found', message: NOT_FOUND_MESSAGE })
|
||||
return
|
||||
}
|
||||
setInfo(response.data)
|
||||
setLoadError(null)
|
||||
setScreenshotsByOrder((previous) => {
|
||||
// 只保留仍可提交商品的本地截图,避免刷新后覆盖用户刚上传的图片;
|
||||
// 不可提交商品不再展示资料,直接丢弃。
|
||||
const next: Record<number, UploadedFile[]> = {}
|
||||
for (const order of orders) {
|
||||
if (!order.canSubmit) continue
|
||||
next[order.workOrderId] = previous[order.workOrderId] ?? order.screenshots ?? []
|
||||
}
|
||||
return next
|
||||
})
|
||||
} catch (error) {
|
||||
if (seq !== requestSeqRef.current) return
|
||||
setInfo(null)
|
||||
setLoadError({
|
||||
code: resolveErrorCode(error),
|
||||
message: resolveErrorMessage(error) || NOT_FOUND_MESSAGE,
|
||||
})
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current && !options.silent) setLoading(false)
|
||||
}
|
||||
},
|
||||
[code],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedCodeRef.current === code) return
|
||||
loadedCodeRef.current = code
|
||||
void loadInfo()
|
||||
}, [code, loadInfo])
|
||||
|
||||
async function handleSubmit(order: CollectMaterialLinkOrder, values: Record<string, string>) {
|
||||
if (!code) return
|
||||
// 多工单时用户可能连点不同卡片;前端只允许一个提交在途,避免列表状态互相覆盖。
|
||||
if (submittingRef.current) return
|
||||
submittingRef.current = true
|
||||
setSubmittingWorkOrderId(order.workOrderId)
|
||||
try {
|
||||
await submitCollectMaterialLink(code, {
|
||||
workOrderId: order.workOrderId,
|
||||
fields: values,
|
||||
screenshots: screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? [],
|
||||
})
|
||||
message.success('提交成功')
|
||||
const submittedName = String(order.productName || '').trim() || '订单商品'
|
||||
setSubmittedNames((previous) => [...previous, submittedName])
|
||||
const remainingOrders = (info?.orders || []).filter(
|
||||
(item) => item.workOrderId !== order.workOrderId,
|
||||
)
|
||||
if (remainingOrders.length > 0) {
|
||||
await loadInfo({ silent: true })
|
||||
} else {
|
||||
// 严格模式只查询待完善订单:最后一个商品提交后不再回查,
|
||||
// 直接本地展示成功页,避免刷新成「无需补充资料」。
|
||||
setSubmittedSuccess(true)
|
||||
}
|
||||
} catch (error) {
|
||||
const errorCode = resolveErrorCode(error)
|
||||
message.error(resolveErrorMessage(error))
|
||||
// 状态类错误说明工单已被并发提交、被客服重置或流转到其他状态:
|
||||
// 以服务端状态为准静默刷新,页面切换成只读卡片而不是整页报错。
|
||||
if (
|
||||
errorCode === 'material_link_already_submitted' ||
|
||||
errorCode === 'material_link_order_status_invalid' ||
|
||||
errorCode === 'material_link_work_order_invalid' ||
|
||||
errorCode === 'material_link_not_found'
|
||||
) {
|
||||
await loadInfo({ silent: true })
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setSubmittingWorkOrderId(0)
|
||||
}
|
||||
}
|
||||
|
||||
const emptyStateMessage = '该订单当前不可修改资料,如需修改请联系客服。'
|
||||
const submittedProductNames = submittedNames.join('、')
|
||||
|
||||
return (
|
||||
<main className="collect-link-page">
|
||||
<Card className="collect-link-card" bordered={false}>
|
||||
{loading ? (
|
||||
<div className="collect-link-state">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
<Text type="secondary">正在读取订单资料...</Text>
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<Result
|
||||
status={loadError.code === 'material_link_not_pending' ? 'info' : 'warning'}
|
||||
title={resolveErrorTitle(loadError.code)}
|
||||
subTitle={
|
||||
loadError.code === 'material_link_not_pending'
|
||||
? undefined
|
||||
: loadError.code === 'rate_limited'
|
||||
? '请稍后再试'
|
||||
: loadError.message || NOT_FOUND_MESSAGE
|
||||
}
|
||||
extra={
|
||||
<Link to="/supplement">
|
||||
<Button>重新输入订单号</Button>
|
||||
</Link>
|
||||
}
|
||||
/>
|
||||
) : submittedSuccess ? (
|
||||
<div className="collect-link-success">
|
||||
<CheckCircleFilled className="collect-link-success-icon" />
|
||||
<Title level={4} className="collect-link-success-title">
|
||||
提交成功
|
||||
</Title>
|
||||
<Text type="secondary">资料已提交;如需修改请联系客服。</Text>
|
||||
<div className="collect-link-success-products">
|
||||
<span>本次提交</span>
|
||||
<strong>{submittedProductNames || '订单资料'}</strong>
|
||||
</div>
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
本页面仅用于补充订单资料,请妥善保管订单号。
|
||||
</Text>
|
||||
</div>
|
||||
) : info ? (
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
{info.pendingCount > 0 ? (
|
||||
<Text type="secondary">请补充以下资料,提交后客服会尽快处理。</Text>
|
||||
) : null}
|
||||
|
||||
{info.pendingCount > 1 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`当前订单有 ${info.pendingCount} 个商品待补充资料,请逐个提交`}
|
||||
description="每个商品的资料仅可提交一次,提交成功后不能再修改。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{info.pendingCount === 0 ? (
|
||||
<Alert type="warning" showIcon message={emptyStateMessage} />
|
||||
) : null}
|
||||
|
||||
{info.orders.map((order) => (
|
||||
<Card key={order.workOrderId} className="collect-link-order-card" size="small">
|
||||
<Space direction="vertical" size={12} className="full-width">
|
||||
<div className="collect-link-order-head">
|
||||
<div className="collect-link-order-name">
|
||||
<span>{order.productName || '订单商品'}</span>
|
||||
</div>
|
||||
<div className="collect-link-order-no">
|
||||
<span>订单号</span>
|
||||
<strong>{order.platformOrderId || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{order.canSubmit ? (
|
||||
<>
|
||||
{info.pendingCount === 1 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该商品资料仅可提交一次"
|
||||
description="提交成功后不能再修改;如需修改请联系客服。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<MaterialLinkOrderForm
|
||||
order={order}
|
||||
code={code}
|
||||
screenshots={
|
||||
screenshotsByOrder[order.workOrderId] ?? order.screenshots ?? []
|
||||
}
|
||||
submitting={submittingWorkOrderId === order.workOrderId}
|
||||
disabled={submittingWorkOrderId !== 0}
|
||||
onScreenshotsChange={(files) =>
|
||||
setScreenshotsByOrder((previous) => ({
|
||||
...previous,
|
||||
[order.workOrderId]: files,
|
||||
}))
|
||||
}
|
||||
onSubmit={(values) => handleSubmit(order, values)}
|
||||
/>
|
||||
</>
|
||||
) : info.pendingCount > 0 ? (
|
||||
<Alert
|
||||
type={order.blockedReason === 'already_submitted' ? 'info' : 'warning'}
|
||||
showIcon
|
||||
message={resolveMaterialOrderMessage(order)}
|
||||
/>
|
||||
) : null}
|
||||
</Space>
|
||||
</Card>
|
||||
))}
|
||||
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
本页面仅用于补充订单资料,请妥善保管订单号。
|
||||
</Text>
|
||||
</Space>
|
||||
) : null}
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
type MaterialLinkOrderFormProps = {
|
||||
order: CollectMaterialLinkOrder
|
||||
code: string
|
||||
screenshots: UploadedFile[]
|
||||
submitting: boolean
|
||||
disabled: boolean
|
||||
onScreenshotsChange: (files: UploadedFile[]) => void
|
||||
onSubmit: (values: Record<string, string>) => void
|
||||
}
|
||||
|
||||
function MaterialLinkOrderForm({
|
||||
order,
|
||||
code,
|
||||
screenshots,
|
||||
submitting,
|
||||
disabled,
|
||||
onScreenshotsChange,
|
||||
onSubmit,
|
||||
}: MaterialLinkOrderFormProps) {
|
||||
return (
|
||||
<Form layout="vertical" initialValues={order.values || {}} onFinish={onSubmit}>
|
||||
{(order.fields ?? []).map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={field.key}
|
||||
label={
|
||||
<Space size={6}>
|
||||
<span>{field.label}</span>
|
||||
{field.required ? <Tag color="orange">必填</Tag> : <Tag>选填</Tag>}
|
||||
</Space>
|
||||
}
|
||||
rules={
|
||||
field.required
|
||||
? [{ required: true, whitespace: true, message: `请填写${field.label}` }]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{field.type === 'select' && (field.options || []).length > 0 ? (
|
||||
<Radio.Group
|
||||
options={(field.options || []).map((option) => ({
|
||||
label: option,
|
||||
value: option,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<Input autoComplete="off" placeholder={`请输入${field.label}`} />
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
|
||||
<Form.Item label="买家主页截图">
|
||||
<ImageUpload
|
||||
scene="collect-material"
|
||||
scope="collect"
|
||||
maxCount={5}
|
||||
showPasteHint
|
||||
captureGlobalPaste
|
||||
value={screenshots}
|
||||
onChange={onScreenshotsChange}
|
||||
uploader={(file, scene) =>
|
||||
uploadCollectMaterialLinkFile(file, scene, code, order.workOrderId)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<div className="collect-link-actions">
|
||||
<Button
|
||||
type="primary"
|
||||
size="large"
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
disabled={disabled}
|
||||
>
|
||||
提交资料
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
@@ -1,186 +0,0 @@
|
||||
import { SearchOutlined } from '@ant-design/icons'
|
||||
import { App, Button, Card, Form, Input, Radio, Result, Space, Typography } from 'antd'
|
||||
import { useState } from 'react'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import { lookupCollectOrder, submitCollectOrder } from '@/services/worker'
|
||||
import type { CollectLookupOrder, UploadedFile } from '@/types/worker-platform'
|
||||
|
||||
export default function CollectPage() {
|
||||
const { message } = App.useApp()
|
||||
const [orderNo, setOrderNo] = useState('')
|
||||
const [lookupOrders, setLookupOrders] = useState<CollectLookupOrder[]>([])
|
||||
const [lookupLoading, setLookupLoading] = useState(false)
|
||||
const [submittingWorkOrderId, setSubmittingWorkOrderId] = useState(0)
|
||||
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
|
||||
const [complete, setComplete] = useState(false)
|
||||
|
||||
async function submitLookup(values: { orderNo: string }) {
|
||||
setLookupLoading(true)
|
||||
setComplete(false)
|
||||
try {
|
||||
const nextOrderNo = String(values.orderNo || '').trim()
|
||||
setOrderNo(nextOrderNo)
|
||||
await refreshLookupOrders(nextOrderNo)
|
||||
} catch (error) {
|
||||
setLookupOrders([])
|
||||
message.error(error instanceof Error ? error.message : '查询失败')
|
||||
} finally {
|
||||
setLookupLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitMaterial(order: CollectLookupOrder, values: Record<string, string>) {
|
||||
setSubmittingWorkOrderId(order.workOrderId)
|
||||
try {
|
||||
const response = await submitCollectOrder({
|
||||
orderNo,
|
||||
workOrderId: order.workOrderId,
|
||||
fields: values,
|
||||
screenshots: screenshotsByOrder[order.workOrderId] || [],
|
||||
})
|
||||
message.success(response.data.complete ? '当前商品资料已完善' : '当前商品资料已提交')
|
||||
await refreshLookupOrders(orderNo, { allowCompleteWhenNotFound: true })
|
||||
} catch (error) {
|
||||
message.error(error instanceof Error ? error.message : '提交失败')
|
||||
} finally {
|
||||
setSubmittingWorkOrderId(0)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="collect-page">
|
||||
<Card className="collect-card" bordered={false}>
|
||||
<Typography.Title level={3}>订单资料补充</Typography.Title>
|
||||
<Typography.Paragraph type="secondary">
|
||||
请填写正确订单号,匹配成功后继续补充资料。
|
||||
</Typography.Paragraph>
|
||||
|
||||
<Form layout="vertical" onFinish={submitLookup}>
|
||||
<Space.Compact className="full-width">
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
className="collect-order-input"
|
||||
rules={[{ required: true, message: '请输入订单号' }]}
|
||||
>
|
||||
<Input placeholder="订单号" />
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
icon={<SearchOutlined />}
|
||||
loading={lookupLoading}
|
||||
>
|
||||
查询
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
</Form>
|
||||
|
||||
{complete ? (
|
||||
<Result
|
||||
status="success"
|
||||
title="资料已提交完成"
|
||||
subTitle="订单已进入待分配状态,请等待处理。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{!complete && lookupOrders.length > 1 ? (
|
||||
<Typography.Paragraph type="secondary" className="platform-section-gap">
|
||||
当前订单有 {lookupOrders.length} 个待完善商品,请逐个提交资料。
|
||||
</Typography.Paragraph>
|
||||
) : null}
|
||||
|
||||
{lookupOrders.map((currentOrder) => (
|
||||
<Card
|
||||
key={currentOrder.workOrderId}
|
||||
className="platform-section-gap"
|
||||
title={currentOrder.productName}
|
||||
extra={<Typography.Text type="secondary">{currentOrder.workOrderNo}</Typography.Text>}
|
||||
size="small"
|
||||
>
|
||||
<Typography.Paragraph type="secondary">
|
||||
订单号:{currentOrder.platformOrderId}
|
||||
</Typography.Paragraph>
|
||||
<Form
|
||||
key={currentOrder.workOrderId}
|
||||
layout="vertical"
|
||||
initialValues={currentOrder.values || {}}
|
||||
onFinish={(values) => submitMaterial(currentOrder, values)}
|
||||
>
|
||||
{currentOrder.fields.map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
label={field.label}
|
||||
name={field.key}
|
||||
rules={
|
||||
field.required ? [{ required: true, message: `请选择/填写${field.label}` }] : []
|
||||
}
|
||||
>
|
||||
{field.type === 'select' && (field.options || []).length > 0 ? (
|
||||
<Radio.Group
|
||||
options={(field.options || []).map((option) => ({
|
||||
label: option,
|
||||
value: option,
|
||||
}))}
|
||||
/>
|
||||
) : (
|
||||
<Input />
|
||||
)}
|
||||
</Form.Item>
|
||||
))}
|
||||
<Form.Item label="买家主页截图">
|
||||
<ImageUpload
|
||||
scene="collect-material"
|
||||
scope="collect"
|
||||
maxCount={5}
|
||||
showPasteHint
|
||||
captureGlobalPaste
|
||||
value={
|
||||
screenshotsByOrder[currentOrder.workOrderId] ?? currentOrder.screenshots ?? []
|
||||
}
|
||||
onChange={(files) =>
|
||||
setScreenshotsByOrder((prev) => ({
|
||||
...prev,
|
||||
[currentOrder.workOrderId]: files,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button
|
||||
type="primary"
|
||||
htmlType="submit"
|
||||
loading={submittingWorkOrderId === currentOrder.workOrderId}
|
||||
>
|
||||
提交资料
|
||||
</Button>
|
||||
</Form>
|
||||
</Card>
|
||||
))}
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
|
||||
async function refreshLookupOrders(
|
||||
currentOrderNo: string,
|
||||
options: { allowCompleteWhenNotFound?: boolean } = {},
|
||||
) {
|
||||
try {
|
||||
const response = await lookupCollectOrder(currentOrderNo)
|
||||
setLookupOrders(response.data.orders || [])
|
||||
setComplete(false)
|
||||
} catch (error) {
|
||||
if (options.allowCompleteWhenNotFound && isCollectOrderNotFoundError(error)) {
|
||||
setLookupOrders([])
|
||||
setComplete(true)
|
||||
return
|
||||
}
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function isCollectOrderNotFoundError(error: unknown) {
|
||||
return (
|
||||
String((error as { errorCode?: string })?.errorCode || '').trim() === 'collect_order_not_found'
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,498 @@
|
||||
import {
|
||||
CheckCircleFilled,
|
||||
LoadingOutlined,
|
||||
ReloadOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Button,
|
||||
Card,
|
||||
DatePicker,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Radio,
|
||||
Result,
|
||||
Select,
|
||||
Space,
|
||||
Spin,
|
||||
Tag,
|
||||
Typography,
|
||||
} from 'antd'
|
||||
import dayjs, { type Dayjs } from 'dayjs'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router'
|
||||
|
||||
import ImageUpload from '@/components/files/ImageUpload'
|
||||
import {
|
||||
fetchOrderService,
|
||||
submitOrderServiceMaterial,
|
||||
uploadOrderServiceFile,
|
||||
type OrderServiceInfo,
|
||||
type OrderServiceItem,
|
||||
} from '@/services/worker'
|
||||
import type { UploadedFile } from '@/types/worker-platform'
|
||||
import { normalizeOrderServiceInput } from '@/utils/order-service-link'
|
||||
import { useOrderServicePageMeta } from './use-order-service-page-meta'
|
||||
|
||||
const { Text, Title } = Typography
|
||||
const NOT_FOUND_MESSAGE = '订单号无效,请确认订单号是否正确'
|
||||
const STATUS_REFRESH_INTERVAL_MS = 15_000
|
||||
|
||||
function resolveErrorCode(error: unknown) {
|
||||
return String((error as { errorCode?: string })?.errorCode || '').trim()
|
||||
}
|
||||
|
||||
function resolveErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '操作失败,请稍后重试'
|
||||
}
|
||||
|
||||
function resolveErrorTitle(code: string) {
|
||||
if (code === 'order_service_not_found') return '订单号无效'
|
||||
if (code === 'rate_limited') return '查询过于频繁'
|
||||
return '暂时无法打开页面'
|
||||
}
|
||||
|
||||
export default function OrderServicePage() {
|
||||
const { message } = App.useApp()
|
||||
const navigate = useNavigate()
|
||||
const { orderNo: routeOrderNo } = useParams<{ orderNo?: string }>()
|
||||
const orderNo = String(routeOrderNo || '').trim()
|
||||
const [info, setInfo] = useState<OrderServiceInfo | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadError, setLoadError] = useState<{ code: string; message: string } | null>(null)
|
||||
const [submittedNames, setSubmittedNames] = useState<string[]>([])
|
||||
const [lastSuccessText, setLastSuccessText] = useState('')
|
||||
const [submittingId, setSubmittingId] = useState(0)
|
||||
const [screenshotsByOrder, setScreenshotsByOrder] = useState<Record<number, UploadedFile[]>>({})
|
||||
const submittingRef = useRef(false)
|
||||
const requestSeqRef = useRef(0)
|
||||
const loadedOrderRef = useRef('')
|
||||
|
||||
useOrderServicePageMeta(info?.items[0]?.title || '订单服务')
|
||||
|
||||
const load = useCallback(
|
||||
async (options: { silent?: boolean } = {}) => {
|
||||
const seq = requestSeqRef.current + 1
|
||||
requestSeqRef.current = seq
|
||||
if (!options.silent) {
|
||||
setSubmittedNames([])
|
||||
setLastSuccessText('')
|
||||
setLoading(true)
|
||||
}
|
||||
if (!orderNo) {
|
||||
setInfo(null)
|
||||
setLoadError(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
try {
|
||||
const response = await fetchOrderService(orderNo)
|
||||
if (seq !== requestSeqRef.current) return
|
||||
setInfo(response.data)
|
||||
setLoadError(null)
|
||||
} catch (error) {
|
||||
if (seq !== requestSeqRef.current) return
|
||||
if (options.silent && info) return
|
||||
setInfo(null)
|
||||
setLoadError({
|
||||
code: resolveErrorCode(error),
|
||||
message: resolveErrorMessage(error) || NOT_FOUND_MESSAGE,
|
||||
})
|
||||
} finally {
|
||||
if (seq === requestSeqRef.current && !options.silent) setLoading(false)
|
||||
}
|
||||
},
|
||||
[info, orderNo],
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (loadedOrderRef.current === orderNo) return
|
||||
loadedOrderRef.current = orderNo
|
||||
setScreenshotsByOrder({})
|
||||
if (!orderNo) {
|
||||
setInfo(null)
|
||||
setLoadError(null)
|
||||
return
|
||||
}
|
||||
void load()
|
||||
}, [load, orderNo])
|
||||
|
||||
useEffect(() => {
|
||||
if (!orderNo) return
|
||||
const refresh = () => {
|
||||
if (document.visibilityState === 'visible' && !submittingRef.current) {
|
||||
void load({ silent: true })
|
||||
}
|
||||
}
|
||||
const timer = window.setInterval(refresh, STATUS_REFRESH_INTERVAL_MS)
|
||||
document.addEventListener('visibilitychange', refresh)
|
||||
window.addEventListener('focus', refresh)
|
||||
return () => {
|
||||
window.clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', refresh)
|
||||
window.removeEventListener('focus', refresh)
|
||||
}
|
||||
}, [load, orderNo])
|
||||
|
||||
function handleSearch(values: { orderNo?: string }) {
|
||||
const nextOrderNo = normalizeOrderServiceInput(values.orderNo)
|
||||
if (!nextOrderNo) {
|
||||
message.warning('请输入订单号')
|
||||
return
|
||||
}
|
||||
void navigate(`/o/${encodeURIComponent(nextOrderNo)}`)
|
||||
}
|
||||
|
||||
async function handleSubmit(item: OrderServiceItem, values: Record<string, unknown>) {
|
||||
if (!orderNo || submittingRef.current) return
|
||||
const screenshots = screenshotsByOrder[item.workOrderId] ?? item.screenshots ?? []
|
||||
const screenshotRule = item.screenshotsRule
|
||||
if (
|
||||
screenshotRule?.enabled &&
|
||||
screenshotRule.minCount > 0 &&
|
||||
screenshots.length < screenshotRule.minCount
|
||||
) {
|
||||
message.error(`${screenshotRule.label}至少需要 ${screenshotRule.minCount} 张`)
|
||||
return
|
||||
}
|
||||
|
||||
submittingRef.current = true
|
||||
setSubmittingId(item.workOrderId)
|
||||
try {
|
||||
const response = await submitOrderServiceMaterial(orderNo, {
|
||||
workOrderId: item.workOrderId,
|
||||
fields: normalizeFormFieldValues(item, values),
|
||||
screenshots,
|
||||
})
|
||||
setSubmittedNames((current) => [
|
||||
...current,
|
||||
item.productName || item.categoryName || '订单商品',
|
||||
])
|
||||
setLastSuccessText(response.data.successText || item.successText || '资料已提交')
|
||||
await load({ silent: true })
|
||||
} catch (error) {
|
||||
message.error(resolveErrorMessage(error))
|
||||
const code = resolveErrorCode(error)
|
||||
if (
|
||||
code === 'order_service_not_found' ||
|
||||
code === 'material_link_already_submitted' ||
|
||||
code === 'material_link_order_status_invalid'
|
||||
) {
|
||||
await load({ silent: true })
|
||||
}
|
||||
} finally {
|
||||
submittingRef.current = false
|
||||
setSubmittingId(0)
|
||||
}
|
||||
}
|
||||
|
||||
if (!orderNo) {
|
||||
return (
|
||||
<main className="collect-link-page">
|
||||
<Card className="collect-link-card" bordered={false}>
|
||||
<Space direction="vertical" size={16} className="full-width">
|
||||
<div>
|
||||
<Title level={3}>订单服务</Title>
|
||||
<Text type="secondary">请输入下单时使用的订单号,补充资料或查看进度。</Text>
|
||||
</div>
|
||||
<Form layout="vertical" onFinish={handleSearch}>
|
||||
<Form.Item
|
||||
name="orderNo"
|
||||
label="订单号"
|
||||
rules={[{ required: true, whitespace: true, message: '请输入订单号' }]}
|
||||
>
|
||||
<Input
|
||||
allowClear
|
||||
autoComplete="off"
|
||||
prefix={<SearchOutlined />}
|
||||
placeholder="请输入订单号"
|
||||
size="large"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Button block htmlType="submit" size="large" type="primary">
|
||||
查询订单
|
||||
</Button>
|
||||
</Form>
|
||||
</Space>
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
const pageConfig = info?.items[0]
|
||||
const pendingItems = (info?.items || []).filter((item) => item.canSubmit)
|
||||
|
||||
return (
|
||||
<main className="collect-link-page">
|
||||
<Card className="collect-link-card" bordered={false}>
|
||||
{loading ? (
|
||||
<div className="collect-link-state">
|
||||
<Spin indicator={<LoadingOutlined spin />} size="large" />
|
||||
<Text type="secondary">正在读取订单信息...</Text>
|
||||
</div>
|
||||
) : loadError ? (
|
||||
<Result
|
||||
status={loadError.code === 'rate_limited' ? 'warning' : 'info'}
|
||||
title={resolveErrorTitle(loadError.code)}
|
||||
subTitle={loadError.message}
|
||||
extra={<Button onClick={() => void load()}>刷新重试</Button>}
|
||||
/>
|
||||
) : info ? (
|
||||
<Space direction="vertical" size={14} className="full-width">
|
||||
<div className="collect-link-heading">
|
||||
<div>
|
||||
<Title level={4}>{pageConfig?.title || '订单服务'}</Title>
|
||||
<Text type="secondary">订单号:{info.platformOrderId}</Text>
|
||||
{pageConfig?.description ? (
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<Text type="secondary">{pageConfig.description}</Text>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
size="small"
|
||||
onClick={() => void load({ silent: true })}
|
||||
>
|
||||
刷新状态
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{submittedNames.length > 0 ? (
|
||||
<Alert
|
||||
type="success"
|
||||
showIcon
|
||||
message={lastSuccessText || '资料已提交'}
|
||||
description={`${submittedNames.join('、')};页面会每 15 秒自动更新订单状态。`}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{pendingItems.length > 1 ? (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`当前订单有 ${pendingItems.length} 个商品需要补充资料`}
|
||||
description="请逐个商品提交;每个商品资料仅可提交一次。"
|
||||
/>
|
||||
) : pendingItems.length === 1 ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="该商品资料仅可提交一次"
|
||||
description="提交成功后不能再修改;如需修改请联系客服。"
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{info.items.map((item) =>
|
||||
item.canSubmit ? (
|
||||
<OrderServiceForm
|
||||
key={item.workOrderId}
|
||||
item={item}
|
||||
code={orderNo}
|
||||
submitting={submittingId === item.workOrderId}
|
||||
disabled={submittingId !== 0}
|
||||
screenshots={screenshotsByOrder[item.workOrderId] ?? item.screenshots ?? []}
|
||||
onScreenshotsChange={(files) => {
|
||||
setScreenshotsByOrder((current) => ({
|
||||
...current,
|
||||
[item.workOrderId]: files,
|
||||
}))
|
||||
}}
|
||||
onSubmit={(values) => void handleSubmit(item, values)}
|
||||
/>
|
||||
) : (
|
||||
<Card key={item.workOrderId} size="small">
|
||||
<Space direction="vertical" size={6} className="full-width">
|
||||
<Space size={8} wrap>
|
||||
<strong>{item.productName || item.categoryName || '订单商品'}</strong>
|
||||
<Tag color={item.state === 'accepted' ? 'green' : 'blue'}>
|
||||
{item.stateLabel}
|
||||
</Tag>
|
||||
</Space>
|
||||
<Text type="secondary">{item.message}</Text>
|
||||
</Space>
|
||||
</Card>
|
||||
),
|
||||
)}
|
||||
|
||||
<Text type="secondary" className="collect-link-footnote">
|
||||
资料提交后如需修改,请联系客服。
|
||||
</Text>
|
||||
</Space>
|
||||
) : null}
|
||||
</Card>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
function OrderServiceForm({
|
||||
item,
|
||||
code,
|
||||
submitting,
|
||||
disabled,
|
||||
screenshots,
|
||||
onScreenshotsChange,
|
||||
onSubmit,
|
||||
}: {
|
||||
item: OrderServiceItem
|
||||
code: string
|
||||
submitting: boolean
|
||||
disabled: boolean
|
||||
screenshots: UploadedFile[]
|
||||
onScreenshotsChange: (files: UploadedFile[]) => void
|
||||
onSubmit: (values: Record<string, unknown>) => void
|
||||
}) {
|
||||
const rule = item.screenshotsRule || {
|
||||
enabled: false,
|
||||
required: false,
|
||||
minCount: 0,
|
||||
maxCount: 5,
|
||||
label: '截图',
|
||||
helpText: '',
|
||||
}
|
||||
|
||||
return (
|
||||
<Card size="small" className="collect-link-order-card">
|
||||
<Space direction="vertical" size={12} className="full-width">
|
||||
<div className="collect-link-order-head">
|
||||
<div className="collect-link-order-name">
|
||||
<span>{item.productName || item.categoryName || '订单商品'}</span>
|
||||
</div>
|
||||
<div className="collect-link-order-no">
|
||||
<span>分类</span>
|
||||
<strong>{item.categoryName || '-'}</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{item.title ? <Title level={5}>{item.title}</Title> : null}
|
||||
{item.description ? <Text type="secondary">{item.description}</Text> : null}
|
||||
|
||||
{item.guideImage && 'url' in item.guideImage && item.guideImage.url ? (
|
||||
<img
|
||||
alt="填写引导图"
|
||||
src={String(item.guideImage.url)}
|
||||
style={{ width: '100%', borderRadius: 8 }}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
{item.fields && item.fields.length > 0 ? (
|
||||
<Form layout="vertical" initialValues={item.values || {}} onFinish={onSubmit}>
|
||||
{item.fields.map((field) => (
|
||||
<Form.Item
|
||||
key={field.key}
|
||||
name={field.key}
|
||||
label={
|
||||
<Space size={6}>
|
||||
<span>{field.label}</span>
|
||||
{field.required ? <Tag color="orange">必填</Tag> : <Tag>选填</Tag>}
|
||||
</Space>
|
||||
}
|
||||
extra={field.helpText}
|
||||
rules={
|
||||
field.required
|
||||
? [{ required: true, whitespace: true, message: `请填写${field.label}` }]
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{renderFieldInput(field)}
|
||||
</Form.Item>
|
||||
))}
|
||||
|
||||
{rule.enabled ? (
|
||||
<Form.Item
|
||||
label={
|
||||
<Space size={6}>
|
||||
<span>{rule.label}</span>
|
||||
<Tag color={rule.required ? 'orange' : 'default'}>
|
||||
{rule.required ? '必填' : '选填'}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={rule.helpText}
|
||||
>
|
||||
<ImageUpload
|
||||
scene="collect-material"
|
||||
scope="collect"
|
||||
maxCount={rule.maxCount}
|
||||
showPasteHint
|
||||
captureGlobalPaste
|
||||
value={screenshots}
|
||||
onChange={onScreenshotsChange}
|
||||
uploader={async (file, scene) => {
|
||||
const response = await uploadOrderServiceFile(
|
||||
file,
|
||||
scene,
|
||||
code,
|
||||
item.workOrderId,
|
||||
)
|
||||
return response.data.file
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : null}
|
||||
|
||||
<div className="collect-link-actions">
|
||||
<Button
|
||||
block
|
||||
htmlType="submit"
|
||||
loading={submitting}
|
||||
disabled={disabled}
|
||||
type="primary"
|
||||
>
|
||||
{item.submitText || '提交资料'}
|
||||
</Button>
|
||||
</div>
|
||||
</Form>
|
||||
) : (
|
||||
<Alert type="warning" showIcon message="该商品尚未配置资料收集字段,请联系客服。" />
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function renderFieldInput(field: NonNullable<OrderServiceItem['fields']>[number]) {
|
||||
const placeholder = field.placeholder || `请输入${field.label}`
|
||||
if (field.type === 'textarea') {
|
||||
return <Input.TextArea autoComplete="off" placeholder={placeholder} rows={3} />
|
||||
}
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<Select
|
||||
allowClear
|
||||
placeholder={placeholder}
|
||||
options={(field.options || []).map((option) => ({ label: option, value: option }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (field.type === 'radio') {
|
||||
return (
|
||||
<Radio.Group
|
||||
options={(field.options || []).map((option) => ({ label: option, value: option }))}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (field.type === 'number') {
|
||||
return <InputNumber style={{ width: '100%' }} placeholder={placeholder} />
|
||||
}
|
||||
if (field.type === 'date') {
|
||||
return <DatePicker style={{ width: '100%' }} placeholder={placeholder} />
|
||||
}
|
||||
return <Input autoComplete="off" placeholder={placeholder} />
|
||||
}
|
||||
|
||||
function normalizeFormFieldValues(item: OrderServiceItem, values: Record<string, unknown>) {
|
||||
const fields = new Map((item.fields || []).map((field) => [field.key, field]))
|
||||
return Object.fromEntries(
|
||||
Object.entries(values).map(([key, value]) => {
|
||||
const field = fields.get(key)
|
||||
if (field?.type === 'date' && value && dayjs.isDayjs(value)) {
|
||||
return [key, (value as Dayjs).format('YYYY-MM-DD')]
|
||||
}
|
||||
return [key, String(value ?? '').trim()]
|
||||
}),
|
||||
)
|
||||
}
|
||||
+4
-13
@@ -1,12 +1,7 @@
|
||||
import { useEffect } from 'react'
|
||||
|
||||
/**
|
||||
* 公开补资料页共用的 document.title / robots / referrer 设置。
|
||||
*
|
||||
* 入口页和补资料页都是可被直接访问的公开路径,必须禁止搜索引擎收录,
|
||||
* 并避免平台订单号通过 referrer 泄漏给第三方。
|
||||
*/
|
||||
export function useCollectLinkPageMeta(title: string) {
|
||||
/** 公开订单服务页不收录,且不向第三方泄漏订单号 Referer。 */
|
||||
export function useOrderServicePageMeta(title: string) {
|
||||
useEffect(() => {
|
||||
const previousTitle = document.title
|
||||
document.title = title
|
||||
@@ -28,18 +23,14 @@ export function useCollectLinkPageMeta(title: string) {
|
||||
if (created) {
|
||||
created.remove()
|
||||
} else if (existing) {
|
||||
if (previous === null) {
|
||||
existing.removeAttribute('content')
|
||||
} else {
|
||||
existing.setAttribute('content', previous)
|
||||
}
|
||||
if (previous === null) existing.removeAttribute('content')
|
||||
else existing.setAttribute('content', previous)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const restoreRobots = applyMeta('robots', 'noindex,nofollow')
|
||||
const restoreReferrer = applyMeta('referrer', 'no-referrer')
|
||||
|
||||
return () => {
|
||||
document.title = previousTitle
|
||||
restoreRobots()
|
||||
@@ -1108,3 +1108,129 @@ export function revokeAdminWorkerRiskBan(banId: string, reason?: string) {
|
||||
{ reason },
|
||||
)
|
||||
}
|
||||
|
||||
export type OrderServiceField = {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
|
||||
required: boolean
|
||||
placeholder?: string
|
||||
helpText?: string
|
||||
options?: string[]
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export type AdminOrderServiceProfile = {
|
||||
id: number
|
||||
categoryId: number
|
||||
name: string
|
||||
title: string
|
||||
description: string
|
||||
guideImage: UploadedFile | Record<string, unknown>
|
||||
fields: OrderServiceField[]
|
||||
screenshots: {
|
||||
enabled: boolean
|
||||
required: boolean
|
||||
minCount: number
|
||||
maxCount: number
|
||||
label: string
|
||||
helpText?: string
|
||||
}
|
||||
autoPublish: {
|
||||
enabled: boolean
|
||||
requireVoucherConsume: boolean
|
||||
retryMax: number
|
||||
}
|
||||
submitText: string
|
||||
successText: string
|
||||
status: 'draft' | 'published' | 'archived' | string
|
||||
currentVersionNo: number | null
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
export type AdminOrderServiceCategory = {
|
||||
categoryId: number
|
||||
categoryKey: string
|
||||
name: string
|
||||
parentId?: number | null
|
||||
status: string
|
||||
}
|
||||
|
||||
export type AdminOrderServiceProfileList = {
|
||||
categories: AdminOrderServiceCategory[]
|
||||
profiles: AdminOrderServiceProfile[]
|
||||
}
|
||||
|
||||
export function fetchAdminOrderServiceProfiles() {
|
||||
return apiGet<AdminOrderServiceProfileList>(
|
||||
'/api/v1/admin/worker-platform/order-service/profiles',
|
||||
)
|
||||
}
|
||||
|
||||
export function saveAdminOrderServiceProfile(payload: {
|
||||
categoryId: number
|
||||
name: string
|
||||
title?: string
|
||||
description?: string
|
||||
guideImage?: UploadedFile | Record<string, unknown>
|
||||
fields: OrderServiceField[]
|
||||
screenshots: {
|
||||
enabled: boolean
|
||||
required: boolean
|
||||
minCount: number
|
||||
maxCount: number
|
||||
label: string
|
||||
helpText?: string
|
||||
}
|
||||
autoPublish: {
|
||||
enabled: boolean
|
||||
requireVoucherConsume: boolean
|
||||
retryMax: number
|
||||
}
|
||||
submitText?: string
|
||||
successText?: string
|
||||
}) {
|
||||
return apiPost<{ profile: AdminOrderServiceProfile }>(
|
||||
'/api/v1/admin/worker-platform/order-service/profiles',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function publishAdminOrderServiceProfile(profileId: number) {
|
||||
return apiPost<{ versionNo: number }>(
|
||||
`/api/v1/admin/worker-platform/order-service/profiles/${profileId}/publish`,
|
||||
)
|
||||
}
|
||||
|
||||
export function archiveAdminOrderServiceProfile(profileId: number) {
|
||||
return apiPost<{ profile: AdminOrderServiceProfile }>(
|
||||
`/api/v1/admin/worker-platform/order-service/profiles/${profileId}/archive`,
|
||||
)
|
||||
}
|
||||
|
||||
export type AdminOrderServiceAutoPublishJob = {
|
||||
id: number
|
||||
workOrderId: number
|
||||
status: 'pending' | 'processing' | 'succeeded' | 'failed' | 'skipped' | string
|
||||
attemptCount: number
|
||||
maxAttempts: number
|
||||
nextRunAt: string | null
|
||||
lastError: string
|
||||
productName: string
|
||||
platformOrderId: string
|
||||
workOrderStatus: string
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
export function fetchAdminOrderServiceAutoPublishJobs(status = 'failed') {
|
||||
return apiGet<{ items: AdminOrderServiceAutoPublishJob[] }>(
|
||||
'/api/v1/admin/worker-platform/order-service/auto-publish-jobs',
|
||||
{ status },
|
||||
)
|
||||
}
|
||||
|
||||
export function retryAdminOrderServiceAutoPublishJob(jobId: number) {
|
||||
return apiPost<{ jobId: number }>(
|
||||
`/api/v1/admin/worker-platform/order-service/auto-publish-jobs/${jobId}/retry`,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -5,44 +5,18 @@ import { optimizeImageForUpload } from '@/utils/image-upload'
|
||||
type UploadScope = 'admin' | 'worker' | 'collect'
|
||||
|
||||
export async function uploadFile(file: File, scene: string, scope: UploadScope) {
|
||||
if (scope === 'collect') {
|
||||
throw new Error('订单服务页面必须使用订单号上传入口')
|
||||
}
|
||||
const uploadTarget = await optimizeImageForUpload(file, scene)
|
||||
const form = new FormData()
|
||||
form.append('file', uploadTarget)
|
||||
form.append('scene', scene)
|
||||
const endpoint =
|
||||
scope === 'admin'
|
||||
? '/api/v1/admin/files/upload'
|
||||
: scope === 'collect'
|
||||
? '/api/v1/collect/upload'
|
||||
: '/api/v1/worker/files/upload'
|
||||
const endpoint = scope === 'admin' ? '/api/v1/admin/files/upload' : '/api/v1/worker/files/upload'
|
||||
const response = await apiPostForm<{ file: UploadedFile }>(endpoint, form)
|
||||
return response.data.file
|
||||
}
|
||||
|
||||
/**
|
||||
* 一次性补资料链接的截图上传。
|
||||
*
|
||||
* 与公开 collect/upload 的区别:路径里带平台订单号,多工单时再带 workOrderId;
|
||||
* 后端会校验对应链接是否仍可用,链接已使用/过期时不会落文件。
|
||||
*/
|
||||
export async function uploadCollectMaterialLinkFile(
|
||||
file: File,
|
||||
scene: string,
|
||||
code: string,
|
||||
workOrderId?: number,
|
||||
) {
|
||||
const uploadTarget = await optimizeImageForUpload(file, scene)
|
||||
const form = new FormData()
|
||||
form.append('file', uploadTarget)
|
||||
form.append('scene', scene)
|
||||
const query = workOrderId ? `?workOrderId=${encodeURIComponent(String(workOrderId))}` : ''
|
||||
const response = await apiPostForm<{ file: UploadedFile }>(
|
||||
`/api/v1/collect/link/${encodeURIComponent(code)}/upload${query}`,
|
||||
form,
|
||||
)
|
||||
return response.data.file
|
||||
}
|
||||
|
||||
/**
|
||||
* 验收视频上传:视频已在浏览器内压好,这里不再做图片压缩。
|
||||
* 带上 durationSeconds 供服务端在容器解析不到时长时兜底(见 resolveVideoDuration)。
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import { apiDelete, apiGet, apiPost, apiPut } from '@/lib/http'
|
||||
import { apiDelete, apiGet, apiPost, apiPostForm, apiPut } from '@/lib/http'
|
||||
import { optimizeImageForUpload } from '@/utils/image-upload'
|
||||
import type {
|
||||
CollectLookupResponse,
|
||||
CollectMaterialLinkInfo,
|
||||
CollectMaterialLinkSubmitResult,
|
||||
UploadedFile,
|
||||
WorkOrder,
|
||||
WorkOrderEvent,
|
||||
@@ -372,40 +370,6 @@ export function supplementWorkerAcceptedOrderEvidence(
|
||||
)
|
||||
}
|
||||
|
||||
export function lookupCollectOrder(orderNo: string) {
|
||||
return apiPost<CollectLookupResponse>('/api/v1/collect/lookup', { orderNo })
|
||||
}
|
||||
|
||||
export function submitCollectOrder(payload: {
|
||||
orderNo: string
|
||||
workOrderId?: number
|
||||
fields: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
}) {
|
||||
return apiPost<{ complete: boolean; order: Record<string, unknown> }>(
|
||||
'/api/v1/collect/submit',
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export function fetchCollectMaterialLink(code: string) {
|
||||
return apiGet<CollectMaterialLinkInfo>(`/api/v1/collect/link/${encodeURIComponent(code)}`)
|
||||
}
|
||||
|
||||
export function submitCollectMaterialLink(
|
||||
code: string,
|
||||
payload: {
|
||||
workOrderId?: number
|
||||
fields: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
},
|
||||
) {
|
||||
return apiPost<CollectMaterialLinkSubmitResult>(
|
||||
`/api/v1/collect/link/${encodeURIComponent(code)}/submit`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------- 打手端:我要寄卖与手枪库存 ----------------
|
||||
|
||||
export type WorkerConsignmentPublishPayload = {
|
||||
@@ -591,3 +555,91 @@ export function fetchWorkerConsignmentPriceSuggestion(params: {
|
||||
matchedRules: { ruleKey: string; productName: string; rewardAmountFen: number }[]
|
||||
}>('/api/v1/worker/consignment/price-suggestion', params)
|
||||
}
|
||||
|
||||
export type OrderServiceField = {
|
||||
key: string
|
||||
label: string
|
||||
type: 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
|
||||
required: boolean
|
||||
placeholder?: string
|
||||
helpText?: string
|
||||
options?: string[]
|
||||
sortOrder?: number
|
||||
}
|
||||
|
||||
export type OrderServiceScreenshots = {
|
||||
enabled: boolean
|
||||
required: boolean
|
||||
minCount: number
|
||||
maxCount: number
|
||||
label: string
|
||||
helpText?: string
|
||||
}
|
||||
|
||||
export type OrderServiceItem = {
|
||||
workOrderId: number
|
||||
productName: string
|
||||
categoryName: string
|
||||
title: string
|
||||
description: string
|
||||
submitText: string
|
||||
successText: string
|
||||
state: string
|
||||
stateLabel: string
|
||||
message: string
|
||||
canSubmit: boolean
|
||||
blockedReason: '' | 'already_submitted' | 'status' | 'assigned' | 'shares'
|
||||
fields?: OrderServiceField[]
|
||||
values?: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
screenshotsRule?: OrderServiceScreenshots
|
||||
guideImage?: UploadedFile | Record<string, unknown>
|
||||
}
|
||||
|
||||
export type OrderServiceInfo = {
|
||||
platformOrderId: string
|
||||
items: OrderServiceItem[]
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
export type OrderServiceSubmitResult = {
|
||||
complete: boolean
|
||||
workOrderId: number
|
||||
successText: string
|
||||
order: { platformOrderId: string; productName: string; status: string }
|
||||
}
|
||||
|
||||
export function fetchOrderService(orderNo: string) {
|
||||
return apiGet<OrderServiceInfo>(`/api/v1/o/${encodeURIComponent(orderNo)}`)
|
||||
}
|
||||
|
||||
export function submitOrderServiceMaterial(
|
||||
orderNo: string,
|
||||
payload: {
|
||||
workOrderId?: number
|
||||
fields: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
},
|
||||
) {
|
||||
return apiPost<OrderServiceSubmitResult>(
|
||||
`/api/v1/o/${encodeURIComponent(orderNo)}/material`,
|
||||
payload,
|
||||
)
|
||||
}
|
||||
|
||||
export async function uploadOrderServiceFile(
|
||||
file: File,
|
||||
scene: string,
|
||||
orderNo: string,
|
||||
workOrderId?: number,
|
||||
) {
|
||||
const optimized = await optimizeImageForUpload(file, scene)
|
||||
const form = new FormData()
|
||||
form.append('file', optimized)
|
||||
form.append('scene', scene)
|
||||
const query = workOrderId ? `?workOrderId=${encodeURIComponent(String(workOrderId))}` : ''
|
||||
return apiPostForm<{ file: UploadedFile }>(
|
||||
`/api/v1/o/${encodeURIComponent(orderNo)}/files${query}`,
|
||||
form,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -508,6 +508,13 @@
|
||||
margin: 0 0 4px !important;
|
||||
}
|
||||
|
||||
.collect-link-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
/* 商品名与订单号放在同一个信息头里,避免标题和灰色信息块分离 */
|
||||
.collect-link-order-head {
|
||||
display: flex;
|
||||
|
||||
@@ -884,68 +884,6 @@ export type CollectField = {
|
||||
options?: string[]
|
||||
}
|
||||
|
||||
export type CollectLookupOrder = {
|
||||
workOrderId: number
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
status: string
|
||||
fields: CollectField[]
|
||||
values?: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
}
|
||||
|
||||
export type CollectLookupResponse = {
|
||||
order: Pick<
|
||||
WorkOrder,
|
||||
'workOrderId' | 'workOrderNo' | 'platformOrderId' | 'productName' | 'status'
|
||||
>
|
||||
fields: CollectField[]
|
||||
orders: CollectLookupOrder[]
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
export type CollectMaterialLinkBlockedReason =
|
||||
| 'already_submitted'
|
||||
| 'status'
|
||||
| 'assigned'
|
||||
| 'shares'
|
||||
| ''
|
||||
|
||||
/** 公开补资料页的单个工单;只有 canSubmit=true 时才有字段/已填值/截图。 */
|
||||
export type CollectMaterialLinkOrder = {
|
||||
workOrderId: number
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
status: string
|
||||
statusLabel: string
|
||||
canSubmit: boolean
|
||||
blockedReason: CollectMaterialLinkBlockedReason
|
||||
message: string
|
||||
fields?: CollectField[]
|
||||
values?: Record<string, string>
|
||||
screenshots?: UploadedFile[]
|
||||
}
|
||||
|
||||
/** 按平台订单号读取到的补资料信息;一单多工单时 orders 会有多条。 */
|
||||
export type CollectMaterialLinkInfo = {
|
||||
platformOrderId: string
|
||||
orders: CollectMaterialLinkOrder[]
|
||||
pendingCount: number
|
||||
}
|
||||
|
||||
export type CollectMaterialLinkSubmitResult = {
|
||||
complete: boolean
|
||||
workOrderId: number
|
||||
order: {
|
||||
workOrderNo: string
|
||||
platformOrderId: string
|
||||
productName: string
|
||||
status: string
|
||||
}
|
||||
}
|
||||
|
||||
/** 大厅风控阈值(后台可调,保存即热生效) */
|
||||
export type WorkerRiskConfig = {
|
||||
enabled: boolean
|
||||
|
||||
@@ -22,6 +22,7 @@ export const WORKER_PLATFORM_TAB_KEYS: readonly string[] = [
|
||||
'after-sales',
|
||||
'announcement',
|
||||
'rules',
|
||||
'order-service',
|
||||
'product-match',
|
||||
'categories',
|
||||
'workers',
|
||||
@@ -49,6 +50,11 @@ export const WORKER_PLATFORM_TAB_QUERY_KEYS: Record<string, string[][]> = {
|
||||
'after-sales': [['admin-after-sales-orders'], ['admin-after-sales-stats']],
|
||||
feedbacks: [['admin-feedbacks']],
|
||||
rules: [['admin-product-rules']],
|
||||
'order-service': [
|
||||
['admin-order-service-profiles'],
|
||||
['admin-order-service-auto-publish-jobs'],
|
||||
['admin-categories'],
|
||||
],
|
||||
'product-match': [['admin-product-match']],
|
||||
categories: [['admin-categories']],
|
||||
workers: [['admin-workers'], ['admin-worker-users']],
|
||||
|
||||
@@ -1,68 +0,0 @@
|
||||
/**
|
||||
* 一次性补资料链接的路径解析。
|
||||
*
|
||||
* 公开链接有两种形态:
|
||||
* - `https://<域名>/supplement/`:输入平台订单号;
|
||||
* - `https://<域名>/supplement/<平台订单号>`:直接打开对应订单的补资料页。
|
||||
* 与后台 HashRouter 完全隔离。
|
||||
*/
|
||||
|
||||
export function isCollectMaterialLinkPath(pathname: unknown): boolean {
|
||||
return splitPathSegments(pathname)[0] === 'supplement'
|
||||
}
|
||||
|
||||
/**
|
||||
* 只认 `/supplement/<一段 code>`:
|
||||
* - `/supplement/abc` 命中,结尾多余斜杠容忍;
|
||||
* - `/supplement`、`/supplement/a/b` 不命中(由入口页/页面自己展示无效)。
|
||||
*/
|
||||
export function resolveCollectMaterialLinkCode(pathname: unknown): string {
|
||||
const segments = splitPathSegments(pathname)
|
||||
if (segments.length !== 2 || segments[0] !== 'supplement') return ''
|
||||
return decodePathSegment(segments[1])
|
||||
}
|
||||
|
||||
/** 统一拼补资料链接;origin 结尾多余斜杠会被去掉。 */
|
||||
export function buildCollectMaterialLinkUrl(origin: unknown, code: unknown): string {
|
||||
const base = String(origin ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
const normalized = String(code ?? '').trim()
|
||||
if (!base || !normalized) return ''
|
||||
return `${base}/supplement/${encodeURIComponent(normalized)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 入口页输入归一化:去空格;如果用户粘贴了完整 `/supplement/<订单号>` 链接,
|
||||
* 自动提取最后一段,避免把整条 URL 当成订单号。
|
||||
*/
|
||||
export function normalizeOrderNoInput(value: unknown): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
|
||||
const marker = '/supplement/'
|
||||
const index = raw.indexOf(marker)
|
||||
if (index < 0) return raw
|
||||
|
||||
const segment = raw
|
||||
.slice(index + marker.length)
|
||||
.split(/[?#]/)[0]
|
||||
.replace(/\/+$/, '')
|
||||
.trim()
|
||||
return decodePathSegment(segment)
|
||||
}
|
||||
|
||||
function splitPathSegments(pathname: unknown): string[] {
|
||||
const normalized = String(pathname ?? '').trim()
|
||||
if (!normalized.startsWith('/')) return []
|
||||
|
||||
return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function decodePathSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
/** 客户订单服务页路径解析:/o 与 /o/:orderNo。 */
|
||||
export function isOrderServicePath(pathname: unknown): boolean {
|
||||
const segments = splitPathSegments(pathname)
|
||||
return segments[0] === 'o'
|
||||
}
|
||||
|
||||
export function isLegacySupplementPath(pathname: unknown): boolean {
|
||||
return splitPathSegments(pathname)[0] === 'supplement'
|
||||
}
|
||||
|
||||
export function resolveLegacySupplementOrderNo(pathname: unknown): string {
|
||||
const segments = splitPathSegments(pathname)
|
||||
if (segments.length !== 2 || segments[0] !== 'supplement') return ''
|
||||
return decodePathSegment(segments[1])
|
||||
}
|
||||
|
||||
export function resolveOrderServiceOrderNo(pathname: unknown): string {
|
||||
const segments = splitPathSegments(pathname)
|
||||
if (segments.length !== 2 || segments[0] !== 'o') return ''
|
||||
return decodePathSegment(segments[1])
|
||||
}
|
||||
|
||||
export function buildOrderServiceUrl(origin: unknown, orderNo: unknown): string {
|
||||
const base = String(origin ?? '')
|
||||
.trim()
|
||||
.replace(/\/+$/, '')
|
||||
const order = String(orderNo ?? '').trim()
|
||||
if (!base || !order) return ''
|
||||
return `${base}/o/${encodeURIComponent(order)}`
|
||||
}
|
||||
|
||||
export function normalizeOrderServiceInput(value: unknown): string {
|
||||
const raw = String(value ?? '').trim()
|
||||
if (!raw) return ''
|
||||
for (const marker of ['/o/', '/supplement/']) {
|
||||
const index = raw.indexOf(marker)
|
||||
if (index < 0) continue
|
||||
return decodePathSegment(
|
||||
raw
|
||||
.slice(index + marker.length)
|
||||
.split(/[?#]/)[0]
|
||||
.replace(/\/+$/, '')
|
||||
.trim(),
|
||||
)
|
||||
}
|
||||
return raw
|
||||
}
|
||||
|
||||
function splitPathSegments(pathname: unknown): string[] {
|
||||
const normalized = String(pathname ?? '').trim()
|
||||
if (!normalized.startsWith('/')) return []
|
||||
return normalized.split(/[?#]/)[0].replace(/\/+$/, '').split('/').filter(Boolean)
|
||||
}
|
||||
|
||||
function decodePathSegment(segment: string): string {
|
||||
try {
|
||||
return decodeURIComponent(segment)
|
||||
} catch {
|
||||
return segment
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user