Files
order_site/apps/frontend/src/pages/admin/panels/OrderServicePanel.tsx
T

1420 lines
56 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import {
ArrowDownOutlined,
ArrowUpOutlined,
DeleteOutlined,
DownOutlined,
PlusOutlined,
ReloadOutlined,
RightOutlined,
SaveOutlined,
SearchOutlined,
} from '@ant-design/icons'
import {
Alert,
App,
Badge,
Button,
Card,
Col,
Collapse,
Dropdown,
Empty,
Form,
Input,
InputNumber,
Row,
Select,
Space,
Switch,
Tag,
Typography,
type MenuProps,
} 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 AdminOrderServiceCategory,
type AdminOrderServiceProfile,
type OrderServiceField,
type OrderServiceMaterialConfirmation,
type OrderServiceStatusSteps,
} 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
guideImageDescription: string
submitText: string
successText: string
postSubmitText: string
screenshots: ScreenshotForm
autoPublish: AutoPublishForm
materialConfirmation: OrderServiceMaterialConfirmation
statusSteps: OrderServiceStatusSteps
}
type OrderServiceCategoryNode = {
category: AdminOrderServiceCategory
children: AdminOrderServiceCategory[]
}
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,
}
const DEFAULT_MATERIAL_CONFIRMATION: OrderServiceMaterialConfirmation = {
enabled: true,
title: '提交资料确认',
content:
'请确认资料填写准确无误。资料提交后不能修改,因个人资料填写错误导致的赠送错误,店铺概不负责!',
confirmText: '确认提交',
cancelText: '返回检查',
}
const DEFAULT_POST_SUBMIT_TEXT = '资料提交后如需修改,请联系客服。'
const DEFAULT_STATUS_STEPS: OrderServiceStatusSteps = {
submitted: { title: '提交资料', description: '' },
scheduled: { title: '客服安排', description: '' },
processing: { title: '订单处理', description: '' },
completed: { title: '交付完成', description: '' },
}
const STATUS_STEP_OPTIONS: Array<{
key: keyof OrderServiceStatusSteps
label: string
}> = [
{ key: 'submitted', label: '提交资料' },
{ key: 'scheduled', label: '客服安排' },
{ key: 'processing', label: '订单处理' },
{ key: 'completed', label: '交付完成' },
]
type FieldPreset = Omit<OrderServiceField, 'sortOrder'>
const FIELD_PRESETS: Record<string, FieldPreset> = {
gameId: {
key: 'gameId',
label: '游戏 ID',
type: 'text',
validation: 'digits',
required: true,
placeholder: '请输入纯数字游戏 ID',
helpText: '仅支持数字,不会丢失前导零或超长 ID。',
options: [],
},
gameNickname: {
key: 'gameNickname',
label: '游戏昵称',
type: 'text',
validation: 'none',
required: true,
placeholder: '请输入游戏昵称',
helpText: '用于交付时核对角色,允许中文、字母和符号。',
options: [],
},
serverZone: {
key: 'serverZone',
label: '区服',
type: 'text',
validation: 'none',
required: true,
placeholder: '例如:微信区 / QQ区 / 具体区服',
helpText: '区服不固定的游戏可改为下拉,并填写对应选项。',
options: [],
},
system: {
key: 'system',
label: '系统',
type: 'select',
validation: 'none',
required: false,
placeholder: '',
helpText: '按需收集客户使用的系统。',
options: ['安卓', '苹果'],
},
gameAccount: {
key: 'gameAccount',
label: '游戏登录账号',
type: 'text',
validation: 'none',
required: false,
placeholder: '请输入游戏登录账号',
helpText: '只有确实需要登录账号时添加,不要收集密码。',
options: [],
},
roleLevel: {
key: 'roleLevel',
label: '角色等级',
type: 'number',
validation: 'none',
required: false,
placeholder: '请输入角色等级',
helpText: '选填,用于交付前核对。',
options: [],
},
contactPhone: {
key: 'contactPhone',
label: '联系电话',
type: 'text',
validation: 'none',
required: false,
placeholder: '请输入联系电话',
helpText: '仅在业务需要联系客户时添加。',
options: [],
},
orderNote: {
key: 'orderNote',
label: '订单备注',
type: 'textarea',
validation: 'none',
required: false,
placeholder: '请输入补充说明',
helpText: '用于收集无法归入标准字段的补充信息。',
options: [],
},
}
const GAME_BASIC_PRESET_KEYS = ['gameId', 'gameNickname', 'serverZone']
const STANDARD_FIELD_KEYS = new Set(Object.keys(FIELD_PRESETS))
const FIELD_PRESET_MENU_ITEMS: MenuProps['items'] = [
{ key: 'game-basic', label: '游戏基础三件套(ID / 昵称 / 区服)' },
{ type: 'divider' },
{ key: 'gameId', label: '游戏 ID(默认纯数字)' },
{ key: 'gameNickname', label: '游戏昵称' },
{ key: 'serverZone', label: '区服' },
{ key: 'system', label: '系统(可选)' },
{ key: 'gameAccount', label: '游戏登录账号(可选)' },
{ key: 'roleLevel', label: '角色等级(可选)' },
{ key: 'contactPhone', label: '联系电话(可选)' },
{ key: 'orderNote', label: '订单备注(可选)' },
]
function buildPresetFields(keys: string[]) {
return keys.flatMap((key) => {
const preset = FIELD_PRESETS[key]
return preset ? [{ ...preset, options: [...(preset.options || [])] }] : []
})
}
function resolveGuideImage(value: unknown): UploadedFile | null {
if (!value || typeof value !== 'object' || Array.isArray(value)) return null
const source = value as Record<string, unknown>
const url = String(source.url || '').trim()
const objectKey = String(source.objectKey || source.object_key || '').trim()
return url || objectKey ? (value as UploadedFile) : null
}
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 [expandedCategoryIds, setExpandedCategoryIds] = useState<Set<number>>(() => new Set())
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 categoryTree = useMemo<OrderServiceCategoryNode[]>(() => {
const childrenByParent = new Map<number, AdminOrderServiceCategory[]>()
const roots: AdminOrderServiceCategory[] = []
for (const category of categories) {
if (category.parentId) {
const children = childrenByParent.get(category.parentId) || []
children.push(category)
childrenByParent.set(category.parentId, children)
} else {
roots.push(category)
}
}
const matches = (category: AdminOrderServiceCategory) =>
!normalizedKeyword ||
category.name.toLowerCase().includes(normalizedKeyword) ||
category.categoryKey.toLowerCase().includes(normalizedKeyword)
return roots.flatMap((category) => {
const children = childrenByParent.get(category.categoryId) || []
if (!normalizedKeyword) return [{ category, children }]
if (matches(category)) return [{ category, children }]
const matchedChildren = children.filter(matches)
return matchedChildren.length > 0 ? [{ category, children: matchedChildren }] : []
})
}, [categories, normalizedKeyword])
const filteredCategoryCount = categoryTree.reduce(
(total, node) => total + 1 + node.children.length,
0,
)
const configuredCount = new Set(
profiles.filter((item) => item.status !== 'archived').map((item) => item.categoryId),
).size
const selectedCategory = categories.find((item) => item.categoryId === selectedCategoryId) || null
const ownProfile =
(selectedCategoryId ? profileByCategoryId.get(selectedCategoryId) : null) || null
const parentCategory = selectedCategory?.parentId
? categories.find((item) => item.categoryId === selectedCategory.parentId) || null
: null
const parentProfileCandidate = parentCategory
? profileByCategoryId.get(parentCategory.categoryId) || null
: null
const parentProfile =
parentProfileCandidate?.currentVersionNo && parentProfileCandidate.status !== 'archived'
? parentProfileCandidate
: null
const profile = ownProfile || parentProfile
const inheritsParentProfile = !ownProfile && Boolean(parentProfile)
const runtimeInheritsParent =
Boolean(parentProfile) &&
(!ownProfile || ownProfile.status === 'archived' || !ownProfile.currentVersionNo)
const guideFiles = useMemo(() => {
const image = resolveGuideImage(guideImage)
return image ? [image] : []
}, [guideImage])
const handleGuideImageChange = useCallback((files: UploadedFile[]) => {
setGuideImage(files[0] || null)
}, [])
const uploadGuideImage = useCallback(
(file: File, scene: string) => uploadFile(file, scene, 'admin'),
[],
)
const materialConfirmationPreview =
Form.useWatch('materialConfirmation', form) || DEFAULT_MATERIAL_CONFIRMATION
const confirmationEnabledValue = Form.useWatch(['materialConfirmation', 'enabled'], form)
const confirmationEnabled = confirmationEnabledValue !== false
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:
inheritsParentProfile && selectedCategory
? selectedCategory.name
: nextProfile?.name || selectedCategory?.name || '',
title: nextProfile?.title || '',
description: nextProfile?.description || '',
guideImageDescription: nextProfile?.guideImageDescription || '',
submitText: nextProfile?.submitText || '提交资料',
successText: nextProfile?.successText || '资料已提交,请等待处理',
postSubmitText: nextProfile?.postSubmitText || DEFAULT_POST_SUBMIT_TEXT,
screenshots: nextProfile?.screenshots || DEFAULT_SCREENSHOTS,
autoPublish: nextProfile?.autoPublish || DEFAULT_AUTO_PUBLISH,
materialConfirmation: nextProfile?.materialConfirmation || DEFAULT_MATERIAL_CONFIRMATION,
statusSteps: nextProfile?.statusSteps || DEFAULT_STATUS_STEPS,
})
setFields(
normalizeFieldOrder(
nextProfile ? nextProfile.fields : buildPresetFields(GAME_BASIC_PRESET_KEYS),
),
)
setGuideImage(resolveGuideImage(nextProfile?.guideImage))
}
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 addFieldPreset(presetId: string) {
const presetKeys = presetId === 'game-basic' ? GAME_BASIC_PRESET_KEYS : [presetId]
const existingKeys = new Set(fields.map((field) => field.key))
const missingKeys = presetKeys.filter((key) => FIELD_PRESETS[key] && !existingKeys.has(key))
if (missingKeys.length === 0) {
message.info('所选字段已存在')
return
}
setFields((current) => normalizeFieldOrder([...current, ...buildPresetFields(missingKeys)]))
}
function addCustomField() {
setFields((current) =>
normalizeFieldOrder([
...current,
{
key: '',
label: '',
type: 'text',
validation: 'none',
required: false,
placeholder: '',
helpText: '',
options: [],
sortOrder: (current.length + 1) * 10,
},
]),
)
}
function updateField(index: number, patch: Partial<OrderServiceField>) {
setFields((current) =>
current.map((row, rowIndex) => {
if (rowIndex !== index) return row
const next = { ...row, ...patch }
if (patch.key !== undefined) {
next.validation = patch.key === 'gameId' ? 'digits' : next.validation
}
return next
}),
)
}
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
}
const invalidKey = normalizedFields.find(
(field) => !/^[a-zA-Z][a-zA-Z0-9_]{0,59}$/.test(field.key),
)
if (invalidKey) {
message.error('字段 Key 必须以英文字母开头,只能包含字母、数字和下划线')
return
}
const emptyLabel = normalizedFields.find((field) => !String(field.label || '').trim())
if (emptyLabel) {
message.error('请填写所有字段的显示名称')
return
}
setSaving(true)
try {
await saveAdminOrderServiceProfile({
categoryId: selectedCategoryId,
name: values.name || selectedCategory?.name || '订单服务配置',
title: values.title || '',
description: values.description || '',
guideImage: guideImage || {},
guideImageDescription: values.guideImageDescription || '',
fields: normalizedFields,
screenshots: values.screenshots,
autoPublish: values.autoPublish,
materialConfirmation: values.materialConfirmation,
statusSteps: values.statusSteps,
submitText: values.submitText,
successText: values.successText,
postSubmitText: values.postSubmitText,
})
message.success(
inheritsParentProfile
? '已创建二级分类独立草稿;发布后优先使用该配置'
: '草稿已保存;点击“发布新版本”后才会影响新工单',
)
await refresh()
} catch (error) {
message.error(error instanceof Error ? error.message : '保存失败')
} finally {
setSaving(false)
}
}
async function handlePublish() {
if (!ownProfile) {
message.warning('当前分类继承父级配置,请先保存二级分类独立草稿')
return
}
setPublishing(true)
try {
const response = await publishAdminOrderServiceProfile(ownProfile.id)
const backfilled = Number(response.data.backfilledPendingOrders || 0)
message.success(
backfilled > 0
? `已发布 v${response.data.versionNo},同步 ${backfilled} 个未提交工单`
: `已发布 v${response.data.versionNo}`,
)
await refresh()
} catch (error) {
message.error(error instanceof Error ? error.message : '发布失败')
} finally {
setPublishing(false)
}
}
async function handleArchive() {
if (!ownProfile) return
setPublishing(true)
try {
await archiveAdminOrderServiceProfile(ownProfile.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">
{filteredCategoryCount}/{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">
{categoryTree.map(({ category, children }) => {
const rowProfile = profileByCategoryId.get(category.categoryId)
const status = resolveProfileStatus(rowProfile)
const expanded =
Boolean(normalizedKeyword) || expandedCategoryIds.has(category.categoryId)
return (
<div className="order-service-category-group" key={category.categoryId}>
<button
type="button"
className={`order-service-category-item${
category.categoryId === selectedCategoryId ? ' is-active' : ''
}`}
onClick={() => {
setSelectedCategoryId(category.categoryId)
if (children.length === 0) return
setExpandedCategoryIds((current) => {
const next = new Set(current)
if (next.has(category.categoryId)) {
next.delete(category.categoryId)
} else {
next.add(category.categoryId)
}
return next
})
}}
>
<span className="order-service-category-main">
{children.length > 0 ? (
<span className="order-service-category-toggle">
{expanded ? <DownOutlined /> : <RightOutlined />}
</span>
) : (
<span className="order-service-category-toggle is-empty" />
)}
<span className="order-service-category-name">{category.name}</span>
{children.length > 0 ? (
<Tag className="order-service-category-count">
{children.length} 个二级
</Tag>
) : null}
</span>
<span className="order-service-category-meta">
<span className="order-service-category-key">
{category.categoryKey}
{category.status === 'active' ? '' : ' · 已停用'}
</span>
<span className={`order-service-status-pill is-${status.tone}`}>
{status.label}
</span>
</span>
</button>
{expanded ? (
<div className="order-service-subcategory-list">
{children.map((child) => {
const childProfile = profileByCategoryId.get(child.categoryId)
const childStatus = resolveInheritedProfileStatus(
childProfile,
rowProfile,
)
return (
<button
key={child.categoryId}
type="button"
className={`order-service-category-item is-child${
child.categoryId === selectedCategoryId ? ' is-active' : ''
}`}
onClick={() => setSelectedCategoryId(child.categoryId)}
>
<span className="order-service-category-main">
<span className="order-service-category-name">{child.name}</span>
</span>
<span className="order-service-category-meta">
<span className="order-service-category-key">
{child.categoryKey}
{child.status === 'active' ? '' : ' · 已停用'}
</span>
<span
className={`order-service-status-pill is-${childStatus.tone}`}
>
{childStatus.label}
</span>
</span>
</button>
)
})}
</div>
) : null}
</div>
)
})}
{categoryTree.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={
ownProfile?.status === 'published'
? 'green'
: inheritsParentProfile
? 'blue'
: 'orange'
}
>
{ownProfile
? resolveProfileStatus(ownProfile).label
: inheritsParentProfile
? `继承「${parentCategory?.name || '父级'}」v${parentProfile?.currentVersionNo}`
: '尚未配置'}
</Tag>
{ownProfile && runtimeInheritsParent ? (
<Tag color="gold">
线上继承「{parentCategory?.name || '父级'}」v
{parentProfile?.currentVersionNo}
</Tag>
) : null}
</Space>
<Text type="secondary">分类标识:{selectedCategory.categoryKey}</Text>
</div>
<Space size={8} wrap>
<Button
disabled={!ownProfile}
loading={publishing}
onClick={() => void handlePublish()}
>
发布新版本
</Button>
{ownProfile ? (
<Button danger loading={publishing} onClick={() => void handleArchive()}>
归档
</Button>
) : null}
</Space>
</div>
<Alert
className="order-service-tip"
type={runtimeInheritsParent ? 'warning' : 'info'}
showIcon
message={
inheritsParentProfile
? `当前二级分类没有独立配置,线上直接继承一级分类「${parentCategory?.name || '父级'}」的已发布模板。修改并保存后会创建二级分类独立草稿。`
: ownProfile && runtimeInheritsParent
? `当前二级分类草稿尚未发布,线上仍继承一级分类「${parentCategory?.name || '父级'}」的已发布模板。`
: selectedCategory.parentId
? '二级分类优先使用独立配置;没有独立已发布配置时自动继承一级分类模板。'
: '一级分类作为默认模板,其下二级分类未独立配置时会自动继承。'
}
/>
<Form form={form} layout="vertical" onFinish={handleSave}>
<section className="order-service-section">
<div className="order-service-section-head">
<OrderServiceSectionTitle
index="01"
title="基础信息"
description="客户打开 /o 后看到的标题、说明与提交反馈"
/>
</div>
<div className="order-service-basic-grid">
<div className="order-service-basic-fields">
<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>
<Col span={24}>
<Form.Item
label="提交后说明"
name="postSubmitText"
extra="显示在客户页底部,支持换行;适合填写资料修改、客服联系等说明。"
>
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 5 }}
maxLength={500}
showCount
placeholder="资料提交后如需修改,请联系客服。"
/>
</Form.Item>
</Col>
</Row>
</div>
<div className="order-service-guide">
<div className="order-service-guide-head">
<Text strong>用户引导图</Text>
<Text type="secondary">
一张,显示在客户页“买家主页截图”上传区右侧,可点击放大查看
</Text>
</div>
<div className="order-service-guide-upload">
<ImageUpload
scene="order-service"
scope="admin"
maxCount={1}
value={guideFiles}
onChange={handleGuideImageChange}
uploader={uploadGuideImage}
/>
</div>
<Form.Item
className="order-service-guide-description"
extra="选填,显示在引导图下方;建议说明截图范围或关键识别信息。"
label="图片说明"
name="guideImageDescription"
>
<Input.TextArea
autoSize={{ minRows: 2, maxRows: 4 }}
disabled={!guideImage}
maxLength={200}
placeholder="例如:请截取买家个人主页,并确认游戏 ID 清晰可见"
showCount
/>
</Form.Item>
</div>
</div>
</section>
<section className="order-service-section">
<div className="order-service-section-head">
<OrderServiceSectionTitle
index="02"
title="客户状态进度"
description="四个节点独立展示,标题保持简短,补充说明用于安抚和引导客户"
/>
</div>
<div className="order-service-status-step-grid">
{STATUS_STEP_OPTIONS.map((step, index) => (
<div className="order-service-status-step-card" key={step.key}>
<div className="order-service-status-step-head">
<span className="order-service-status-step-index">{index + 1}</span>
<div>
<Text strong>{step.label}</Text>
<Text type="secondary">客户页第 {index + 1} 个节点</Text>
</div>
</div>
<Form.Item
label="节点标题"
name={['statusSteps', step.key, 'title']}
rules={[
{ required: true, whitespace: true, message: '请填写节点标题' },
]}
>
<Input maxLength={20} />
</Form.Item>
<Form.Item
label="补充说明"
name={['statusSteps', step.key, 'description']}
>
<Input maxLength={80} placeholder="选填,显示在节点标题下方" />
</Form.Item>
</div>
))}
</div>
</section>
<section className="order-service-section">
<div className="order-service-section-head">
<OrderServiceSectionTitle
index="03"
title="提交资料确认"
description="客户点击提交时二次确认,降低资料填写错误导致的售后风险"
/>
<div className="order-service-confirmation-switch">
<span>
<Text strong>{confirmationEnabled ? '已启用' : '已关闭'}</Text>
<Text type="secondary">
{confirmationEnabled ? '提交前弹窗确认' : '客户将直接提交资料'}
</Text>
</span>
<Form.Item
name={['materialConfirmation', 'enabled']}
valuePropName="checked"
noStyle
>
<Switch />
</Form.Item>
</div>
</div>
<div className="order-service-confirmation-grid">
<div className="order-service-confirmation-editor">
<Row gutter={[12, 0]}>
<Col span={24}>
<Form.Item label="确认标题" name={['materialConfirmation', 'title']}>
<Input
disabled={!confirmationEnabled}
maxLength={40}
placeholder="例如:提交资料确认"
/>
</Form.Item>
</Col>
<Col xs={12}>
<Form.Item
label="确认按钮"
name={['materialConfirmation', 'confirmText']}
>
<Input
disabled={!confirmationEnabled}
maxLength={12}
placeholder="确认提交"
/>
</Form.Item>
</Col>
<Col xs={12}>
<Form.Item
label="取消按钮"
name={['materialConfirmation', 'cancelText']}
>
<Input
disabled={!confirmationEnabled}
maxLength={12}
placeholder="返回检查"
/>
</Form.Item>
</Col>
<Col span={24}>
<Form.Item label="确认内容" name={['materialConfirmation', 'content']}>
<Input.TextArea
autoSize={{ minRows: 4, maxRows: 7 }}
disabled={!confirmationEnabled}
maxLength={500}
showCount
placeholder="请输入客户提交资料前需要确认的内容"
/>
</Form.Item>
</Col>
</Row>
</div>
<div className="order-service-confirmation-preview-panel">
<div className="order-service-confirmation-preview-head">
<span>
<Text strong>客户端预览</Text>
<Text type="secondary">实际弹窗会按客户资料生成摘要</Text>
</span>
<Tag color={confirmationEnabled ? 'green' : 'default'}>
{confirmationEnabled ? '生效中' : '未启用'}
</Tag>
</div>
<MaterialConfirmationPreview confirmation={materialConfirmationPreview} />
</div>
</div>
</section>
<section className="order-service-section">
<div className="order-service-section-head">
<OrderServiceSectionTitle
index="04"
title="资料字段"
description="标准字段自动支持搜索、复制与资料匹配;自定义字段仅用于展示"
/>
<Space wrap>
<Dropdown
menu={{
items: FIELD_PRESET_MENU_ITEMS,
onClick: ({ key }) => addFieldPreset(String(key)),
}}
trigger={['click']}
>
<Button icon={<PlusOutlined />} type="dashed">
添加常用字段
</Button>
</Dropdown>
<Button icon={<PlusOutlined />} onClick={addCustomField}>
自定义字段
</Button>
</Space>
</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
addonAfter={
field.validation === 'digits'
? '纯数字'
: STANDARD_FIELD_KEYS.has(field.key)
? '标准'
: undefined
}
disabled={STANDARD_FIELD_KEYS.has(field.key)}
value={field.key}
maxLength={60}
placeholder="例如:accountName"
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 })
}
/>
{!STANDARD_FIELD_KEYS.has(field.key) ? (
<Text type="secondary" className="order-service-field-extra-tip">
自定义 Key 仅用于展示;需要搜索、复制或匹配能力请使用标准字段。
</Text>
) : null}
{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">
<OrderServiceSectionTitle
index="05"
title="截图规则"
description="控制客户上传买家主页截图的限制"
/>
</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">
<OrderServiceSectionTitle
index="06"
title="自动发布并核销"
description="客户提交后按工单快照自动处理"
/>
</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' }
}
function resolveInheritedProfileStatus(
profile: AdminOrderServiceProfile | undefined,
parentProfile: AdminOrderServiceProfile | undefined,
) {
if (profile) {
const status = resolveProfileStatus(profile)
if (profile.status === 'archived' && parentProfile?.currentVersionNo) {
return { label: `已归档 · 继承 v${parentProfile.currentVersionNo}`, tone: 'muted' }
}
if (
!profile.currentVersionNo &&
profile.status !== 'archived' &&
parentProfile?.currentVersionNo
) {
return { label: `草稿 · 继承 v${parentProfile.currentVersionNo}`, tone: 'warning' }
}
return status
}
if (parentProfile?.currentVersionNo && parentProfile.status !== 'archived') {
return {
label: `继承 v${parentProfile.currentVersionNo}`,
tone: 'inherited',
}
}
return { label: '未配置', tone: 'muted' }
}
function OrderServiceSectionTitle({
index,
title,
description,
}: {
index: string
title: string
description: string
}) {
return (
<div className="order-service-section-title">
<span className="order-service-section-index">{index}</span>
<span className="order-service-section-title-copy">
<Text strong>{title}</Text>
<Text type="secondary">{description}</Text>
</span>
</div>
)
}
function MaterialConfirmationPreview({
confirmation,
}: {
confirmation: OrderServiceMaterialConfirmation
}) {
return (
<div
className={`order-service-confirmation-preview${confirmation.enabled ? '' : ' is-disabled'}`}
>
<strong>{confirmation.title || '提交资料确认'}</strong>
<div className="order-service-confirmation-preview-summary">
<span>商品:示例商品</span>
<span>游戏账号:customer@example.com</span>
<span>区服:安卓</span>
<span>截图:2 张</span>
</div>
<div className="order-service-confirmation-preview-content">
{confirmation.content || '提交资料确认内容'}
</div>
<div className="order-service-confirmation-preview-actions">
<span>{confirmation.cancelText || '返回检查'}</span>
<span className="is-primary">{confirmation.confirmText || '确认提交'}</span>
</div>
{!confirmation.enabled ? <small>当前已关闭,点击提交将直接提交资料</small> : null}
</div>
)
}