feat(product-rules): 接单模板列表支持二级目录层级联动筛选
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user