style(order-service): 优化配置页分类导航与布局

This commit is contained in:
yml2213
2026-11-04 11:48:30 +08:00
parent 9f14e8de7a
commit bb692754af
2 changed files with 841 additions and 346 deletions
@@ -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' }
}
+386
View File
@@ -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;
}
}