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

807 lines
30 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,
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<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'),
[],
)
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<OrderServiceField>) {
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) => (
<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>
</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>
<Button icon={<PlusOutlined />} onClick={addField} type="dashed">
添加字段
</Button>
</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
value={field.key}
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 })
}
/>
{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' }
}