feat(order-service): 优化资料字段预设与游戏ID校验

This commit is contained in:
yml2213
2026-11-04 12:33:33 +08:00
parent e17f336e1c
commit 1a80521057
21 changed files with 377 additions and 37 deletions
@@ -15,6 +15,7 @@ import {
Card,
Col,
Collapse,
Dropdown,
Empty,
Form,
Input,
@@ -25,6 +26,7 @@ import {
Switch,
Tag,
Typography,
type MenuProps,
} from 'antd'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { useCallback, useEffect, useMemo, useState } from 'react'
@@ -99,6 +101,113 @@ const DEFAULT_MATERIAL_CONFIRMATION: OrderServiceMaterialConfirmation = {
cancelText: '返回检查',
}
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()
@@ -175,7 +284,11 @@ export default function OrderServicePanel() {
autoPublish: nextProfile?.autoPublish || DEFAULT_AUTO_PUBLISH,
materialConfirmation: nextProfile?.materialConfirmation || DEFAULT_MATERIAL_CONFIRMATION,
})
setFields(normalizeFieldOrder(nextProfile?.fields || []))
setFields(
normalizeFieldOrder(
nextProfile ? nextProfile.fields : buildPresetFields(GAME_BASIC_PRESET_KEYS),
),
)
setGuideImage(nextProfile?.guideImage || null)
}
@@ -187,15 +300,27 @@ export default function OrderServicePanel() {
])
}
function addField() {
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: `field${Date.now().toString(36)}`,
label: '新字段',
key: '',
label: '',
type: 'text',
required: true,
validation: 'none',
required: false,
placeholder: '',
helpText: '',
options: [],
@@ -207,7 +332,14 @@ export default function OrderServicePanel() {
function updateField(index: number, patch: Partial<OrderServiceField>) {
setFields((current) =>
current.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)),
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
}),
)
}
@@ -235,6 +367,18 @@ export default function OrderServicePanel() {
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({
@@ -577,17 +721,32 @@ export default function OrderServicePanel() {
<div className="order-service-section-head">
<div>
<Text strong>资料字段</Text>
<Text type="secondary">客户必须填写或选填的资料,可调整顺序。</Text>
<Text type="secondary">
优先添加常用字段;标准字段会自动支持搜索、复制和资料匹配。
</Text>
</div>
<Button icon={<PlusOutlined />} onClick={addField} type="dashed">
添加字段
</Button>
<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="至少添加一个资料字段后才能发布"
description="至少添加一个资料字段后才能发布,推荐先添加游戏基础三件套"
/>
) : (
<div className="order-service-field-list">
@@ -597,7 +756,17 @@ export default function OrderServicePanel() {
<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 })
}
@@ -673,6 +842,11 @@ export default function OrderServicePanel() {
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="选项"
@@ -3232,11 +3232,22 @@ export default function WorkOrdersPanel() {
key={field.key}
label={field.label}
name={['fields', field.key]}
rules={
field.required
rules={[
...(field.required
? [{ required: true, message: `请选择/填写${field.label}` }]
: undefined
}
: []),
...(field.validation === 'digits'
? [
{
validator: (_rule: unknown, value: unknown) => {
const text = String(value ?? '').trim()
if (!text || /^\d+$/.test(text)) return Promise.resolve()
return Promise.reject(new Error(`${field.label}只能填写数字`))
},
},
]
: []),
]}
>
{field.type === 'select' && (field.options || []).length > 0 ? (
<Radio.Group
@@ -3246,7 +3257,11 @@ export default function WorkOrdersPanel() {
}))}
/>
) : (
<Input />
<Input
inputMode={field.validation === 'digits' ? 'numeric' : undefined}
maxLength={field.validation === 'digits' ? 50 : undefined}
pattern={field.validation === 'digits' ? '[0-9]*' : undefined}
/>
)}
</Form.Item>
))}
@@ -10,6 +10,7 @@ type MappedRequirementField = {
label: string
required: boolean
type: 'text' | 'select'
validation: 'none' | 'digits'
options: string[]
}
@@ -26,6 +27,7 @@ export function getRequirementFields(order: WorkOrder | null): CollectField[] {
label: String(source.label || key).trim(),
required: source.required !== false,
type: source.type === 'select' ? ('select' as const) : ('text' as const),
validation: source.validation === 'digits' || key === 'gameId' ? 'digits' : 'none',
options: Array.isArray(source.options)
? source.options.map((option) => String(option || '').trim()).filter(Boolean)
: [],
@@ -111,10 +111,15 @@ export function parseWorkerMaterialText(text: string): Record<string, string> {
const zoneValue = value.toLowerCase()
if (zoneValue.includes('微信') || /^v/.test(zoneValue)) result.serverZone = '微信区'
else if (zoneValue.includes('q')) result.serverZone = 'QQ区'
} else if (/角色|昵称|名称/.test(label)) {
} else if (/角色|昵称|名称|游戏名/.test(label)) {
result.gameNickname = value
} else if (/登录账号|平台账号|手机号|邮箱/.test(label)) {
result.gameAccount = value
} else if (/编号|游戏id|账号id|^id|uid/.test(label)) {
result.gameId = value
} else if (/游戏账号|账号/.test(label)) {
if (/^\d+$/.test(value)) result.gameId = value
else result.gameAccount = value
}
}
return result
@@ -432,11 +432,22 @@ function OrderServiceForm({
</Space>
}
extra={field.helpText}
rules={
field.required
rules={[
...(field.required
? [{ required: true, whitespace: true, message: `请填写${field.label}` }]
: undefined
}
: []),
...(field.validation === 'digits'
? [
{
validator: (_rule: unknown, value: unknown) => {
const text = String(value ?? '').trim()
if (!text || /^\d+$/.test(text)) return Promise.resolve()
return Promise.reject(new Error(`${field.label}只能填写数字`))
},
},
]
: []),
]}
>
{renderFieldInput(field)}
</Form.Item>
@@ -497,6 +508,17 @@ function OrderServiceForm({
function renderFieldInput(field: NonNullable<OrderServiceItem['fields']>[number]) {
const placeholder = field.placeholder || `请输入${field.label}`
if (field.validation === 'digits') {
return (
<Input
autoComplete="off"
inputMode="numeric"
maxLength={50}
pattern="[0-9]*"
placeholder={placeholder}
/>
)
}
if (field.type === 'textarea') {
return <Input.TextArea autoComplete="off" placeholder={placeholder} rows={3} />
}
@@ -214,14 +214,17 @@ export function getRequirementFields(order: WorkOrder | null): CollectField[] {
if (!order) return []
const rawFields = Array.isArray(order.requirement?.fields) ? order.requirement.fields : []
return rawFields
.map((item) => {
.map((item): CollectField | null => {
const source = asRecord(item)
const key = String(source.key || '').trim()
if (!key) return null
const validation: NonNullable<CollectField['validation']> =
source.validation === 'digits' || key === 'gameId' ? 'digits' : 'none'
return {
key,
label: String(source.label || key).trim(),
required: source.required !== false,
validation,
}
})
.filter((item): item is CollectField => Boolean(item))
@@ -1113,6 +1113,7 @@ export type OrderServiceField = {
key: string
label: string
type: 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
validation: 'none' | 'digits'
required: boolean
placeholder?: string
helpText?: string
+1
View File
@@ -560,6 +560,7 @@ export type OrderServiceField = {
key: string
label: string
type: 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
validation?: 'none' | 'digits'
required: boolean
placeholder?: string
helpText?: string
+5
View File
@@ -1953,6 +1953,11 @@
margin-top: 8px;
}
.order-service-field-extra-tip {
grid-column: 1 / -1;
font-size: 12px;
}
.order-service-field-extra > :last-child:nth-child(3) {
grid-column: 1 / -1;
}
@@ -881,6 +881,7 @@ export type CollectField = {
label: string
required: boolean
type?: 'text' | 'select'
validation?: 'none' | 'digits'
options?: string[]
}