diff --git a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts index fc4c9790..3f47f897 100644 --- a/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts +++ b/apps/backend/src/repositories/worker-platform/work-product-rule-repo.ts @@ -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 ')}` : '', diff --git a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx index e0138442..a0758475 100644 --- a/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx +++ b/apps/frontend/src/pages/admin/panels/ProductRulesPanel.tsx @@ -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('all') + const [selectedSubKey, setSelectedSubKey] = useState('all') const [categoryId, setCategoryId] = useState() 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( + (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() + 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() + 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) => ( -
- - {row.productName || row.ruleKey} - - - - {row.ruleKey} - - {row.categoryName ? ( - - {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 ( +
+ + {row.productName || row.ruleKey} + + + + {row.ruleKey} - ) : null} - {row.provider ? ( - - {row.provider} - - ) : null} - -
- ), + {categoryDisplay ? ( + + {categoryDisplay} + + ) : null} + {row.provider ? ( + + {row.provider} + + ) : null} +
+
+ ) + }, }, { title: '已绑定商品', @@ -1132,30 +1197,86 @@ export default function ProductRulesPanel() { } bordered={false} > - {/* Category Tabs Bar */} + {/* Level 1: 一级大类 Tabs */}
{ - 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')})`, + }, + ] + : []), ]} />
+ {/* Level 2: 二级子分类胶囊栏 (仅在选中的一级大类含有子目录时展示) */} + {activeSubCategories.length > 0 ? ( +
+
+ + 子分类: +
+
+ + {activeSubCategories.map((child) => { + const count = categoryCounts.get(String(child.categoryId)) || 0 + const isActive = selectedSubKey === String(child.categoryId) + return ( + + ) + })} +
+
+ ) : null} + rowKey="ruleId" size="small" diff --git a/apps/frontend/src/styles/admin.css b/apps/frontend/src/styles/admin.css index 1976513c..f1045a61 100644 --- a/apps/frontend/src/styles/admin.css +++ b/apps/frontend/src/styles/admin.css @@ -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; }