import { ArrowDownOutlined, ArrowUpOutlined, DeleteOutlined, PlusOutlined, ReloadOutlined, SaveOutlined, SearchOutlined, } from '@ant-design/icons' import { Alert, App, Badge, Button, Card, Col, Collapse, Empty, Form, Input, InputNumber, Row, Select, Space, Switch, Tag, Typography, } 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, } 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 screenshots: ScreenshotForm autoPublish: AutoPublishForm } 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, } 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'), [], ) 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 || '资料已提交,请等待处理', screenshots: nextProfile?.screenshots || DEFAULT_SCREENSHOTS, autoPublish: nextProfile?.autoPublish || DEFAULT_AUTO_PUBLISH, }) setFields(normalizeFieldOrder(nextProfile?.fields || [])) 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 addField() { setFields((current) => normalizeFieldOrder([ ...current, { key: `field${Date.now().toString(36)}`, label: '新字段', type: 'text', required: true, placeholder: '', helpText: '', options: [], sortOrder: (current.length + 1) * 10, }, ]), ) } function updateField(index: number, patch: Partial) { setFields((current) => current.map((row, rowIndex) => (rowIndex === index ? { ...row, ...patch } : row)), ) } 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 } 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, submitText: values.submitText, successText: values.successText, }) 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) message.success(`已发布 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` 后看到的标题与提示。
用户引导图 首版一张,展示在字段上方
资料字段 客户必须填写或选填的资料,可调整顺序。
{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' } }