feat(product-rules): 接单模板列表支持二级目录层级联动筛选

This commit is contained in:
yml2213
2026-09-13 10:51:36 +08:00
parent 447d501f7b
commit 9fcc4ef99d
3 changed files with 240 additions and 37 deletions
@@ -42,7 +42,9 @@ function buildWorkProductRuleWhere({
}
if (categoryId) {
params.push(categoryId)
filters.push(`wpr.category_id = $${params.length}`)
filters.push(
`(wpr.category_id = $${params.length} OR wpr.category_id IN (SELECT id FROM work_categories WHERE parent_id = $${params.length}))`,
)
}
return {
whereClause: filters.length > 0 ? `WHERE ${filters.join(' AND ')}` : '',
@@ -1,6 +1,7 @@
import {
DeleteOutlined,
EditOutlined,
FolderOpenOutlined,
FormOutlined,
LinkOutlined,
PlusOutlined,
@@ -67,6 +68,8 @@ export default function ProductRulesPanel() {
const [keywordInput, setKeywordInput] = useState('')
const [keyword, setKeyword] = useState('')
const [selectedRootKey, setSelectedRootKey] = useState<string>('all')
const [selectedSubKey, setSelectedSubKey] = useState<string>('all')
const [categoryId, setCategoryId] = useState<number | undefined>()
const [page, setPage] = useState(1)
const [pageSize, setPageSize] = useState(ADMIN_DEFAULT_PAGE_SIZE)
@@ -145,12 +148,64 @@ export default function ProductRulesPanel() {
mappingsByRuleId.set(mapping.ruleId, rMappings)
}
const rulesPagination = rulesQuery.data?.data.pagination
const categoryCounts = new Map(
(rulesQuery.data?.data.categoryCounts || []).map((item) => [
item.categoryId === null ? 'uncategorized' : String(item.categoryId),
item.total,
]),
)
const categoryCounts = useMemo(() => {
return new Map<string, number>(
(rulesQuery.data?.data.categoryCounts || []).map((item) => [
item.categoryId === null ? 'uncategorized' : String(item.categoryId),
item.total,
]),
)
}, [rulesQuery.data?.data.categoryCounts])
// 整理层级分类结构与汇总数量
const { rootCategories, childrenMap, rootCategoryCounts } = useMemo(() => {
const raw = categoriesQuery.data?.data.items || []
const roots = raw
.filter((c) => !c.parentId)
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
const childrenByParent = new Map<number, WorkCategory[]>()
for (const c of raw) {
if (c.parentId) {
const list = childrenByParent.get(c.parentId) || []
list.push(c)
childrenByParent.set(c.parentId, list)
}
}
for (const list of childrenByParent.values()) {
list.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
}
const counts = new Map<number, number>()
for (const root of roots) {
const direct = categoryCounts.get(String(root.categoryId)) || 0
const children = childrenByParent.get(root.categoryId) || []
const subTotal = children.reduce(
(sum, child) => sum + (categoryCounts.get(String(child.categoryId)) || 0),
0,
)
counts.set(root.categoryId, direct + subTotal)
}
return {
rootCategories: roots,
childrenMap: childrenByParent,
rootCategoryCounts: counts,
}
}, [categoriesQuery.data?.data.items, categoryCounts])
const activeRootCategory = useMemo(() => {
if (selectedRootKey === 'all' || selectedRootKey === 'uncategorized') return null
return rootCategories.find((r) => String(r.categoryId) === selectedRootKey) || null
}, [selectedRootKey, rootCategories])
const activeSubCategories = useMemo(() => {
if (!activeRootCategory) return []
return childrenMap.get(activeRootCategory.categoryId) || []
}, [activeRootCategory, childrenMap])
const activeRootTotalCount = activeRootCategory
? rootCategoryCounts.get(activeRootCategory.categoryId) || 0
: 0
// 首次加载时默认选中第一条;新建时必须保留空表单,不能再次自动选回旧模板。
useEffect(() => {
@@ -601,28 +656,38 @@ export default function ProductRulesPanel() {
title: '模板 / 分类',
key: 'rule',
width: '42%',
render: (_, row) => (
<div className="cell-stack">
<Typography.Text strong title={row.productName || row.ruleKey} ellipsis>
{row.productName || row.ruleKey}
</Typography.Text>
<Space size={4} wrap style={{ marginTop: 2 }}>
<Tag color="blue" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
{row.ruleKey}
</Tag>
{row.categoryName ? (
<Tag color="cyan" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
{row.categoryName}
render: (_, row) => {
const category = row.categoryId ? categoryMap.get(row.categoryId) : null
const parentCategory = category?.parentId ? categoryMap.get(category.parentId) : null
const categoryDisplay = category
? parentCategory
? `${parentCategory.name} / ${category.name}`
: category.name
: row.categoryName
return (
<div className="cell-stack">
<Typography.Text strong title={row.productName || row.ruleKey} ellipsis>
{row.productName || row.ruleKey}
</Typography.Text>
<Space size={4} wrap style={{ marginTop: 2 }}>
<Tag color="blue" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
{row.ruleKey}
</Tag>
) : null}
{row.provider ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{row.provider}
</Typography.Text>
) : null}
</Space>
</div>
),
{categoryDisplay ? (
<Tag color="cyan" style={{ margin: 0, fontSize: 11, lineHeight: '18px' }}>
{categoryDisplay}
</Tag>
) : null}
{row.provider ? (
<Typography.Text type="secondary" style={{ fontSize: 11 }}>
{row.provider}
</Typography.Text>
) : null}
</Space>
</div>
)
},
},
{
title: '已绑定商品',
@@ -1132,30 +1197,86 @@ export default function ProductRulesPanel() {
}
bordered={false}
>
{/* Category Tabs Bar */}
{/* Level 1: 一级大类 Tabs */}
<div className="product-rules-category-tabs-bar">
<Tabs
activeKey={categoryId ? String(categoryId) : 'all'}
activeKey={selectedRootKey}
size="small"
onChange={(key) => {
setCategoryId(key === 'all' ? undefined : Number(key))
setSelectedRootKey(key)
setSelectedSubKey('all')
if (key === 'all') {
setCategoryId(undefined)
} else if (key === 'uncategorized') {
setCategoryId(-1)
} else {
setCategoryId(Number(key))
}
setPage(1)
}}
items={[
{ 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
...rootCategories.map((root) => {
const count = rootCategoryCounts.get(root.categoryId) || 0
return {
key: String(item.categoryId),
label: `${displayName}${count ? ` (${count})` : ''}`,
key: String(root.categoryId),
label: `${root.name}${count ? ` (${count})` : ''}`,
}
}),
...(categoryCounts.get('uncategorized')
? [
{
key: 'uncategorized',
label: `未分类 (${categoryCounts.get('uncategorized')})`,
},
]
: []),
]}
/>
</div>
{/* Level 2: 二级子分类胶囊栏 (仅在选中的一级大类含有子目录时展示) */}
{activeSubCategories.length > 0 ? (
<div className="product-rules-sub-category-bar">
<div className="sub-category-bar-label">
<FolderOpenOutlined style={{ color: '#1677ff', fontSize: 13 }} />
<span>子分类:</span>
</div>
<div className="sub-category-pills">
<button
type="button"
className={`sub-category-pill ${selectedSubKey === 'all' ? 'active' : ''}`}
onClick={() => {
setSelectedSubKey('all')
setCategoryId(Number(selectedRootKey))
setPage(1)
}}
>
全部 ({activeRootTotalCount})
</button>
{activeSubCategories.map((child) => {
const count = categoryCounts.get(String(child.categoryId)) || 0
const isActive = selectedSubKey === String(child.categoryId)
return (
<button
key={child.categoryId}
type="button"
className={`sub-category-pill ${isActive ? 'active' : ''}`}
onClick={() => {
setSelectedSubKey(String(child.categoryId))
setCategoryId(child.categoryId)
setPage(1)
}}
>
{child.name}
<span className="pill-count">{count}</span>
</button>
)
})}
</div>
</div>
) : null}
<Table<WorkProductRule>
rowKey="ruleId"
size="small"
+80
View File
@@ -1350,6 +1350,86 @@
margin: 0;
}
.product-rules-sub-category-bar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 10px;
margin-bottom: 10px;
background: #f8fafc;
border: 1px solid #edf2f7;
border-radius: 6px;
overflow-x: auto;
}
.sub-category-bar-label {
display: flex;
align-items: center;
gap: 4px;
font-size: 12px;
color: #64748b;
font-weight: 500;
white-space: nowrap;
flex-shrink: 0;
}
.sub-category-pills {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: nowrap;
min-width: 0;
}
.sub-category-pill {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
height: 24px;
font-size: 12px;
color: #475569;
background: #ffffff;
border: 1px solid #e2e8f0;
border-radius: 12px;
cursor: pointer;
white-space: nowrap;
transition: all 0.15s ease;
user-select: none;
flex-shrink: 0;
}
.sub-category-pill:hover {
color: #1677ff;
border-color: #91caff;
background: #f0f7ff;
}
.sub-category-pill.active {
color: #ffffff;
background: #1677ff;
border-color: #1677ff;
font-weight: 500;
box-shadow: 0 1px 2px rgba(22, 119, 255, 0.2);
}
.sub-category-pill .pill-count {
display: inline-block;
font-size: 11px;
padding: 0 5px;
height: 16px;
line-height: 16px;
border-radius: 8px;
background: #f1f5f9;
color: #64748b;
font-weight: normal;
}
.sub-category-pill.active .pill-count {
background: rgba(255, 255, 255, 0.25);
color: #ffffff;
}
.product-rule-row-active > td {
background: #e6f4ff !important;
}