style(order-service): 优化配置页分类导航与布局
This commit is contained in:
@@ -4,16 +4,21 @@ import {
|
||||
DeleteOutlined,
|
||||
PlusOutlined,
|
||||
ReloadOutlined,
|
||||
SaveOutlined,
|
||||
SearchOutlined,
|
||||
} from '@ant-design/icons'
|
||||
import {
|
||||
Alert,
|
||||
App,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Col,
|
||||
Collapse,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
@@ -88,6 +93,7 @@ export default function OrderServicePanel() {
|
||||
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)
|
||||
@@ -106,8 +112,27 @@ export default function OrderServicePanel() {
|
||||
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 = profiles.find((item) => item.categoryId === selectedCategoryId) || null
|
||||
const profile = (selectedCategoryId ? profileByCategoryId.get(selectedCategoryId) : null) || null
|
||||
|
||||
useEffect(() => {
|
||||
if (selectedCategoryId !== null || categories.length === 0) return
|
||||
@@ -255,59 +280,10 @@ export default function OrderServicePanel() {
|
||||
}
|
||||
}
|
||||
|
||||
const categoryColumns = useMemo(
|
||||
() => [
|
||||
{ title: '分类', dataIndex: 'name' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (value: string) => (
|
||||
<Tag color={value === 'active' ? 'green' : 'default'}>
|
||||
{value === 'active' ? '启用' : '停用'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '订单服务',
|
||||
width: 180,
|
||||
render: (_: unknown, row: { categoryId: number }) => {
|
||||
const rowProfile = profiles.find((item) => item.categoryId === row.categoryId)
|
||||
if (!rowProfile) return <Tag>未配置</Tag>
|
||||
if (rowProfile.status === 'draft') {
|
||||
return (
|
||||
<Tag color={rowProfile.currentVersionNo ? 'orange' : 'default'}>
|
||||
{rowProfile.currentVersionNo
|
||||
? `有未发布修改(线上 v${rowProfile.currentVersionNo})`
|
||||
: '草稿'}
|
||||
</Tag>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<Tag color={rowProfile.status === 'published' ? 'green' : 'default'}>
|
||||
{rowProfile.status === 'published'
|
||||
? `已发布 v${rowProfile.currentVersionNo || 1}`
|
||||
: rowProfile.status}
|
||||
</Tag>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 90,
|
||||
render: (_: unknown, row: { categoryId: number }) => (
|
||||
<Button size="small" onClick={() => setSelectedCategoryId(row.categoryId)}>
|
||||
配置
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[profiles],
|
||||
)
|
||||
|
||||
const jobColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
key: 'order',
|
||||
title: '订单',
|
||||
render: (_: unknown, row: AdminOrderServiceAutoPublishJob) => (
|
||||
<Space direction="vertical" size={0}>
|
||||
@@ -317,24 +293,28 @@ export default function OrderServicePanel() {
|
||||
),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
@@ -352,10 +332,15 @@ export default function OrderServicePanel() {
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="page-stack">
|
||||
<div className="page-stack order-service-page">
|
||||
<Card
|
||||
size="small"
|
||||
title="订单服务配置"
|
||||
className="order-service-shell"
|
||||
title={
|
||||
<Space size={8}>
|
||||
<span>订单服务配置</span>
|
||||
<Tag color="blue">{configuredCount} 个分类已配置</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Button
|
||||
icon={<ReloadOutlined />}
|
||||
@@ -366,305 +351,415 @@ export default function OrderServicePanel() {
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="一个分类使用一套订单服务配置"
|
||||
description="接单模板仍通过“所属分类”绑定分类;客户资料字段只在这里维护,不再在接单模板里重复配置。"
|
||||
style={{ marginBottom: 12 }}
|
||||
/>
|
||||
<PercentTable
|
||||
rowKey="categoryId"
|
||||
size="small"
|
||||
loading={profilesQuery.isLoading}
|
||||
dataSource={categories}
|
||||
columns={categoryColumns}
|
||||
pagination={false}
|
||||
rowClassName={(row: { categoryId: number }) =>
|
||||
row.categoryId === selectedCategoryId ? 'ant-table-row-selected' : ''
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{selectedCategory ? (
|
||||
<Card
|
||||
size="small"
|
||||
title={`配置:${selectedCategory.name}`}
|
||||
extra={
|
||||
<Space size={6} wrap>
|
||||
<Tag color={profile?.status === 'published' ? 'green' : 'orange'}>
|
||||
{profile?.status === 'published'
|
||||
? `线上 v${profile.currentVersionNo || 1}`
|
||||
: profile?.currentVersionNo
|
||||
? `线上 v${profile.currentVersionNo},当前有草稿`
|
||||
: '未发布'}
|
||||
</Tag>
|
||||
<Button loading={publishing} onClick={() => void handlePublish()}>
|
||||
发布新版本
|
||||
</Button>
|
||||
{profile ? (
|
||||
<Button danger loading={publishing} onClick={() => void handleArchive()}>
|
||||
归档
|
||||
</Button>
|
||||
) : null}
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Form form={form} layout="vertical" onFinish={handleSave}>
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="配置名称" name="name" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="客户页标题" name="title">
|
||||
<Input placeholder="例如:蛋仔派对资料填写" />
|
||||
</Form.Item>
|
||||
</Col>
|
||||
<Col xs={24} md={8}>
|
||||
<Form.Item label="页面说明" name="description">
|
||||
<Input 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>
|
||||
|
||||
<Card size="small" title="用户引导图(一张)" style={{ marginBottom: 16 }}>
|
||||
<ImageUpload
|
||||
scene="order-service"
|
||||
scope="admin"
|
||||
maxCount={1}
|
||||
value={guideImage ? [guideImage as UploadedFile] : []}
|
||||
onChange={(files) => setGuideImage(files[0] || null)}
|
||||
uploader={(file, scene) => uploadFile(file, scene, 'admin')}
|
||||
<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)}
|
||||
/>
|
||||
</Card>
|
||||
</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>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="资料字段"
|
||||
extra={
|
||||
<Button icon={<PlusOutlined />} onClick={addField} size="small">
|
||||
添加字段
|
||||
</Button>
|
||||
}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
{fields.length === 0 ? (
|
||||
<Empty description="至少添加一个资料字段后才能发布" />
|
||||
) : (
|
||||
<Space direction="vertical" className="full-width">
|
||||
{fields.map((field, index) => (
|
||||
<Card key={`${field.key}-${index}`} size="small">
|
||||
<Row gutter={[12, 12]}>
|
||||
<Col xs={24} md={5}>
|
||||
<Input
|
||||
addonBefore="Key"
|
||||
value={field.key}
|
||||
onChange={(event) => updateField(index, { key: event.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={5}>
|
||||
<Input
|
||||
addonBefore="名称"
|
||||
value={field.label}
|
||||
onChange={(event) => updateField(index, { label: event.target.value })}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<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: '日期' },
|
||||
]}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={12} md={4}>
|
||||
<Space>
|
||||
<Text type="secondary">必填</Text>
|
||||
<Switch
|
||||
checked={field.required}
|
||||
onChange={(value) => updateField(index, { required: value })}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} md={6}>
|
||||
<Space>
|
||||
<Button
|
||||
disabled={index === 0}
|
||||
icon={<ArrowUpOutlined />}
|
||||
onClick={() => moveField(index, -1)}
|
||||
/>
|
||||
<Button
|
||||
disabled={index === fields.length - 1}
|
||||
icon={<ArrowDownOutlined />}
|
||||
onClick={() => moveField(index, 1)}
|
||||
/>
|
||||
<Button
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
onClick={() => removeField(index)}
|
||||
/>
|
||||
</Space>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Input
|
||||
addonBefore="占位提示"
|
||||
value={field.placeholder || ''}
|
||||
onChange={(event) =>
|
||||
updateField(index, { placeholder: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Input
|
||||
addonBefore="填写说明"
|
||||
value={field.helpText || ''}
|
||||
onChange={(event) =>
|
||||
updateField(index, { helpText: event.target.value })
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
{(field.type === 'select' || field.type === 'radio') && (
|
||||
<Col span={24}>
|
||||
<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),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} md={12}>
|
||||
<Card size="small" title="截图规则">
|
||||
<Form.Item name={['screenshots', 'label']} label="截图标题">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name={['screenshots', 'helpText']} label="截图说明">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Space size={24} wrap>
|
||||
<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="最少">
|
||||
<Input type="number" style={{ width: 90 }} />
|
||||
</Form.Item>
|
||||
<Form.Item name={['screenshots', 'maxCount']} label="最多">
|
||||
<Input type="number" style={{ width: 90 }} />
|
||||
</Form.Item>
|
||||
</Space>
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={24} md={12}>
|
||||
<Card size="small" title="自动发布并核销">
|
||||
<Space direction="vertical" className="full-width">
|
||||
<Space size={24} wrap>
|
||||
<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="额外重试次数">
|
||||
<Input type="number" min={0} max={10} style={{ width: 120 }} />
|
||||
</Form.Item>
|
||||
<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">
|
||||
开启后,客户提交完整资料会按当前工单快照自动发布;要求核销时,核销失败不会发布到大厅。
|
||||
</Text>
|
||||
<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>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
</div>
|
||||
|
||||
<Space style={{ marginTop: 16 }}>
|
||||
<Button
|
||||
htmlType="button"
|
||||
onClick={() => {
|
||||
form.resetFields()
|
||||
applyProfile(profile)
|
||||
}}
|
||||
>
|
||||
重置
|
||||
</Button>
|
||||
<Button htmlType="submit" loading={saving} type="primary">
|
||||
保存草稿
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Card>
|
||||
) : (
|
||||
<Card size="small">
|
||||
<Empty description="请选择分类" />
|
||||
</Card>
|
||||
)}
|
||||
<Alert
|
||||
className="order-service-tip"
|
||||
type="info"
|
||||
showIcon
|
||||
message="一个分类复用一套资料配置,接单模板通过“所属分类”自动关联。"
|
||||
/>
|
||||
|
||||
<Card
|
||||
size="small"
|
||||
title="自动发布失败任务"
|
||||
extra={<Tag color={failedJobs.length > 0 ? 'red' : 'default'}>{failedJobs.length}</Tag>}
|
||||
>
|
||||
<PercentTable
|
||||
rowKey="id"
|
||||
size="small"
|
||||
loading={jobsQuery.isLoading}
|
||||
dataSource={failedJobs}
|
||||
columns={jobColumns}
|
||||
pagination={false}
|
||||
/>
|
||||
<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={guideImage ? [guideImage as UploadedFile] : []}
|
||||
onChange={(files) => setGuideImage(files[0] || null)}
|
||||
uploader={(file, scene) => uploadFile(file, scene, 'admin')}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -687,3 +782,17 @@ function findDuplicateKeys(fields: OrderServiceField[]) {
|
||||
}
|
||||
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' }
|
||||
}
|
||||
|
||||
@@ -1645,3 +1645,389 @@
|
||||
.admin-share-row-cancelled:hover > td {
|
||||
background: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
/* 订单服务配置:分类导航 + 配置编辑器 */
|
||||
.order-service-shell {
|
||||
overflow: hidden;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.order-service-shell > .ant-card-head {
|
||||
min-height: 54px;
|
||||
background: linear-gradient(180deg, #ffffff 0%, #fbfcff 100%);
|
||||
}
|
||||
|
||||
.order-service-shell > .ant-card-body {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.order-service-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 270px minmax(0, 1fr);
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.order-service-sidebar {
|
||||
position: sticky;
|
||||
top: 12px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(100vh - 190px);
|
||||
max-height: calc(100vh - 180px);
|
||||
background: #f7f9fc;
|
||||
border-right: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.order-service-sidebar-head {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 14px 14px 10px;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.order-service-category-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
min-height: 0;
|
||||
padding: 8px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.order-service-category-item {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
width: 100%;
|
||||
padding: 9px 10px;
|
||||
color: #25324a;
|
||||
text-align: left;
|
||||
background: transparent;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 9px;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background 0.16s ease,
|
||||
border-color 0.16s ease,
|
||||
box-shadow 0.16s ease;
|
||||
}
|
||||
|
||||
.order-service-category-item:hover {
|
||||
background: #ffffff;
|
||||
border-color: #e5eaf2;
|
||||
}
|
||||
|
||||
.order-service-category-item.is-active {
|
||||
background: #ffffff;
|
||||
border-color: #91caff;
|
||||
box-shadow: 0 2px 8px rgba(22, 104, 220, 0.08);
|
||||
}
|
||||
|
||||
.order-service-category-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.order-service-category-name {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-service-category-meta {
|
||||
overflow: hidden;
|
||||
color: #8a94a6;
|
||||
font-size: 11px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.order-service-status-pill {
|
||||
flex: none;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
line-height: 17px;
|
||||
border: 1px solid #d9dde5;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.order-service-status-pill.is-success {
|
||||
color: #237804;
|
||||
background: #f6ffed;
|
||||
border-color: #b7eb8f;
|
||||
}
|
||||
|
||||
.order-service-status-pill.is-warning {
|
||||
color: #ad6800;
|
||||
background: #fffbe6;
|
||||
border-color: #ffe58f;
|
||||
}
|
||||
|
||||
.order-service-status-pill.is-muted {
|
||||
color: #8c8c8c;
|
||||
background: #fafafa;
|
||||
}
|
||||
|
||||
.order-service-editor {
|
||||
min-width: 0;
|
||||
padding: 20px 22px 0;
|
||||
}
|
||||
|
||||
.order-service-editor-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
padding-bottom: 14px;
|
||||
}
|
||||
|
||||
.order-service-editor-head .ant-typography {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.order-service-tip {
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.order-service-section {
|
||||
padding: 16px;
|
||||
margin-bottom: 14px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.order-service-section-fill {
|
||||
height: 100%;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.order-service-section-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
margin-bottom: 14px;
|
||||
}
|
||||
|
||||
.order-service-section-head > div:first-child {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.order-service-basic-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 230px;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.order-service-basic-grid .ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.order-service-guide {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 4px;
|
||||
padding: 12px;
|
||||
background: #f8fafc;
|
||||
border: 1px dashed #d9e1ec;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.order-service-guide .ant-upload-wrapper {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.order-service-field-list {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.order-service-field-row {
|
||||
padding: 12px;
|
||||
background: #fbfcfe;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.order-service-field-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(120px, 0.9fr) minmax(150px, 1fr) 120px 70px 104px;
|
||||
gap: 10px;
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.order-service-field-main label,
|
||||
.order-service-field-required {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.order-service-field-required {
|
||||
justify-items: start;
|
||||
}
|
||||
|
||||
.order-service-field-required .ant-switch {
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.order-service-field-actions {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.order-service-field-extra {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 8px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.order-service-field-extra > :last-child:nth-child(3) {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
|
||||
.order-service-switch-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.order-service-switch-grid.is-auto-publish {
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.order-service-switch-grid .ant-form-item {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.order-service-inline-tip {
|
||||
display: block;
|
||||
margin-top: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.order-service-save-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
z-index: 4;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 12px 4px 14px;
|
||||
margin-top: 2px;
|
||||
background: linear-gradient(180deg, rgba(245, 247, 251, 0) 0%, #f5f7fb 28%, #f5f7fb 100%);
|
||||
}
|
||||
|
||||
.order-service-jobs-collapse {
|
||||
margin-top: 12px;
|
||||
background: #ffffff;
|
||||
border: 1px solid #edf0f5;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.order-service-jobs-collapse .ant-collapse-header {
|
||||
align-items: center !important;
|
||||
}
|
||||
|
||||
.order-service-jobs-collapse .ant-collapse-content-box {
|
||||
padding-top: 0 !important;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.order-service-field-main {
|
||||
grid-template-columns: minmax(120px, 1fr) minmax(140px, 1fr) 110px 64px 104px;
|
||||
}
|
||||
|
||||
.order-service-basic-grid {
|
||||
grid-template-columns: minmax(0, 1fr) 200px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.order-service-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.order-service-sidebar {
|
||||
position: static;
|
||||
min-height: 0;
|
||||
max-height: 280px;
|
||||
border-right: 0;
|
||||
border-bottom: 1px solid #edf0f5;
|
||||
}
|
||||
|
||||
.order-service-category-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
max-height: 200px;
|
||||
}
|
||||
|
||||
.order-service-editor {
|
||||
padding: 16px 16px 0;
|
||||
}
|
||||
|
||||
.order-service-basic-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.order-service-guide {
|
||||
grid-template-columns: minmax(0, 1fr) 160px;
|
||||
}
|
||||
|
||||
.order-service-guide > :not(.ant-upload-wrapper) {
|
||||
grid-column: 1;
|
||||
}
|
||||
|
||||
.order-service-guide .ant-upload-wrapper {
|
||||
grid-column: 2;
|
||||
grid-row: 1 / span 3;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 720px) {
|
||||
.order-service-editor-head,
|
||||
.order-service-save-bar {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.order-service-category-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.order-service-field-main {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.order-service-field-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.order-service-field-extra,
|
||||
.order-service-switch-grid,
|
||||
.order-service-switch-grid.is-auto-publish {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.order-service-guide {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.order-service-guide > :not(.ant-upload-wrapper),
|
||||
.order-service-guide .ant-upload-wrapper {
|
||||
grid-column: 1;
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user