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

1097 lines
42 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,
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,
} 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
}
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 = '资料提交后如需修改,请联系客服。'
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,
})
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,
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>
<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>
)
}