Commit ebd95398 authored by drigle's avatar drigle

fix: optimize case category tabs with aggregation

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