feat(order-service): 重构分类资料收集与订单状态链路

This commit is contained in:
yml2213
2026-11-03 19:46:49 +08:00
parent 6e4758a3c7
commit 9f14e8de7a
59 changed files with 4866 additions and 1734 deletions
+46 -36
View File
@@ -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()]
}),
)
}
@@ -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`,
)
}
+4 -30
View File
@@ -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)。
+90 -38
View File
@@ -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,
)
}
+7
View File
@@ -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']],
-68
View File
@@ -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
}
}