feat(order-service): 优化资料字段预设与游戏ID校验
This commit is contained in:
@@ -235,7 +235,12 @@ export async function backfillPendingOrderServiceSnapshotsForCategory(
|
||||
AND wo.category_id = p.category_id
|
||||
AND wo.status = 'pending_material'
|
||||
AND wo.assigned_worker_id IS NULL
|
||||
AND wo.service_profile_snapshot_json IS NULL
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM work_order_shares share
|
||||
WHERE share.work_order_id = wo.id
|
||||
AND share.status <> 'cancelled'
|
||||
)
|
||||
`,
|
||||
[input.categoryId, input.profileId, input.versionId],
|
||||
)
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
resolveRequirementFields,
|
||||
} from './mappers.js'
|
||||
import { resolveWorkOrderRealtimeWorkerIds } from './admin-work-order-realtime-service.js'
|
||||
import { findInvalidSubmittedFields } from './work-order-material-link-service.js'
|
||||
import { getRequiredWorkOrder } from './worker-session-context-service.js'
|
||||
|
||||
export async function submitAdminWorkOrderMaterial(
|
||||
@@ -75,6 +76,14 @@ async function persistAdminWorkOrderMaterial(workOrderId: number | string, paylo
|
||||
|
||||
const fields = resolveRequirementFields(workOrder)
|
||||
const submittedFields = normalizeSubmittedFields(payload.fields || payload.material || payload)
|
||||
const invalidDigits = findInvalidSubmittedFields(fields, submittedFields)
|
||||
if (invalidDigits.length > 0) {
|
||||
throw createHttpError(`${invalidDigits.map((field) => field.label).join('、')}只能填写数字`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'work_order_material_fields_invalid',
|
||||
context: { invalidFields: invalidDigits.map((field) => field.key) },
|
||||
})
|
||||
}
|
||||
const complete = fields.every(
|
||||
(field) => !field.required || String(submittedFields[field.key] || '').trim(),
|
||||
)
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
isOrderServiceMaterialRequired,
|
||||
normalizeProfileConfig,
|
||||
} from './order-service-profile-service.js'
|
||||
import { backfillPendingOrderServiceSnapshotsForCategory } from '../../repositories/worker-platform/order-service-profile-repo.js'
|
||||
|
||||
function config(patch: Record<string, unknown> = {}) {
|
||||
return normalizeProfileConfig({
|
||||
@@ -78,6 +79,13 @@ test('订单服务配置规范化:未知字段类型降级为文本,空下
|
||||
assert.equal(normalized.fields[0]?.type, 'text')
|
||||
})
|
||||
|
||||
test('订单服务游戏 ID:标准化为纯数字字符串校验', () => {
|
||||
const normalized = config({
|
||||
fields: [{ key: 'gameId', label: '游戏 ID', type: 'text', required: true }],
|
||||
})
|
||||
assert.equal(normalized.fields[0]?.validation, 'digits')
|
||||
})
|
||||
|
||||
test('订单服务提交确认:旧配置默认展示不可修改与资料错误免责声明', () => {
|
||||
const normalized = config()
|
||||
assert.deepEqual(normalized.materialConfirmation, {
|
||||
@@ -108,3 +116,23 @@ test('订单服务提交确认:支持自定义标题、正文和按钮文字',
|
||||
cancelText: '再检查一下',
|
||||
})
|
||||
})
|
||||
|
||||
test('发布配置:待提交且无有效拼单份额的工单可更新到最新快照', async () => {
|
||||
let capturedSql = ''
|
||||
const client = {
|
||||
query: async (sql: string) => {
|
||||
capturedSql = sql
|
||||
return { rowCount: 2 }
|
||||
},
|
||||
} as unknown as Parameters<typeof backfillPendingOrderServiceSnapshotsForCategory>[0]
|
||||
|
||||
const count = await backfillPendingOrderServiceSnapshotsForCategory(client, {
|
||||
categoryId: 1,
|
||||
profileId: 1,
|
||||
versionId: 14,
|
||||
})
|
||||
|
||||
assert.equal(count, 2)
|
||||
assert.doesNotMatch(capturedSql, /service_profile_snapshot_json IS NULL/)
|
||||
assert.match(capturedSql, /share\.status <> 'cancelled'/)
|
||||
})
|
||||
|
||||
@@ -20,11 +20,13 @@ import { listAllWorkCategories } from '../../repositories/worker-platform/work-c
|
||||
import { normalizeUploadedFiles } from './worker-input-normalizers.js'
|
||||
|
||||
export type OrderServiceFieldType = 'text' | 'textarea' | 'select' | 'radio' | 'number' | 'date'
|
||||
export type OrderServiceFieldValidation = 'none' | 'digits'
|
||||
|
||||
export type OrderServiceField = {
|
||||
key: string
|
||||
label: string
|
||||
type: OrderServiceFieldType
|
||||
validation: OrderServiceFieldValidation
|
||||
required: boolean
|
||||
placeholder: string
|
||||
helpText: string
|
||||
@@ -223,7 +225,7 @@ function mapAdminProfile(row: OrderServiceProfileRow) {
|
||||
title: row.title,
|
||||
description: row.description,
|
||||
guideImage: row.guide_image_json,
|
||||
fields: row.fields_json,
|
||||
fields: buildProfileConfig(row).fields,
|
||||
screenshots: row.screenshots_json,
|
||||
autoPublish: row.auto_publish_json,
|
||||
materialConfirmation: normalizeMaterialConfirmation(row.material_confirmation_json),
|
||||
@@ -328,6 +330,8 @@ function normalizeFields(value: unknown): OrderServiceField[] {
|
||||
const type = (
|
||||
FIELD_TYPES.has(rawType as OrderServiceFieldType) ? rawType : 'text'
|
||||
) as OrderServiceFieldType
|
||||
const validation: OrderServiceFieldValidation =
|
||||
source.validation === 'digits' || key === 'gameId' ? 'digits' : 'none'
|
||||
const options = Array.isArray(source.options)
|
||||
? source.options.map((option) => text(option, 60)).filter(Boolean)
|
||||
: []
|
||||
@@ -336,6 +340,7 @@ function normalizeFields(value: unknown): OrderServiceField[] {
|
||||
key,
|
||||
label: text(source.label, 60) || key,
|
||||
type,
|
||||
validation,
|
||||
required: source.required !== false,
|
||||
placeholder: text(source.placeholder, 100),
|
||||
helpText: text(source.helpText ?? source.help_text, 200),
|
||||
@@ -347,6 +352,7 @@ function normalizeFields(value: unknown): OrderServiceField[] {
|
||||
key,
|
||||
label: text(source.label, 60) || key,
|
||||
type,
|
||||
validation,
|
||||
required: source.required !== false,
|
||||
placeholder: '',
|
||||
helpText: text(source.helpText ?? source.help_text, 200),
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
} from '../file-storage/file-storage-service.js'
|
||||
import { enqueueCustomerOrderNotification } from '../notification/customer-order-notification-service.js'
|
||||
import {
|
||||
findInvalidSubmittedFields,
|
||||
pickSubmittedMaterialFields,
|
||||
resolvePublicMaterialOrderState,
|
||||
throwPublicMaterialBlocked,
|
||||
@@ -160,6 +161,14 @@ export async function submitOrderServiceMaterial(
|
||||
},
|
||||
)
|
||||
}
|
||||
const invalidDigits = findInvalidSubmittedFields(fields, submittedFields)
|
||||
if (invalidDigits.length > 0) {
|
||||
throw createHttpError(`${invalidDigits.map((field) => field.label).join('、')}只能填写数字`, {
|
||||
statusCode: 400,
|
||||
errorCode: 'material_link_fields_invalid',
|
||||
context: { invalidFields: invalidDigits.map((field) => field.key) },
|
||||
})
|
||||
}
|
||||
|
||||
const screenshotRule = profileConfig.screenshots
|
||||
const requestedScreenshots = screenshotRule.enabled
|
||||
@@ -421,7 +430,10 @@ async function resolveProfileConfig(
|
||||
title: '',
|
||||
description: '',
|
||||
guideImage: {},
|
||||
fields: legacy.fields as OrderServiceField[],
|
||||
fields: normalizeProfileConfig({
|
||||
name: '通用资料',
|
||||
fields: legacy.fields,
|
||||
}).fields,
|
||||
screenshots: {
|
||||
enabled: true,
|
||||
required: true,
|
||||
|
||||
@@ -2,6 +2,7 @@ import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
findInvalidSubmittedFields,
|
||||
formatPublicMaterialStatus,
|
||||
pickSubmittedMaterialFields,
|
||||
resolveMaterialResetBlockReason,
|
||||
@@ -139,3 +140,17 @@ test('公开补资料:提交字段只保留 requirements 白名单,额外字
|
||||
|
||||
assert.deepEqual(result, { gameId: '123', gameNickname: '张三' })
|
||||
})
|
||||
|
||||
test('纯数字字段:只接受 ASCII 数字字符串,可选字段留空不报错', () => {
|
||||
const fields = [
|
||||
{ key: 'gameId', label: '游戏 ID', validation: 'digits' as const },
|
||||
{ key: 'backupId', label: '备用 ID', validation: 'digits' as const },
|
||||
]
|
||||
assert.deepEqual(findInvalidSubmittedFields(fields, { gameId: '001234567890' }), [])
|
||||
assert.deepEqual(
|
||||
findInvalidSubmittedFields(fields, { gameId: '12A', backupId: '123' }).map(
|
||||
(field) => field.key,
|
||||
),
|
||||
['gameId', 'backupId'],
|
||||
)
|
||||
})
|
||||
|
||||
@@ -112,6 +112,16 @@ export function pickSubmittedMaterialFields(
|
||||
return Object.fromEntries(Object.entries(submitted).filter(([key]) => allowedKeys.has(key)))
|
||||
}
|
||||
|
||||
export function findInvalidSubmittedFields(
|
||||
fields: Array<{ key: string; label: string; validation?: 'none' | 'digits' }>,
|
||||
submitted: Record<string, string>,
|
||||
) {
|
||||
return fields.filter((field) => {
|
||||
const value = String(submitted[field.key] || '').trim()
|
||||
return field.validation === 'digits' && Boolean(value) && !/^\d+$/.test(value)
|
||||
})
|
||||
}
|
||||
|
||||
export function throwPublicMaterialBlocked(state: PublicMaterialOrderState): never {
|
||||
if (state.blockedReason === 'already_submitted') {
|
||||
throw createHttpError(state.message, {
|
||||
|
||||
@@ -33,7 +33,12 @@ export function extractConsignmentBuyerInfo(input: {
|
||||
fields.gameNickname || fields.roleName || fields.nickname || fields.game_nickname || '',
|
||||
).trim()
|
||||
const gameId = String(
|
||||
fields.gameId || fields.characterId || fields.uid || fields.game_id || '',
|
||||
fields.gameId ||
|
||||
fields.characterId ||
|
||||
fields.uid ||
|
||||
fields.game_id ||
|
||||
(/^\d+$/.test(String(fields.gameAccount || '').trim()) ? fields.gameAccount : '') ||
|
||||
'',
|
||||
).trim()
|
||||
const serverZone = String(fields.serverZone || fields.zone || fields.server_zone || '').trim()
|
||||
const system = String(fields.system || fields.os || '').trim()
|
||||
|
||||
@@ -7,6 +7,7 @@ export type RequirementField = {
|
||||
label: string
|
||||
required: boolean
|
||||
type: 'text' | 'select'
|
||||
validation: 'none' | 'digits'
|
||||
options: string[]
|
||||
mockValue: string
|
||||
}
|
||||
@@ -15,10 +16,10 @@ export function resolveRequirementFields(workOrder: WorkOrderRow) {
|
||||
return normalizeRequirementFields(safeParseJson(workOrder.requirement_json).fields)
|
||||
}
|
||||
|
||||
export function normalizeRequirementFields(value: unknown) {
|
||||
export function normalizeRequirementFields(value: unknown): RequirementField[] {
|
||||
const rawFields = Array.isArray(value) ? value : []
|
||||
const fields = rawFields
|
||||
.map((item) => {
|
||||
.map((item): RequirementField | null => {
|
||||
const source =
|
||||
item && typeof item === 'object' && !Array.isArray(item) ? (item as JsonObject) : {}
|
||||
const key = String(source.key || '').trim()
|
||||
@@ -27,18 +28,21 @@ export function normalizeRequirementFields(value: unknown) {
|
||||
const options = Array.isArray(source.options)
|
||||
? source.options.map((option) => String(option || '').trim()).filter(Boolean)
|
||||
: []
|
||||
const validation: RequirementField['validation'] =
|
||||
source.validation === 'digits' || key === 'gameId' ? 'digits' : 'none'
|
||||
return {
|
||||
key,
|
||||
label: String(source.label || key).trim(),
|
||||
required: source.required !== false,
|
||||
type,
|
||||
validation,
|
||||
options,
|
||||
mockValue: String(source.mockValue || '').trim(),
|
||||
}
|
||||
})
|
||||
.filter((item): item is RequirementField => Boolean(item))
|
||||
|
||||
if (fields.length > 0 && !isDefaultTemplateRequirementFields(fields)) {
|
||||
if (fields.length > 0) {
|
||||
return fields
|
||||
}
|
||||
return [
|
||||
@@ -47,6 +51,7 @@ export function normalizeRequirementFields(value: unknown) {
|
||||
label: '游戏编号',
|
||||
required: true,
|
||||
type: 'text',
|
||||
validation: 'digits',
|
||||
options: [],
|
||||
mockValue: 'test_uid',
|
||||
},
|
||||
@@ -55,6 +60,7 @@ export function normalizeRequirementFields(value: unknown) {
|
||||
label: '游戏昵称',
|
||||
required: true,
|
||||
type: 'text',
|
||||
validation: 'none',
|
||||
options: [],
|
||||
mockValue: '测试角色',
|
||||
},
|
||||
@@ -63,6 +69,7 @@ export function normalizeRequirementFields(value: unknown) {
|
||||
label: '系统',
|
||||
required: false,
|
||||
type: 'select',
|
||||
validation: 'none',
|
||||
options: ['安卓', '苹果'],
|
||||
mockValue: '安卓',
|
||||
},
|
||||
@@ -71,19 +78,13 @@ export function normalizeRequirementFields(value: unknown) {
|
||||
label: '区服',
|
||||
required: false,
|
||||
type: 'select',
|
||||
validation: 'none',
|
||||
options: ['QQ区', '微信区'],
|
||||
mockValue: 'QQ区',
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
function isDefaultTemplateRequirementFields(fields: RequirementField[]): boolean {
|
||||
if (fields.length === 0) return true
|
||||
const defaultKeys = new Set(['gameId', 'gameNickname', 'system', 'serverZone'])
|
||||
const legacyKeys = new Set(['gameAccount', 'serverName', 'roleName'])
|
||||
return fields.every((field) => defaultKeys.has(field.key) || legacyKeys.has(field.key))
|
||||
}
|
||||
|
||||
export function normalizeRequirementFieldsFromPayload(payload: JsonObject) {
|
||||
const directFields = Array.isArray(payload.fields) ? payload.fields : null
|
||||
const requirement =
|
||||
@@ -105,7 +106,7 @@ export function normalizeRequirementFieldsFromPayload(payload: JsonObject) {
|
||||
return textFields.length > 0 ? textFields : normalizeRequirementFields([])
|
||||
}
|
||||
|
||||
export function normalizeRequirementFieldsText(value: unknown) {
|
||||
export function normalizeRequirementFieldsText(value: unknown): RequirementField[] {
|
||||
return String(value || '')
|
||||
.split(/\r?\n/)
|
||||
.map((line, index) => {
|
||||
@@ -123,6 +124,7 @@ export function normalizeRequirementFieldsText(value: unknown) {
|
||||
label: label || key,
|
||||
required: true,
|
||||
type: rawOptions.length > 0 ? 'select' : 'text',
|
||||
validation: key === 'gameId' ? 'digits' : 'none',
|
||||
options: rawOptions,
|
||||
mockValue: '',
|
||||
}
|
||||
@@ -133,6 +135,7 @@ export function normalizeRequirementFieldsText(value: unknown) {
|
||||
label: plainLabel,
|
||||
required: true,
|
||||
type: plainOptions.length > 0 ? 'select' : 'text',
|
||||
validation: 'none',
|
||||
options: plainOptions,
|
||||
mockValue: '',
|
||||
}
|
||||
|
||||
@@ -1599,6 +1599,15 @@ test('normalizeRequirementFields default template marks serverZone optional with
|
||||
assert.deepEqual(serverZone?.options, ['QQ区', '微信区'])
|
||||
})
|
||||
|
||||
test('normalizeRequirementFields 只保留运营实际选择的字段,不把部分默认字段扩成四件套', () => {
|
||||
const fields = normalizeRequirementFields([
|
||||
{ key: 'gameId', label: '游戏 ID', type: 'text', required: true },
|
||||
])
|
||||
assert.equal(fields.length, 1)
|
||||
assert.equal(fields[0]?.key, 'gameId')
|
||||
assert.equal(fields[0]?.validation, 'digits')
|
||||
})
|
||||
|
||||
test('resolveSkuNameQuantity parses arabic quantities from sku names', () => {
|
||||
assert.equal(resolveSkuNameQuantity('指挥官秘钥1个'), 1)
|
||||
assert.equal(resolveSkuNameQuantity('指挥官秘钥15个'), 15)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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[]
|
||||
}
|
||||
|
||||
|
||||
@@ -83,8 +83,17 @@ work_orders.service_profile_snapshot_json
|
||||
|
||||
- 写入最新配置版本和快照;
|
||||
- 同步 `requirement_json.fields`;
|
||||
- 有有效拼单份额的工单不会覆盖;
|
||||
- 已提交、已指派、已进入履约的工单保持不变。
|
||||
|
||||
字段配置优先使用标准字段:
|
||||
|
||||
- `gameId`:游戏 ID,按字符串保存并校验为纯数字,避免超长 ID 精度丢失;
|
||||
- `gameNickname`:游戏昵称,允许中文、字母和符号;
|
||||
- `serverZone`:区服;
|
||||
- `system`、`gameAccount`、`roleLevel`、`contactPhone`、`orderNote` 作为可选预设;
|
||||
- 自定义字段必须填写有业务含义的 Key,不再自动生成随机 Key。
|
||||
|
||||
## 4. 客户页面
|
||||
|
||||
`/o` 支持:
|
||||
|
||||
Reference in New Issue
Block a user