807 lines
30 KiB
TypeScript
807 lines
30 KiB
TypeScript
import {
|
||
ArrowDownOutlined,
|
||
ArrowUpOutlined,
|
||
DeleteOutlined,
|
||
PlusOutlined,
|
||
ReloadOutlined,
|
||
SaveOutlined,
|
||
SearchOutlined,
|
||
} from '@ant-design/icons'
|
||
import {
|
||
Alert,
|
||
App,
|
||
Badge,
|
||
Button,
|
||
Card,
|
||
Col,
|
||
Collapse,
|
||
Empty,
|
||
Form,
|
||
Input,
|
||
InputNumber,
|
||
Row,
|
||
Select,
|
||
Space,
|
||
Switch,
|
||
Tag,
|
||
Typography,
|
||
} from 'antd'
|
||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||
import { useCallback, 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 [categoryKeyword, setCategoryKeyword] = useState('')
|
||
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 profileByCategoryId = useMemo(
|
||
() => new Map(profiles.map((item) => [item.categoryId, item])),
|
||
[profiles],
|
||
)
|
||
const normalizedKeyword = categoryKeyword.trim().toLowerCase()
|
||
const filteredCategories = useMemo(
|
||
() =>
|
||
categories.filter((item) => {
|
||
if (!normalizedKeyword) return true
|
||
return (
|
||
item.name.toLowerCase().includes(normalizedKeyword) ||
|
||
item.categoryKey.toLowerCase().includes(normalizedKeyword)
|
||
)
|
||
}),
|
||
[categories, normalizedKeyword],
|
||
)
|
||
const configuredCount = categories.filter((item) =>
|
||
profileByCategoryId.has(item.categoryId),
|
||
).length
|
||
const selectedCategory = categories.find((item) => item.categoryId === selectedCategoryId) || null
|
||
const profile = (selectedCategoryId ? profileByCategoryId.get(selectedCategoryId) : null) || null
|
||
const guideFiles = useMemo(() => (guideImage ? [guideImage as UploadedFile] : []), [guideImage])
|
||
const handleGuideImageChange = useCallback((files: UploadedFile[]) => {
|
||
setGuideImage(files[0] || null)
|
||
}, [])
|
||
const uploadGuideImage = useCallback(
|
||
(file: File, scene: string) => uploadFile(file, scene, 'admin'),
|
||
[],
|
||
)
|
||
|
||
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 jobColumns = useMemo(
|
||
() => [
|
||
{
|
||
key: 'order',
|
||
title: '订单',
|
||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => (
|
||
<Space direction="vertical" size={0}>
|
||
<Text strong>{row.productName || '-'}</Text>
|
||
<Text type="secondary">{row.platformOrderId || '-'}</Text>
|
||
</Space>
|
||
),
|
||
},
|
||
{
|
||
key: 'attempts',
|
||
title: '重试',
|
||
width: 90,
|
||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) =>
|
||
`${row.attemptCount}/${row.maxAttempts}`,
|
||
},
|
||
{
|
||
key: 'error',
|
||
title: '失败原因',
|
||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => (
|
||
<Text type="danger">{row.lastError || '未知错误'}</Text>
|
||
),
|
||
},
|
||
{
|
||
key: 'updatedAt',
|
||
title: '更新时间',
|
||
width: 180,
|
||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) =>
|
||
row.updatedAt ? formatAdminDateTime(row.updatedAt) : '-',
|
||
},
|
||
{
|
||
key: 'actions',
|
||
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 order-service-page">
|
||
<Card
|
||
className="order-service-shell"
|
||
title={
|
||
<Space size={8}>
|
||
<span>订单服务配置</span>
|
||
<Tag color="blue">{configuredCount} 个分类已配置</Tag>
|
||
</Space>
|
||
}
|
||
extra={
|
||
<Button
|
||
icon={<ReloadOutlined />}
|
||
loading={profilesQuery.isFetching || jobsQuery.isFetching}
|
||
onClick={() => void refresh()}
|
||
>
|
||
刷新
|
||
</Button>
|
||
}
|
||
>
|
||
<div className="order-service-layout">
|
||
<aside className="order-service-sidebar">
|
||
<div className="order-service-sidebar-head">
|
||
<Space size={6}>
|
||
<Text strong>接单分类</Text>
|
||
<Text type="secondary">
|
||
{filteredCategories.length}/{categories.length}
|
||
</Text>
|
||
</Space>
|
||
<Input
|
||
allowClear
|
||
prefix={<SearchOutlined />}
|
||
placeholder="搜索分类"
|
||
size="small"
|
||
value={categoryKeyword}
|
||
onChange={(event) => setCategoryKeyword(event.target.value)}
|
||
/>
|
||
</div>
|
||
<div className="order-service-category-list">
|
||
{filteredCategories.map((category) => {
|
||
const rowProfile = profileByCategoryId.get(category.categoryId)
|
||
const status = resolveProfileStatus(rowProfile)
|
||
return (
|
||
<button
|
||
key={category.categoryId}
|
||
type="button"
|
||
className={`order-service-category-item${
|
||
category.categoryId === selectedCategoryId ? ' is-active' : ''
|
||
}`}
|
||
onClick={() => setSelectedCategoryId(category.categoryId)}
|
||
>
|
||
<span className="order-service-category-main">
|
||
<span className="order-service-category-name">{category.name}</span>
|
||
<span className={`order-service-status-pill is-${status.tone}`}>
|
||
{status.label}
|
||
</span>
|
||
</span>
|
||
<span className="order-service-category-meta">
|
||
{category.categoryKey}
|
||
{category.status === 'active' ? '' : ' · 已停用'}
|
||
</span>
|
||
</button>
|
||
)
|
||
})}
|
||
{filteredCategories.length === 0 ? (
|
||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="没有匹配分类" />
|
||
) : null}
|
||
</div>
|
||
</aside>
|
||
|
||
<section className="order-service-editor">
|
||
{selectedCategory ? (
|
||
<>
|
||
<div className="order-service-editor-head">
|
||
<div>
|
||
<Space size={8} wrap>
|
||
<Title level={5}>{selectedCategory.name}</Title>
|
||
<Tag color={selectedCategory.status === 'active' ? 'green' : 'default'}>
|
||
{selectedCategory.status === 'active' ? '启用' : '停用'}
|
||
</Tag>
|
||
<Tag color={profile?.status === 'published' ? 'green' : 'orange'}>
|
||
{profile?.status === 'published'
|
||
? `线上 v${profile.currentVersionNo || 1}`
|
||
: profile?.currentVersionNo
|
||
? `线上 v${profile.currentVersionNo} · 有草稿`
|
||
: '尚未发布'}
|
||
</Tag>
|
||
</Space>
|
||
<Text type="secondary">分类标识:{selectedCategory.categoryKey}</Text>
|
||
</div>
|
||
<Space size={8} wrap>
|
||
<Button loading={publishing} onClick={() => void handlePublish()}>
|
||
发布新版本
|
||
</Button>
|
||
{profile ? (
|
||
<Button danger loading={publishing} onClick={() => void handleArchive()}>
|
||
归档
|
||
</Button>
|
||
) : null}
|
||
</Space>
|
||
</div>
|
||
|
||
<Alert
|
||
className="order-service-tip"
|
||
type="info"
|
||
showIcon
|
||
message="一个分类复用一套资料配置,接单模板通过“所属分类”自动关联。"
|
||
/>
|
||
|
||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||
<section className="order-service-section">
|
||
<div className="order-service-section-head">
|
||
<div>
|
||
<Text strong>基础信息</Text>
|
||
<Text type="secondary">客户打开 `/o` 后看到的标题与提示。</Text>
|
||
</div>
|
||
</div>
|
||
<div className="order-service-basic-grid">
|
||
<div>
|
||
<Row gutter={[12, 0]}>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="配置名称" name="name" rules={[{ required: true }]}>
|
||
<Input placeholder="后台识别名称" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="客户页标题" name="title">
|
||
<Input placeholder="例如:蛋仔派对资料填写" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Item label="页面说明" name="description">
|
||
<Input.TextArea
|
||
autoSize={{ minRows: 1, maxRows: 3 }}
|
||
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>
|
||
</div>
|
||
<div className="order-service-guide">
|
||
<Text strong>用户引导图</Text>
|
||
<Text type="secondary">首版一张,展示在字段上方</Text>
|
||
<ImageUpload
|
||
scene="order-service"
|
||
scope="admin"
|
||
maxCount={1}
|
||
value={guideFiles}
|
||
onChange={handleGuideImageChange}
|
||
uploader={uploadGuideImage}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="order-service-section">
|
||
<div className="order-service-section-head">
|
||
<div>
|
||
<Text strong>资料字段</Text>
|
||
<Text type="secondary">客户必须填写或选填的资料,可调整顺序。</Text>
|
||
</div>
|
||
<Button icon={<PlusOutlined />} onClick={addField} type="dashed">
|
||
添加字段
|
||
</Button>
|
||
</div>
|
||
|
||
{fields.length === 0 ? (
|
||
<Empty
|
||
image={Empty.PRESENTED_IMAGE_SIMPLE}
|
||
description="至少添加一个资料字段后才能发布"
|
||
/>
|
||
) : (
|
||
<div className="order-service-field-list">
|
||
{fields.map((field, index) => (
|
||
<div className="order-service-field-row" key={`${field.key}-${index}`}>
|
||
<div className="order-service-field-main">
|
||
<label>
|
||
<Text type="secondary">字段 Key</Text>
|
||
<Input
|
||
value={field.key}
|
||
onChange={(event) =>
|
||
updateField(index, { key: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<Text type="secondary">显示名称</Text>
|
||
<Input
|
||
value={field.label}
|
||
onChange={(event) =>
|
||
updateField(index, { label: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
<label>
|
||
<Text type="secondary">类型</Text>
|
||
<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: '日期' },
|
||
]}
|
||
/>
|
||
</label>
|
||
<label className="order-service-field-required">
|
||
<Text type="secondary">必填</Text>
|
||
<Switch
|
||
checked={field.required}
|
||
onChange={(value) => updateField(index, { required: value })}
|
||
/>
|
||
</label>
|
||
<div className="order-service-field-actions">
|
||
<Button
|
||
disabled={index === 0}
|
||
icon={<ArrowUpOutlined />}
|
||
size="small"
|
||
onClick={() => moveField(index, -1)}
|
||
/>
|
||
<Button
|
||
disabled={index === fields.length - 1}
|
||
icon={<ArrowDownOutlined />}
|
||
size="small"
|
||
onClick={() => moveField(index, 1)}
|
||
/>
|
||
<Button
|
||
danger
|
||
icon={<DeleteOutlined />}
|
||
size="small"
|
||
onClick={() => removeField(index)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div className="order-service-field-extra">
|
||
<Input
|
||
addonBefore="占位提示"
|
||
value={field.placeholder || ''}
|
||
onChange={(event) =>
|
||
updateField(index, { placeholder: event.target.value })
|
||
}
|
||
/>
|
||
<Input
|
||
addonBefore="填写说明"
|
||
value={field.helpText || ''}
|
||
onChange={(event) =>
|
||
updateField(index, { helpText: event.target.value })
|
||
}
|
||
/>
|
||
{field.type === 'select' || field.type === 'radio' ? (
|
||
<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),
|
||
})
|
||
}
|
||
/>
|
||
) : null}
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
)}
|
||
</section>
|
||
|
||
<Row gutter={[16, 16]}>
|
||
<Col xs={24} xl={12}>
|
||
<section className="order-service-section order-service-section-fill">
|
||
<div className="order-service-section-head">
|
||
<div>
|
||
<Text strong>截图规则</Text>
|
||
<Text type="secondary">控制客户上传买家主页截图的限制。</Text>
|
||
</div>
|
||
</div>
|
||
<Row gutter={[12, 0]}>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item name={['screenshots', 'label']} label="截图标题">
|
||
<Input />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item name={['screenshots', 'helpText']} label="截图说明">
|
||
<Input />
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
<div className="order-service-switch-grid">
|
||
<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="最少张数">
|
||
<InputNumber min={0} max={10} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
<Form.Item name={['screenshots', 'maxCount']} label="最多张数">
|
||
<InputNumber min={1} max={10} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</div>
|
||
</section>
|
||
</Col>
|
||
<Col xs={24} xl={12}>
|
||
<section className="order-service-section order-service-section-fill">
|
||
<div className="order-service-section-head">
|
||
<div>
|
||
<Text strong>自动发布并核销</Text>
|
||
<Text type="secondary">客户提交后按工单快照自动处理。</Text>
|
||
</div>
|
||
</div>
|
||
<div className="order-service-switch-grid is-auto-publish">
|
||
<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="额外重试次数">
|
||
<InputNumber min={0} max={10} style={{ width: '100%' }} />
|
||
</Form.Item>
|
||
</div>
|
||
<Text type="secondary" className="order-service-inline-tip">
|
||
要求核销时,核销失败不会发布到大厅,会进入失败任务。
|
||
</Text>
|
||
</section>
|
||
</Col>
|
||
</Row>
|
||
|
||
<div className="order-service-save-bar">
|
||
<Text type="secondary">保存只更新草稿,发布后才会影响新工单。</Text>
|
||
<Space>
|
||
<Button
|
||
htmlType="button"
|
||
onClick={() => {
|
||
form.resetFields()
|
||
applyProfile(profile)
|
||
}}
|
||
>
|
||
重置
|
||
</Button>
|
||
<Button
|
||
htmlType="submit"
|
||
icon={<SaveOutlined />}
|
||
loading={saving}
|
||
type="primary"
|
||
>
|
||
保存草稿
|
||
</Button>
|
||
</Space>
|
||
</div>
|
||
</Form>
|
||
</>
|
||
) : (
|
||
<Empty description="请选择分类" />
|
||
)}
|
||
</section>
|
||
</div>
|
||
</Card>
|
||
|
||
<Collapse
|
||
ghost
|
||
className="order-service-jobs-collapse"
|
||
items={[
|
||
{
|
||
key: 'failed-jobs',
|
||
label: (
|
||
<Space size={8}>
|
||
<Text strong>自动发布失败任务</Text>
|
||
<Badge
|
||
count={failedJobs.length}
|
||
showZero
|
||
color={failedJobs.length > 0 ? 'red' : 'default'}
|
||
/>
|
||
</Space>
|
||
),
|
||
children:
|
||
failedJobs.length > 0 ? (
|
||
<PercentTable
|
||
rowKey="id"
|
||
size="small"
|
||
loading={jobsQuery.isLoading}
|
||
dataSource={failedJobs}
|
||
columns={jobColumns}
|
||
pagination={false}
|
||
/>
|
||
) : (
|
||
<Empty image={Empty.PRESENTED_IMAGE_SIMPLE} description="暂无失败任务" />
|
||
),
|
||
},
|
||
]}
|
||
/>
|
||
</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]
|
||
}
|
||
|
||
function resolveProfileStatus(profile: AdminOrderServiceProfile | undefined) {
|
||
if (!profile) return { label: '未配置', tone: 'muted' }
|
||
if (profile.status === 'published') {
|
||
return { label: `v${profile.currentVersionNo || 1}`, tone: 'success' }
|
||
}
|
||
if (profile.status === 'draft') {
|
||
return {
|
||
label: profile.currentVersionNo ? `草稿 · 线上 v${profile.currentVersionNo}` : '草稿',
|
||
tone: 'warning',
|
||
}
|
||
}
|
||
return { label: '已归档', tone: 'muted' }
|
||
}
|