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
@@ -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]
}