1151 lines
44 KiB
TypeScript
1151 lines
44 KiB
TypeScript
import {
|
||
ArrowDownOutlined,
|
||
ArrowUpOutlined,
|
||
DeleteOutlined,
|
||
PlusOutlined,
|
||
ReloadOutlined,
|
||
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 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
|
||
submitText: string
|
||
successText: string
|
||
postSubmitText: string
|
||
screenshots: ScreenshotForm
|
||
autoPublish: AutoPublishForm
|
||
materialConfirmation: OrderServiceMaterialConfirmation
|
||
statusSteps: OrderServiceStatusSteps
|
||
}
|
||
|
||
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: '第 1 步 · 提交资料' },
|
||
{ key: 'scheduled', label: '第 2 步 · 客服安排' },
|
||
{ key: 'processing', label: '第 3 步 · 订单处理' },
|
||
{ key: 'completed', label: '第 4 步 · 交付完成' },
|
||
]
|
||
|
||
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 || [])] }] : []
|
||
})
|
||
}
|
||
|
||
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'),
|
||
[],
|
||
)
|
||
const materialConfirmationPreview =
|
||
Form.useWatch('materialConfirmation', form) || DEFAULT_MATERIAL_CONFIRMATION
|
||
|
||
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 || '资料已提交,请等待处理',
|
||
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(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 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 || {},
|
||
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('草稿已保存;点击“发布新版本”后才会影响新工单')
|
||
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)
|
||
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 (!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>
|
||
<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">
|
||
<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>
|
||
</div>
|
||
<div className="order-service-status-step-grid">
|
||
{STATUS_STEP_OPTIONS.map((step) => (
|
||
<div className="order-service-status-step-card" key={step.key}>
|
||
<Text strong>{step.label}</Text>
|
||
<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">
|
||
<div>
|
||
<Text strong>提交资料确认</Text>
|
||
<Text type="secondary">
|
||
客户点击提交时弹窗确认,可自定义提示内容和按钮文字。
|
||
</Text>
|
||
</div>
|
||
<Space size={8}>
|
||
<Text type="secondary">启用确认</Text>
|
||
<Form.Item
|
||
name={['materialConfirmation', 'enabled']}
|
||
valuePropName="checked"
|
||
noStyle
|
||
>
|
||
<Switch />
|
||
</Form.Item>
|
||
</Space>
|
||
</div>
|
||
<Row gutter={[12, 0]}>
|
||
<Col xs={24} md={12}>
|
||
<Form.Item label="确认标题" name={['materialConfirmation', 'title']}>
|
||
<Input maxLength={40} placeholder="例如:提交资料确认" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={12} md={6}>
|
||
<Form.Item label="确认按钮" name={['materialConfirmation', 'confirmText']}>
|
||
<Input maxLength={12} placeholder="确认提交" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col xs={12} md={6}>
|
||
<Form.Item label="取消按钮" name={['materialConfirmation', 'cancelText']}>
|
||
<Input maxLength={12} placeholder="返回检查" />
|
||
</Form.Item>
|
||
</Col>
|
||
<Col span={24}>
|
||
<Form.Item label="确认内容" name={['materialConfirmation', 'content']}>
|
||
<Input.TextArea
|
||
autoSize={{ minRows: 3, maxRows: 6 }}
|
||
maxLength={500}
|
||
showCount
|
||
placeholder="请输入客户提交资料前需要确认的内容"
|
||
/>
|
||
</Form.Item>
|
||
</Col>
|
||
</Row>
|
||
<MaterialConfirmationPreview confirmation={materialConfirmationPreview} />
|
||
</section>
|
||
|
||
<section className="order-service-section">
|
||
<div className="order-service-section-head">
|
||
<div>
|
||
<Text strong>资料字段</Text>
|
||
<Text type="secondary">
|
||
优先添加常用字段;标准字段会自动支持搜索、复制和资料匹配。
|
||
</Text>
|
||
</div>
|
||
<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">
|
||
<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' }
|
||
}
|
||
|
||
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>
|
||
)
|
||
}
|