feat(category): 接单平台支持二级分类目录与接单大厅联动展示及限额优化

This commit is contained in:
yml2213
2026-09-12 18:45:56 +08:00
parent 111f400bdb
commit d1e06f362e
17 changed files with 819 additions and 135 deletions
@@ -1,6 +1,8 @@
import { UploadOutlined } from '@ant-design/icons'
import { useQuery } from '@tanstack/react-query'
import {
App,
Avatar,
Button,
Card,
Form,
@@ -12,22 +14,35 @@ import {
Table,
Tag,
Typography,
Upload,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useState } from 'react'
import { useMemo, useState } from 'react'
import {
deleteAdminWorkCategory,
fetchAdminWorkCategories,
saveAdminWorkCategory,
} from '@/services/admin'
import { uploadFile } from '@/services/files'
import type { WorkCategory } from '@/types/worker-platform'
type CategoryFormValues = {
categoryKey: string
name: string
parentId?: number
iconUrl?: string
sortOrder?: number
status?: string
}
export default function CategoriesPanel() {
const { message } = App.useApp()
const [form] = Form.useForm()
const [form] = Form.useForm<CategoryFormValues>()
const [editingCategory, setEditingCategory] = useState<WorkCategory | null>(null)
const [deletingCategory, setDeletingCategory] = useState<WorkCategory | null>(null)
const [uploadingIcon, setUploadingIcon] = useState(false)
const categoriesQuery = useQuery({
queryKey: ['admin-worker-platform-categories'],
queryFn: () => fetchAdminWorkCategories(),
@@ -37,11 +52,56 @@ export default function CategoriesPanel() {
await categoriesQuery.refetch()
}
const rawCategories = categoriesQuery.data?.data.items || []
// 一级分类选项(排除正在编辑的分类自身,防止自引用)
const parentOptions = useMemo(() => {
const level1Categories = rawCategories.filter(
(c) => !c.parentId && (!editingCategory || c.categoryId !== editingCategory.categoryId),
)
return [
{ value: 0, label: '无(作为一级分类)' },
...level1Categories.map((c) => ({
value: c.categoryId,
label: `${c.name} (${c.categoryKey})`,
})),
]
}, [rawCategories, editingCategory])
// 构建树形表格数据(一级分类包含 children,二级分类挂在下面)
const treeData = useMemo(() => {
const rootCategories = rawCategories.filter((item) => !item.parentId)
const childrenMap = new Map<number, WorkCategory[]>()
for (const item of rawCategories) {
if (item.parentId) {
const list = childrenMap.get(item.parentId) || []
list.push(item)
childrenMap.set(item.parentId, list)
}
}
return rootCategories.map((root) => {
const children = childrenMap.get(root.categoryId) || []
return {
...root,
children:
children.length > 0
? children.map((c) => ({
...c,
parentName: root.name,
}))
: undefined,
}
})
}, [rawCategories])
function startEdit(category: WorkCategory) {
setEditingCategory(category)
form.setFieldsValue({
categoryKey: category.categoryKey,
name: category.name,
parentId: category.parentId || 0,
iconUrl: category.iconUrl || '',
sortOrder: category.sortOrder,
status: category.status,
})
@@ -52,14 +112,30 @@ export default function CategoriesPanel() {
form.resetFields()
}
async function saveCategory(values: {
categoryKey: string
name: string
sortOrder?: number
status?: string
}) {
async function handleUploadIcon(file: File) {
setUploadingIcon(true)
try {
await saveAdminWorkCategory(values)
const uploaded = await uploadFile(file, 'category_icon', 'admin')
form.setFieldValue('iconUrl', uploaded.url)
message.success('图标上传成功')
} catch (error) {
message.error(error instanceof Error ? error.message : '图标上传失败')
} finally {
setUploadingIcon(false)
}
return false
}
async function saveCategory(values: CategoryFormValues) {
try {
await saveAdminWorkCategory({
categoryKey: values.categoryKey,
name: values.name,
parentId: values.parentId ? Number(values.parentId) : null,
iconUrl: values.iconUrl ? String(values.iconUrl).trim() : null,
sortOrder: values.sortOrder,
status: values.status,
})
message.success(editingCategory ? '分类已更新' : '分类已保存')
cancelEdit()
await refreshCategories()
@@ -73,6 +149,8 @@ export default function CategoriesPanel() {
await saveAdminWorkCategory({
categoryKey: category.categoryKey,
name: category.name,
parentId: category.parentId || null,
iconUrl: category.iconUrl || null,
sortOrder: category.sortOrder,
status: category.status === 'active' ? 'disabled' : 'active',
})
@@ -97,12 +175,55 @@ export default function CategoriesPanel() {
}
const columns: TableColumnsType<WorkCategory> = [
{ title: '分类', dataIndex: 'name' },
{ title: '标识', dataIndex: 'categoryKey' },
{ title: '排序', dataIndex: 'sortOrder', width: 100 },
{
title: '分类名称',
dataIndex: 'name',
render: (_, row) => (
<Space align="center">
{row.iconUrl ? (
<Avatar src={row.iconUrl} size={28} shape="square" />
) : (
<Avatar
size={28}
shape="square"
style={{
backgroundColor: row.parentId ? '#bae0ff' : '#ffd591',
color: '#1f1f1f',
fontSize: 13,
}}
>
{row.name.slice(0, 1)}
</Avatar>
)}
<span>{row.name}</span>
{row.parentId ? (
<Tag color="blue" style={{ marginLeft: 4 }}>
二级
</Tag>
) : (
<Tag color="orange" style={{ marginLeft: 4 }}>
一级
</Tag>
)}
</Space>
),
},
{
title: '上级分类',
width: 140,
render: (_, row) => {
if (!row.parentId) {
return <Typography.Text type="secondary">-</Typography.Text>
}
const parent = rawCategories.find((c) => c.categoryId === row.parentId)
return <Tag color="geekblue">{parent ? parent.name : `ID: ${row.parentId}`}</Tag>
},
},
{ title: '标识', dataIndex: 'categoryKey', width: 150 },
{ title: '排序', dataIndex: 'sortOrder', width: 90 },
{
title: '状态',
width: 120,
width: 100,
render: (_, row) => (
<Tag color={row.status === 'active' ? 'green' : 'default'}>
{row.status === 'active' ? '启用' : '停用'}
@@ -111,7 +232,7 @@ export default function CategoriesPanel() {
},
{
title: '操作',
width: 240,
width: 220,
render: (_, row) => (
<Space wrap>
<Button size="small" onClick={() => startEdit(row)}>
@@ -136,33 +257,61 @@ export default function CategoriesPanel() {
>
<Form
form={form}
layout="inline"
initialValues={{ status: 'active', sortOrder: 100 }}
layout="vertical"
initialValues={{ parentId: 0, status: 'active', sortOrder: 100 }}
onFinish={saveCategory}
>
<Form.Item
label="标识"
name="categoryKey"
rules={[{ required: true, message: '请输入标识' }]}
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(200px, 1fr))',
gap: '12px 16px',
}}
>
<Input />
</Form.Item>
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
<Input />
</Form.Item>
<Form.Item label="排序" name="sortOrder">
<InputNumber min={0} max={9999} />
</Form.Item>
<Form.Item label="状态" name="status">
<Select
style={{ width: 110 }}
options={[
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '停用' },
]}
/>
</Form.Item>
<Form.Item>
<Form.Item
label="上级分类"
name="parentId"
tooltip="选择一级分类即可设为该分类下的二级目录;留空或选「无」则作为独立一级分类。"
>
<Select options={parentOptions} />
</Form.Item>
<Form.Item
label="标识"
name="categoryKey"
rules={[{ required: true, message: '请输入标识' }]}
>
<Input placeholder="如 hpjy、dtts" />
</Form.Item>
<Form.Item label="名称" name="name" rules={[{ required: true, message: '请输入名称' }]}>
<Input placeholder="如 和平精英、地铁装备" />
</Form.Item>
<Form.Item
label="分类图标"
name="iconUrl"
tooltip="在接单大厅等位置展示的图标。支持直接填写链接,或点击右侧按钮上传。"
>
<Space.Compact style={{ width: '100%' }}>
<Input placeholder="图标 URL(可选)" />
<Upload showUploadList={false} beforeUpload={handleUploadIcon} accept="image/*">
<Button icon={<UploadOutlined />} loading={uploadingIcon}>
上传
</Button>
</Upload>
</Space.Compact>
</Form.Item>
<Form.Item label="排序" name="sortOrder">
<InputNumber min={0} max={9999} style={{ width: '100%' }} />
</Form.Item>
<Form.Item label="状态" name="status">
<Select
options={[
{ value: 'active', label: '启用' },
{ value: 'disabled', label: '停用' },
]}
/>
</Form.Item>
</div>
<Form.Item style={{ marginBottom: 0 }}>
<Space>
<Button type="primary" htmlType="submit">
保存
@@ -172,13 +321,14 @@ export default function CategoriesPanel() {
</Form.Item>
</Form>
</Card>
<Card title="分类列表" bordered={false}>
<Card title="分类列表(树形层级)" bordered={false}>
<Table<WorkCategory>
rowKey="categoryId"
loading={categoriesQuery.isLoading}
dataSource={categoriesQuery.data?.data.items || []}
dataSource={treeData}
columns={columns}
pagination={false}
defaultExpandAllRows
/>
</Card>
<Modal
@@ -192,7 +342,7 @@ export default function CategoriesPanel() {
>
<Typography.Text>
确定删除分类「{deletingCategory?.name}
」?已引用该分类的工单和物品规则将无法删除,需先处理。
」?已引用该分类的工单、接单模板或下属子分类将无法删除,需先处理关联。
</Typography.Text>
</Modal>
</section>
@@ -1,6 +1,18 @@
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { App, Button, Card, Descriptions, Form, InputNumber, Space, Table, Tag } from 'antd'
import { useEffect, useState } from 'react'
import {
App,
Avatar,
Button,
Card,
Descriptions,
Form,
InputNumber,
Space,
Switch,
Table,
Tag,
} from 'antd'
import { useEffect, useMemo, useState } from 'react'
import {
fetchAdminWorkCategories,
@@ -11,6 +23,7 @@ import type { WorkCategory } from '@/types/worker-platform'
type HallConfigFormValues = {
maxVisibleOrders: number
showSubcategories: boolean
categoryMaxVisibleOrders: Record<string, number>
}
@@ -29,20 +42,44 @@ export default function HallConfigPanel() {
if (config) {
form.setFieldsValue({
maxVisibleOrders: config.maxVisibleOrders,
showSubcategories: config.showSubcategories ?? true,
categoryMaxVisibleOrders: config.categoryMaxVisibleOrders || {},
})
}
}, [config, form])
const categoriesQuery = useQuery({
queryKey: ['admin-worker-platform-categories'],
queryFn: fetchAdminWorkCategories,
})
const treeData = useMemo(() => {
const rawCategories = categoriesQuery.data?.data.items || []
const rootCategories = rawCategories.filter((item) => !item.parentId)
const childrenMap = new Map<number, WorkCategory[]>()
for (const item of rawCategories) {
if (item.parentId) {
const list = childrenMap.get(item.parentId) || []
list.push(item)
childrenMap.set(item.parentId, list)
}
}
return rootCategories.map((root) => {
const children = childrenMap.get(root.categoryId) || []
return {
...root,
children: children.length > 0 ? children : undefined,
}
})
}, [categoriesQuery.data?.data.items])
async function saveConfig(values: HallConfigFormValues) {
setSaving(true)
try {
await saveAdminWorkerHallConfig({
maxVisibleOrders: Number(values.maxVisibleOrders),
showSubcategories: Boolean(values.showSubcategories),
categoryMaxVisibleOrders: values.categoryMaxVisibleOrders || {},
})
await Promise.all([
@@ -59,29 +96,43 @@ export default function HallConfigPanel() {
return (
<Card title="大厅配置" bordered={false} loading={configQuery.isLoading}>
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 560 }}>
<Space direction="vertical" size="middle" style={{ width: '100%', maxWidth: 640 }}>
<Descriptions size="small" column={3}>
<Descriptions.Item label="展示中">{config?.visibleOrderCount || 0}</Descriptions.Item>
<Descriptions.Item label="等待入厅">{config?.queuedOrderCount || 0}</Descriptions.Item>
<Descriptions.Item label="当前上限">{config?.maxVisibleOrders || '-'}</Descriptions.Item>
</Descriptions>
<Form form={form} layout="vertical" onFinish={saveConfig}>
<Form.Item
name="maxVisibleOrders"
label="大厅订单总数"
rules={[{ required: true, message: '请输入大厅订单总数' }]}
>
<InputNumber min={1} max={500} precision={0} style={{ width: 160 }} />
</Form.Item>
<Space size="large" align="start" wrap style={{ marginBottom: 8 }}>
<Form.Item
name="maxVisibleOrders"
label="大厅订单总数"
rules={[{ required: true, message: '请输入大厅订单总数' }]}
style={{ marginBottom: 12 }}
>
<InputNumber min={1} max={500} precision={0} style={{ width: 160 }} />
</Form.Item>
<Form.Item
name="showSubcategories"
label="大厅二级目录展示"
valuePropName="checked"
tooltip="开启后,接单大厅一级分类下方联动展示其二级分类选项;关闭后大厅只展示一级分类。"
style={{ marginBottom: 12 }}
>
<Switch checkedChildren="开启展示" unCheckedChildren="关闭展示" />
</Form.Item>
</Space>
<Form.Item>
<Button type="primary" htmlType="submit" loading={saving}>
保存
保存配置
</Button>
</Form.Item>
<div>
<Tag color="blue">分类上限</Tag>
<span style={{ marginLeft: 8, color: '#666' }}>
未填写的分类沿用大厅总上限;填写后仅限制该分类最多展示数量。
<Tag color="blue">分类最多展示上限</Tag>
<span style={{ marginLeft: 8, color: '#666', fontSize: 13 }}>
一级分类限额将限制该游戏及旗下所有子分类的总单量;二级分类可单独设置细化限额。
</span>
</div>
<Table<WorkCategory>
@@ -89,11 +140,39 @@ export default function HallConfigPanel() {
size="small"
pagination={false}
loading={categoriesQuery.isLoading}
dataSource={categoriesQuery.data?.data.items || []}
dataSource={treeData}
defaultExpandAllRows
columns={[
{ title: '分类', dataIndex: 'name' },
{
title: '分类名称',
dataIndex: 'name',
render: (_, category) => (
<Space size="small">
{category.iconUrl ? (
<Avatar src={category.iconUrl} size={20} shape="square" />
) : null}
<span>{category.name}</span>
{category.parentId ? (
<Tag
color="blue"
style={{ fontSize: 11, lineHeight: '18px', padding: '0 4px' }}
>
二级
</Tag>
) : (
<Tag
color="orange"
style={{ fontSize: 11, lineHeight: '18px', padding: '0 4px' }}
>
一级
</Tag>
)}
</Space>
),
},
{
title: '状态',
width: 90,
render: (_, category) => (
<Tag color={category.status === 'active' ? 'green' : 'default'}>
{category.status === 'active' ? '启用' : '停用'}
@@ -102,12 +181,19 @@ export default function HallConfigPanel() {
},
{
title: '最多展示',
width: 180,
render: (_, category) => (
<Form.Item
name={['categoryMaxVisibleOrders', String(category.categoryId)]}
style={{ margin: 0 }}
>
<InputNumber min={1} max={500} precision={0} placeholder="跟随总上限" />
<InputNumber
min={1}
max={500}
precision={0}
placeholder={category.parentId ? '跟随父级' : '跟随总上限'}
style={{ width: '100%' }}
/>
</Form.Item>
),
},
@@ -41,7 +41,12 @@ import {
saveAdminWorkProductRule,
syncAdminWorkProductRulePrice,
} from '@/services/admin'
import type { CollectField, WorkProductRule, WorkProductRuleMapping } from '@/types/worker-platform'
import type {
CollectField,
WorkCategory,
WorkProductRule,
WorkProductRuleMapping,
} from '@/types/worker-platform'
import type { AdminKuaishouIndustryShopOption } from '@/types/admin'
import { ADMIN_DEFAULT_PAGE_SIZE, buildAdminTablePagination } from '@/utils/admin-pagination'
import { formatMoney } from './shared'
@@ -92,6 +97,36 @@ export default function ProductRulesPanel() {
queryKey: ['admin-worker-platform-categories'],
queryFn: () => fetchAdminWorkCategories(),
})
const categoryOptions = useMemo(() => {
const raw = categoriesQuery.data?.data.items || []
const rootCategories = raw.filter((c) => !c.parentId)
const childrenMap = new Map<number, typeof raw>()
for (const c of raw) {
if (c.parentId) {
const list = childrenMap.get(c.parentId) || []
list.push(c)
childrenMap.set(c.parentId, list)
}
}
const result: Array<{ label: string; value: number }> = []
for (const root of rootCategories) {
const children = childrenMap.get(root.categoryId) || []
result.push({ label: root.name, value: root.categoryId })
for (const child of children) {
result.push({ label: ` └ ${child.name}`, value: child.categoryId })
}
}
return result
}, [categoriesQuery.data?.data.items])
const categoryMap = useMemo(() => {
const map = new Map<number, WorkCategory>()
for (const item of categoriesQuery.data?.data.items || []) {
map.set(item.categoryId, item)
}
return map
}, [categoriesQuery.data?.data.items])
const shopsQuery = useQuery({
queryKey: ['admin-kuaishou-industry-shops', 'product-rules'],
queryFn: fetchAdminKuaishouIndustryShops,
@@ -777,10 +812,7 @@ export default function ProductRulesPanel() {
allowClear
placeholder="选择分类"
loading={categoriesQuery.isLoading}
options={(categoriesQuery.data?.data.items || []).map((item) => ({
value: item.categoryId,
label: item.name,
}))}
options={categoryOptions}
/>
</Form.Item>
</div>
@@ -1113,9 +1145,11 @@ export default function ProductRulesPanel() {
{ key: 'all', label: `全部 (${rulesPagination?.total || 0})` },
...(categoriesQuery.data?.data.items || []).map((item) => {
const count = categoryCounts.get(String(item.categoryId)) || 0
const parent = item.parentId ? categoryMap.get(item.parentId) : null
const displayName = parent ? `${parent.name} · ${item.name}` : item.name
return {
key: String(item.categoryId),
label: `${item.name}${count ? ` (${count})` : ''}`,
label: `${displayName}${count ? ` (${count})` : ''}`,
}
}),
]}
@@ -35,7 +35,7 @@ import {
Typography,
} from 'antd'
import type { TableColumnsType } from 'antd'
import { useEffect, useRef, useState } from 'react'
import { useEffect, useMemo, useRef, useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router'
import JsonPreview from '@/components/admin/JsonPreview'
@@ -325,6 +325,50 @@ export default function WorkOrdersPanel() {
queryKey: ['admin-worker-platform-categories'],
queryFn: () => fetchAdminWorkCategories(),
})
const categoryOptions = useMemo(() => {
const raw = categoriesQuery.data?.data.items || []
const rootCategories = raw.filter((c) => !c.parentId)
const childrenMap = new Map<number, typeof raw>()
for (const c of raw) {
if (c.parentId) {
const list = childrenMap.get(c.parentId) || []
list.push(c)
childrenMap.set(c.parentId, list)
}
}
const result: Array<{ label: string; value: number }> = []
for (const root of rootCategories) {
const children = childrenMap.get(root.categoryId) || []
result.push({ label: root.name, value: root.categoryId })
for (const child of children) {
result.push({ label: ` └ ${child.name}`, value: child.categoryId })
}
}
return result
}, [categoriesQuery.data?.data.items])
const activeCategoryOptions = useMemo(() => {
const raw = (categoriesQuery.data?.data.items || []).filter((c) => c.status === 'active')
const rootCategories = raw.filter((c) => !c.parentId)
const childrenMap = new Map<number, typeof raw>()
for (const c of raw) {
if (c.parentId) {
const list = childrenMap.get(c.parentId) || []
list.push(c)
childrenMap.set(c.parentId, list)
}
}
const result: Array<{ label: string; value: number }> = []
for (const root of rootCategories) {
const children = childrenMap.get(root.categoryId) || []
result.push({ label: root.name, value: root.categoryId })
for (const child of children) {
result.push({ label: ` └ ${child.name}`, value: child.categoryId })
}
}
return result
}, [categoriesQuery.data?.data.items])
const workerLevelsQuery = useQuery({
queryKey: ['admin-worker-platform-levels'],
queryFn: () => fetchAdminWorkerLevels(),
@@ -1376,10 +1420,7 @@ export default function WorkOrdersPanel() {
placeholder="全部接单分类"
style={{ width: 180 }}
loading={categoriesQuery.isLoading}
options={(categoriesQuery.data?.data.items || []).map((category) => ({
value: category.categoryId,
label: category.name,
}))}
options={categoryOptions}
onChange={(nextCategoryId) => {
setCategoryId(nextCategoryId)
setPage(1)
@@ -1892,16 +1933,7 @@ export default function WorkOrdersPanel() {
<Input placeholder="留空表示无平台订单号" />
</Form.Item>
<Form.Item label="分类" name="categoryId">
<Select
allowClear
placeholder="选择接单分类"
options={(categoriesQuery.data?.data.items || [])
.filter((category) => category.status === 'active')
.map((category) => ({
label: category.name,
value: category.categoryId,
}))}
/>
<Select allowClear placeholder="选择接单分类" options={activeCategoryOptions} />
</Form.Item>
<Form.Item
label="接单金额"