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 const FIELD_PRESETS: Record = { 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() const [selectedCategoryId, setSelectedCategoryId] = useState(null) const [categoryKeyword, setCategoryKeyword] = useState('') const [fields, setFields] = useState([]) const [guideImage, setGuideImage] = useState | 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) { 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) => ( {row.productName || '-'} {row.platformOrderId || '-'} ), }, { key: 'attempts', title: '重试', width: 90, render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => `${row.attemptCount}/${row.maxAttempts}`, }, { key: 'error', title: '失败原因', render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => ( {row.lastError || '未知错误'} ), }, { key: 'updatedAt', title: '更新时间', width: 180, render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => row.updatedAt ? formatAdminDateTime(row.updatedAt) : '-', }, { key: 'actions', title: '操作', width: 100, render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => ( ), }, ], [retryingJobId], ) return (
订单服务配置 {configuredCount} 个分类已配置 } extra={ } >
{selectedCategory ? ( <>
{selectedCategory.name} {selectedCategory.status === 'active' ? '启用' : '停用'} {profile?.status === 'published' ? `线上 v${profile.currentVersionNo || 1}` : profile?.currentVersionNo ? `线上 v${profile.currentVersionNo} · 有草稿` : '尚未发布'} 分类标识:{selectedCategory.categoryKey}
{profile ? ( ) : null}
基础信息 客户打开 `/o` 后看到的标题与提示。
用户引导图 首版一张,展示在字段上方
客户状态进度文案 四个节点独立展示;标题保持简短,补充说明可填写引导文案。
{STATUS_STEP_OPTIONS.map((step) => (
{step.label}
))}
提交资料确认 客户点击提交时弹窗确认,可自定义提示内容和按钮文字。
启用确认
资料字段 优先添加常用字段;标准字段会自动支持搜索、复制和资料匹配。
addFieldPreset(String(key)), }} trigger={['click']} >
{fields.length === 0 ? ( ) : (
{fields.map((field, index) => (
))}
)}
截图规则 控制客户上传买家主页截图的限制。
自动发布并核销 客户提交后按工单快照自动处理。
要求核销时,核销失败不会发布到大厅,会进入失败任务。
保存只更新草稿,发布后才会影响新工单。
) : ( )}
自动发布失败任务 0 ? 'red' : 'default'} /> ), children: failedJobs.length > 0 ? ( ) : ( ), }, ]} />
) } function normalizeFieldOrder(fields: OrderServiceField[]) { return fields.map((field, index) => ({ ...field, sortOrder: (index + 1) * 10, })) } function findDuplicateKeys(fields: OrderServiceField[]) { const seen = new Set() const duplicates = new Set() 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 (
{confirmation.title || '提交资料确认'}
商品:示例商品 游戏账号:customer@example.com 区服:安卓 截图:2 张
{confirmation.content || '提交资料确认内容'}
{confirmation.cancelText || '返回检查'} {confirmation.confirmText || '确认提交'}
{!confirmation.enabled ? 当前已关闭,点击提交将直接提交资料 : null}
) }