Commit ebd95398 authored by drigle's avatar drigle

fix: optimize case category tabs with aggregation

parent 4ca8494c
...@@ -133,9 +133,9 @@ ...@@ -133,9 +133,9 @@
3. 通过案例来源、案例类型、问题属性、分类标签输入框填写组合筛选条件,输入框仅录入文本条件,不提供下拉候选 3. 通过案例来源、案例类型、问题属性、分类标签输入框填写组合筛选条件,输入框仅录入文本条件,不提供下拉候选
4. 全文搜索与各筛选值变化后,前端实时将当前条件提交到 `POST /record/query` 做远程查询,并从第一页展示结果 4. 全文搜索与各筛选值变化后,前端实时将当前条件提交到 `POST /record/query` 做远程查询,并从第一页展示结果
5. 筛选区使用 HeroUI `Surface + Disclosure`:默认展示案例来源、案例类型和问题属性三项,点击 `展开` 后展示分类标签;折叠态不保留隐藏内容的布局高度,筛选区不展示刷新或查询按钮 5. 筛选区使用 HeroUI `Surface + Disclosure`:默认展示案例来源、案例类型和问题属性三项,点击 `展开` 后展示分类标签;折叠态不保留隐藏内容的布局高度,筛选区不展示刷新或查询按钮
6. 通过 HeroUI Tabs 的案例性质标签快速切换当前视图;分类名称保持单行显示,空间不足时在 Tabs 内部横向滚动 6. 案例性质标签先通过 `POST /record/aggregate` 按 `metadata.category` 全库分组生成,不再依赖当前页案例在前端分组;加载时显示进度,失败时保留旧标签并提供重试;返回的 `_id` 映射为标签 key/label,空字符串或缺失分类显示为 `未分类`;标签顺序固定为 `全部案例`、`未分类`、其余分类(按中文名称排序),成功刷新时替换旧分类标签;分类名称保持单行显示,空间不足时在 Tabs 内部横向滚动
7. 分类标签筛选优先匹配 `metadata.tag`,同时兼容多值 `metadata.keyword` 7. 分类标签筛选优先匹配 `metadata.tag`,同时兼容多值 `metadata.keyword`
8. 页面不对当前页案例做本地二次搜索 8. 切换案例性质标签后,从第一页通过 `POST /record/query` 的 `filter.metadata.category` 远程查询;`未分类` 使用 `$in: [null, "", "未分类"]` 兼容缺失及空字符串分类。页面不对当前页案例做本地二次搜索
9. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换 9. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换
10. 卡片视图用于浏览案例摘要;每条案例使用独立一级业务卡片,分类/等级/标签、标题、类型、摘要和时间信息按层级展示,点击卡片后以弹框方式查看并编辑详情 10. 卡片视图用于浏览案例摘要;每条案例使用独立一级业务卡片,分类/等级/标签、标题、类型、摘要和时间信息按层级展示,点击卡片后以弹框方式查看并编辑详情
11. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段;宽表在 DataGrid 内部横向滚动,各字段只在所属单元格内单行截断,完整值通过 HeroUI Tooltip 查看,不允许跨列覆盖;首列标题仅用于展示,不承担点击操作,查看详情统一从右侧操作列进入 11. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段;宽表在 DataGrid 内部横向滚动,各字段只在所属单元格内单行截断,完整值通过 HeroUI Tooltip 查看,不允许跨列覆盖;首列标题仅用于展示,不承担点击操作,查看详情统一从右侧操作列进入
...@@ -153,6 +153,8 @@ ...@@ -153,6 +153,8 @@
7. 点击 `保存` 后,前端调用 `PUT /record/:name` 7. 点击 `保存` 后,前端调用 `PUT /record/:name`
8. 保存成功后刷新列表与统计数据,并返回查看态;失败时保留当前草稿和错误提示 8. 保存成功后刷新列表与统计数据,并返回查看态;失败时保留当前草稿和错误提示
创建、编辑、删除、批量修改、批量删除和 Excel 导入成功后,除刷新当前列表外,还必须重新调用分类 aggregate 刷新案例性质标签;aggregate 失败时保留当前标签并展示重试提示。
### 4.6 批量修改案例 ### 4.6 批量修改案例
1. 用户切换到表格视图 1. 用户切换到表格视图
...@@ -213,6 +215,7 @@ ...@@ -213,6 +215,7 @@
- 筛选统一走远程 `POST /record/query`;页面不使用本地数据搜索替代远程结果 - 筛选统一走远程 `POST /record/query`;页面不使用本地数据搜索替代远程结果
- 全文搜索、结构化筛选和案例性质变化后实时发起远程查询,并从第一页展示结果 - 全文搜索、结构化筛选和案例性质变化后实时发起远程查询,并从第一页展示结果
- 筛选输入使用普通文本输入框,不使用 `select`、`datalist` 或其它下拉候选控件 - 筛选输入使用普通文本输入框,不使用 `select`、`datalist` 或其它下拉候选控件
- 案例性质标签数据来源统一为 `POST /record/aggregate`,分组字段为 `metadata.category`;空分类在 UI 中展示为 `未分类`,并通过包含 `null`、空字符串和 `未分类` 的过滤条件查询
--- ---
...@@ -299,16 +302,22 @@ ...@@ -299,16 +302,22 @@
- `POST /record/query` - `POST /record/query`
- `subject = risk_case_library` - `subject = risk_case_library`
### 7.2 创建案例 ### 7.2 聚合案例分类
- `POST /record/aggregate`
- `subject = risk_case_library`
- `stages = [{ "$group": { "_id": "$metadata.category" } }]`
### 7.3 创建案例
- `POST /record` - `POST /record`
- `subject = risk_case_library` - `subject = risk_case_library`
### 7.3 更新案例 ### 7.4 更新案例
- `PUT /record/:name` - `PUT /record/:name`
### 7.4 删除案例 ### 7.5 删除案例
- `DELETE /record/:name` - `DELETE /record/:name`
...@@ -321,6 +330,7 @@ ...@@ -321,6 +330,7 @@
- 每页固定加载 20 条,不展示或提供每页条数选择器 - 每页固定加载 20 条,不展示或提供每页条数选择器
- 当用户修改全文搜索、任一结构化筛选或切换案例性质时,实时从第一页重新加载 - 当用户修改全文搜索、任一结构化筛选或切换案例性质时,实时从第一页重新加载
- 搜索与筛选统一发起远程查询,不在当前页结果中做本地搜索;连续请求只采用最新筛选状态对应的结果 - 搜索与筛选统一发起远程查询,不在当前页结果中做本地搜索;连续请求只采用最新筛选状态对应的结果
- 分类标签使用独立 aggregate 请求;列表仍按页查询,不得先拉取全量案例后再生成标签
- 总数优先读取 `POST /record/query` 返回的 `total_records`;接口未返回总数时再回退到 `count` 接口 - 总数优先读取 `POST /record/query` 返回的 `total_records`;接口未返回总数时再回退到 `count` 接口
--- ---
......
import { import {
countRecords, countRecords,
createRecord, createRecord,
aggregateRecords,
queryRecords, queryRecords,
removeRecord, removeRecord,
updateRecord, updateRecord,
...@@ -10,6 +11,7 @@ import * as XLSX from 'xlsx'; ...@@ -10,6 +11,7 @@ import * as XLSX from 'xlsx';
export const CASE_SUBJECT = 'risk_case_library'; export const CASE_SUBJECT = 'risk_case_library';
export const DEFAULT_PAGE_SIZE = 20; export const DEFAULT_PAGE_SIZE = 20;
export const UNCATEGORIZED_CATEGORY = '未分类';
const CASE_EXPORT_HEADERS = [ const CASE_EXPORT_HEADERS = [
'标题', '标题',
...@@ -387,9 +389,13 @@ function buildRecordFilter(filters = {}) { ...@@ -387,9 +389,13 @@ function buildRecordFilter(filters = {}) {
} }
if (filters.category) { if (filters.category) {
clauses.push({ const category = String(filters.category).trim();
'metadata.category': String(filters.category).trim(),
}); clauses.push(
category === UNCATEGORIZED_CATEGORY
? { 'metadata.category': { $in: [null, '', UNCATEGORIZED_CATEGORY] } }
: { 'metadata.category': category },
);
} }
if (filters.tag) { if (filters.tag) {
...@@ -439,6 +445,36 @@ function hasActiveFilters(filters = {}) { ...@@ -439,6 +445,36 @@ function hasActiveFilters(filters = {}) {
); );
} }
export async function loadCaseCategoryGroups() {
const response = await aggregateRecords({
subject: CASE_SUBJECT,
stages: [
{
$group: {
_id: '$metadata.category',
},
},
],
});
const rows = [
response,
response?.data,
response?.result,
].find(Array.isArray) || [];
const groups = rows
.map((row) => String(row?._id ?? '').trim())
.map((category) => category || UNCATEGORIZED_CATEGORY);
return [...new Set(groups)]
.sort((left, right) => {
if (left === UNCATEGORIZED_CATEGORY) return -1;
if (right === UNCATEGORIZED_CATEGORY) return 1;
return left.localeCompare(right, 'zh-Hans-CN');
})
.map((category) => ({ key: category, label: category }));
}
export function normalizeCaseRecord(record = {}) { export function normalizeCaseRecord(record = {}) {
const metadata = record?.metadata || {}; const metadata = record?.metadata || {};
const label = String(metadata.label || record.title || '').trim(); const label = String(metadata.label || record.title || '').trim();
......
...@@ -58,7 +58,6 @@ import RiskCaseMaterialField from '../RiskCaseMaterialField'; ...@@ -58,7 +58,6 @@ import RiskCaseMaterialField from '../RiskCaseMaterialField';
import { createRiskCaseMaterial } from '../riskCaseMaterialService'; import { createRiskCaseMaterial } from '../riskCaseMaterialService';
import { loadRiskCaseViewResources } from '../riskCaseViewService'; import { loadRiskCaseViewResources } from '../riskCaseViewService';
import { import {
buildCaseDashboard,
createCaseRecord, createCaseRecord,
createCaseTitle, createCaseTitle,
deleteCaseRecord, deleteCaseRecord,
...@@ -73,6 +72,7 @@ import { ...@@ -73,6 +72,7 @@ import {
previewCaseWorkbook, previewCaseWorkbook,
updateCaseRecord, updateCaseRecord,
updateCaseRecords, updateCaseRecords,
loadCaseCategoryGroups,
} from '../materialService'; } from '../materialService';
const ALL_CATEGORY_KEY = '__all__'; const ALL_CATEGORY_KEY = '__all__';
...@@ -516,10 +516,11 @@ export default function ResourceBookingPage() { ...@@ -516,10 +516,11 @@ export default function ResourceBookingPage() {
const [caseAttributeFilter, setCaseAttributeFilter] = useState(''); const [caseAttributeFilter, setCaseAttributeFilter] = useState('');
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY); const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY);
const [categoryTabItems, setCategoryTabItems] = useState(DEFAULT_CATEGORY_TABS); const [categoryTabItems, setCategoryTabItems] = useState(DEFAULT_CATEGORY_TABS);
const [categoryTabLoading, setCategoryTabLoading] = useState(true);
const [categoryTabError, setCategoryTabError] = useState('');
const [viewMode, setViewMode] = useState(CARD_VIEW_MODE); const [viewMode, setViewMode] = useState(CARD_VIEW_MODE);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
const [cases, setCases] = useState([]); const [cases, setCases] = useState([]);
const [dashboard, setDashboard] = useState(() => buildCaseDashboard([]));
const [activeCaseId, setActiveCaseId] = useState(''); const [activeCaseId, setActiveCaseId] = useState('');
const [selectedCaseIds, setSelectedCaseIds] = useState([]); const [selectedCaseIds, setSelectedCaseIds] = useState([]);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
...@@ -560,6 +561,7 @@ export default function ResourceBookingPage() { ...@@ -560,6 +561,7 @@ export default function ResourceBookingPage() {
const [materialValidationErrors, setMaterialValidationErrors] = useState({}); const [materialValidationErrors, setMaterialValidationErrors] = useState({});
const pendingLocalMaterialsRef = useRef({}); const pendingLocalMaterialsRef = useRef({});
const editSessionRef = useRef(0); const editSessionRef = useRef(0);
const categoryTabRequestRef = useRef(0);
const activeGridView = useMemo( const activeGridView = useMemo(
() => gridViews.find((view) => view.name === activeGridViewName) || null, () => gridViews.find((view) => view.name === activeGridViewName) || null,
...@@ -591,12 +593,8 @@ export default function ResourceBookingPage() { ...@@ -591,12 +593,8 @@ export default function ResourceBookingPage() {
); );
const categoryTabs = useMemo(() => { const categoryTabs = useMemo(() => {
return mergeCategoryTabItems( return mergeCategoryTabItems(categoryTabItems, [], activeCategory);
categoryTabItems, }, [activeCategory, categoryTabItems]);
dashboard.categoryGroups || [],
activeCategory,
);
}, [activeCategory, categoryTabItems, dashboard.categoryGroups]);
const visibleCases = useMemo(() => { const visibleCases = useMemo(() => {
return cases; return cases;
...@@ -704,6 +702,38 @@ export default function ResourceBookingPage() { ...@@ -704,6 +702,38 @@ export default function ResourceBookingPage() {
} }
}, [activeDetailViewName, visibleDetailViews]); }, [activeDetailViewName, visibleDetailViews]);
async function loadCategoryTabItems() {
const requestId = categoryTabRequestRef.current + 1;
categoryTabRequestRef.current = requestId;
setCategoryTabLoading(true);
setCategoryTabError('');
try {
const groups = await loadCaseCategoryGroups();
if (categoryTabRequestRef.current !== requestId) {
return;
}
setCategoryTabItems(buildCategoryTabItems(groups));
} catch (loadError) {
if (categoryTabRequestRef.current === requestId) {
setCategoryTabError(
loadError?.message || '案例分类加载失败,请稍后重试。',
);
}
} finally {
if (categoryTabRequestRef.current === requestId) {
setCategoryTabLoading(false);
}
}
}
useEffect(() => {
loadCategoryTabItems();
}, []);
useEffect(() => { useEffect(() => {
setDetailForm(buildCaseForm(activeCase)); setDetailForm(buildCaseForm(activeCase));
}, [activeCase]); }, [activeCase]);
...@@ -727,18 +757,6 @@ export default function ResourceBookingPage() { ...@@ -727,18 +757,6 @@ export default function ResourceBookingPage() {
}); });
setCases(result.cases || []); setCases(result.cases || []);
setDashboard(
buildCaseDashboard(result.cases || [], {
totalCount: result.total || 0,
}),
);
setCategoryTabItems((currentTabs) =>
mergeCategoryTabItems(
currentTabs,
buildCaseDashboard(result.cases || []).categoryGroups || [],
activeCategory,
),
);
setPage(nextPage); setPage(nextPage);
setTotal(result.total || 0); setTotal(result.total || 0);
setIsDemoMode(Boolean(result.isDemoMode)); setIsDemoMode(Boolean(result.isDemoMode));
...@@ -776,18 +794,6 @@ export default function ResourceBookingPage() { ...@@ -776,18 +794,6 @@ export default function ResourceBookingPage() {
} }
setCases(result.cases || []); setCases(result.cases || []);
setDashboard(
buildCaseDashboard(result.cases || [], {
totalCount: result.total || 0,
}),
);
setCategoryTabItems((currentTabs) =>
mergeCategoryTabItems(
currentTabs,
buildCaseDashboard(result.cases || []).categoryGroups || [],
activeCategory,
),
);
setPage(0); setPage(0);
setTotal(result.total || 0); setTotal(result.total || 0);
setError(result.error || ''); setError(result.error || '');
...@@ -941,7 +947,7 @@ export default function ResourceBookingPage() { ...@@ -941,7 +947,7 @@ export default function ResourceBookingPage() {
setIsCreateOpen(false); setIsCreateOpen(false);
setIsDetailOpen(false); setIsDetailOpen(false);
resetCreateForm(); resetCreateForm();
await refreshCurrentList(0); await Promise.all([refreshCurrentList(0), loadCategoryTabItems()]);
setActiveCaseId(created.id || ''); setActiveCaseId(created.id || '');
} catch (createError) { } catch (createError) {
setError(createError?.message || '案例创建失败,请稍后重试。'); setError(createError?.message || '案例创建失败,请稍后重试。');
...@@ -990,7 +996,7 @@ export default function ResourceBookingPage() { ...@@ -990,7 +996,7 @@ export default function ResourceBookingPage() {
} }
const updated = await updateCaseRecord(activeCase, detailForm); const updated = await updateCaseRecord(activeCase, detailForm);
setNotice(`案例“${updated.label}”已更新。`); setNotice(`案例“${updated.label}”已更新。`);
await refreshCurrentList(); await Promise.all([refreshCurrentList(), loadCategoryTabItems()]);
setActiveCaseId(updated.id || activeCase.id); setActiveCaseId(updated.id || activeCase.id);
setIsDetailEditing(false); setIsDetailEditing(false);
pendingLocalMaterialsRef.current = {}; pendingLocalMaterialsRef.current = {};
...@@ -1016,7 +1022,7 @@ export default function ResourceBookingPage() { ...@@ -1016,7 +1022,7 @@ export default function ResourceBookingPage() {
setNotice(`案例“${activeCase.label}”已删除。`); setNotice(`案例“${activeCase.label}”已删除。`);
setIsDeleteOpen(false); setIsDeleteOpen(false);
setIsDetailOpen(false); setIsDetailOpen(false);
await refreshCurrentList(); await Promise.all([refreshCurrentList(), loadCategoryTabItems()]);
} catch (deleteError) { } catch (deleteError) {
setError(deleteError?.message || '案例删除失败,请稍后重试。'); setError(deleteError?.message || '案例删除失败,请稍后重试。');
} finally { } finally {
...@@ -1041,7 +1047,7 @@ export default function ResourceBookingPage() { ...@@ -1041,7 +1047,7 @@ export default function ResourceBookingPage() {
} }
setIsBatchDeleteOpen(false); setIsBatchDeleteOpen(false);
await refreshCurrentList(); await Promise.all([refreshCurrentList(), loadCategoryTabItems()]);
const failedIds = result.failures const failedIds = result.failures
.map((item) => item.id) .map((item) => item.id)
...@@ -1088,7 +1094,7 @@ export default function ResourceBookingPage() { ...@@ -1088,7 +1094,7 @@ export default function ResourceBookingPage() {
setIsBatchEditOpen(false); setIsBatchEditOpen(false);
resetBatchEditForm(); resetBatchEditForm();
await refreshCurrentList(); await Promise.all([refreshCurrentList(), loadCategoryTabItems()]);
const failedIds = result.failures const failedIds = result.failures
.map((item) => item.id) .map((item) => item.id)
...@@ -1177,7 +1183,7 @@ export default function ResourceBookingPage() { ...@@ -1177,7 +1183,7 @@ export default function ResourceBookingPage() {
} }
setIsImportOpen(false); setIsImportOpen(false);
resetImportState(); resetImportState();
await refreshCurrentList(0); await Promise.all([refreshCurrentList(0), loadCategoryTabItems()]);
} catch (importError) { } catch (importError) {
setError(importError?.message || 'Excel 导入失败,请稍后重试。'); setError(importError?.message || 'Excel 导入失败,请稍后重试。');
} finally { } finally {
...@@ -1269,8 +1275,31 @@ export default function ResourceBookingPage() { ...@@ -1269,8 +1275,31 @@ export default function ResourceBookingPage() {
</Tabs> </Tabs>
) : null} ) : null}
<div className="min-w-0 max-w-full"> <div className="min-w-0 max-w-full">
{categoryTabLoading ? (
<div className="mb-2 flex items-center gap-2 text-sm text-muted" role="status">
<Spinner size="sm" />
正在加载案例分类…
</div>
) : null}
{categoryTabError ? (
<Alert className="mb-3" status="warning">
<Alert.Content>
<Alert.Description>{categoryTabError}</Alert.Description>
<Button
className="mt-2"
isDisabled={categoryTabLoading}
size="sm"
variant="secondary"
onPress={loadCategoryTabItems}
>
重试
</Button>
</Alert.Content>
</Alert>
) : null}
<Tabs <Tabs
className="w-fit max-w-full min-w-0" className="w-fit max-w-full min-w-0"
aria-busy={categoryTabLoading}
selectedKey={activeCategory} selectedKey={activeCategory}
variant="primary" variant="primary"
onSelectionChange={(key) => setActiveCategory(String(key))} onSelectionChange={(key) => setActiveCategory(String(key))}
......
...@@ -108,6 +108,43 @@ ...@@ -108,6 +108,43 @@
功能:获取单条详情 功能:获取单条详情
#### 3.1.3 POST `/record/aggregate`
权限:`api.record.aggregate`
功能:对同一 subject 的 active 记录执行 Mongo aggregation。前端只用它做轻量分组统计,不返回记录列表。
请求体:
| 字段 | 类型 | 必填 | 说明 |
| --- | --- | --- | --- |
| subject | string | 是 | 主题名 |
| stages | object[] | 是 | Mongo aggregation pipeline |
示例:
```json
{
"subject": "risk_case_library",
"stages": [
{
"$group": {
"_id": "$metadata.category"
}
}
]
}
```
响应为数组,每项包含分组键 `_id`:
```json
[
{ "_id": "合规风险" },
{ "_id": "操作风险" }
]
```
### 3.2 创建 / 更新 ### 3.2 创建 / 更新
- POST `/record` - POST `/record`
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment