Commit 06f0d7dd authored by David Yang's avatar David Yang

update: 筛选优化

parent 7b7c281e
......@@ -7,7 +7,7 @@
- 导入线下 Excel 维护的案例数据
- 支持手动创建、编辑、删除案例
- 提供更直观的案例浏览体验
- 支持按关键词、案例性质、案例来源、案例等级快速检索典型案例
- 支持按全文关键词、案例来源、案例类型、问题属性、分类标签、案例性质等多字段组合检索典型案例
- 为培训、复盘、经验沉淀提供统一入口
当前阶段的核心原则是:
......@@ -33,7 +33,7 @@
- 展示关键字词云与最近更新案例
- 提供对库内内容密度和结构完整度的快速判断
- `案例管理`
- 搜索、筛选、浏览案例卡片
- 多字段筛选、浏览案例卡片
- 手动创建案例
- Excel 批量导入案例
- 查看案例详情并编辑
......@@ -101,7 +101,7 @@
1. 用户进入 `案例管理`
2. 点击 `Excel 批量导出`
3. 前端按当前搜索词、案例性质与关键字筛选条件查询全部案例
3. 前端按当前全文搜索、案例性质、案例来源、案例类型、问题属性、分类标签等筛选条件查询全部案例
4. 导出文件统一输出以下 11 列:
- `标题`
- `分类`
......@@ -115,25 +115,28 @@
- `案例等级`
- `案例年份`
5. 浏览器直接下载 `.xlsx` 文件
6. `metadata.keyword` 导出到 `关键字` 列时恢复为逗号拼接字符串,便于线下继续维护
6. `metadata.keyword` 导出到 `关键字` 列时统一使用英文逗号 `,` 拼接字符串,便于线下继续维护
### 4.4 浏览与检索案例
1. 用户进入 `案例管理`
2. 通过搜索框检索标题、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级
3. 通过案例性质标签快速切换当前视图
4. 通过关键字输入框进一步缩小范围,前端同时兼容历史 `metadata.tag`
5. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换
6. 卡片视图用于浏览案例摘要,点击卡片后以弹框方式查看并编辑详情
7. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段
8. 表格视图按页加载案例,支持选择每页 20、50、100 条,并显示页码、当前页范围和匹配总数
9. 表格视图支持单选、全选当前页案例,并显示当前勾选数量
2. 通过全文搜索框检索标题、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级
3. 通过案例来源、案例类型、问题属性、分类标签输入框填写组合筛选条件,输入框仅录入文本条件,不提供下拉候选
4. 点击 `查询` 后,前端将当前输入条件提交到 `POST /record/query` 做远程查询;仅输入文字不触发请求
5. 通过案例性质标签快速切换当前视图
6. 分类标签筛选优先匹配 `metadata.tag`,同时兼容多值 `metadata.keyword`
7. 页面不对当前页案例做本地二次搜索
8. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换
9. 卡片视图用于浏览案例摘要,点击卡片后以弹框方式查看并编辑详情
10. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段
11. 表格视图按页加载案例,支持选择每页 20、50、100 条,并显示页码、当前页范围和匹配总数
12. 表格视图支持单选、全选当前页案例,并显示当前勾选数量
### 4.5 编辑案例
1. 用户在列表中打开某个案例详情
2. 修改标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份
3. 关键字编辑区支持逗号输入,并实时预览为关键字标签;保存时拆分为 `metadata.keyword` 字符串数组
3. 关键字编辑区的输入框仅用于新增关键字,已有关键字以标签展示并支持逐个删除;保存时写入 `metadata.keyword` 字符串数组
4. 前端调用 `PUT /record/:name`
5. 保存成功后刷新列表与统计数据
......@@ -181,15 +184,22 @@
- 空行直接跳过
- `标题` 为空时,优先使用 `案例类型`,再回退为 `案例详情` 摘要自动生成 `title`
- 普通文本字段统一按字符串保存
- `关键字` 是多值字段,导入时将逗号拼接文本拆分为字符串数组保存
- `关键字` 是多值字段,导入时识别英文逗号 `,` 与中文逗号 `,`,拆分、去空、去重后保存为字符串数组
- `案例年份` 优先按数字写入,不可解析时保留原字符串
- 导出模板固定为 11 列标准表头,便于线下统一维护
### 5.3 筛选规则
- 案例性质筛选使用 `metadata.category`
- 关键字筛选优先使用多值数组 `metadata.keyword`,同时兼容历史字符串型 `metadata.keyword` 与 `metadata.tag`
- 案例来源筛选使用 `metadata.case_source`,支持模糊匹配
- 案例类型筛选使用 `metadata.case_type`,支持模糊匹配
- 问题属性筛选使用 `metadata.case_attribute`,支持模糊匹配
- 分类标签筛选优先匹配 `metadata.tag`,同时兼容多值数组或历史字符串型 `metadata.keyword`
- 关键字检索优先使用多值数组 `metadata.keyword`,同时兼容历史字符串型 `metadata.keyword` 与 `metadata.tag`
- 关键词检索同时覆盖 `title`、`metadata.label`、`metadata.case_type`、`metadata.case_attribute`、`metadata.case_details`、`metadata.case_warning`、`metadata.keyword`、`metadata.case_source`、`metadata.case_level`、`metadata.category`、`metadata.tag`
- 筛选统一走远程 `POST /record/query`;页面不使用本地数据搜索替代远程结果
- 筛选条件输入后必须点击 `查询` 才发起请求,输入过程不自动请求
- 筛选输入使用普通文本输入框,不使用 `select`、`datalist` 或其它下拉候选控件
---
......@@ -223,9 +233,14 @@
建议至少包含:
- 搜索框
- 全文搜索框
- 案例性质切换
- 关键字筛选
- 案例来源筛选
- 案例类型筛选
- 问题属性筛选
- 分类标签筛选
- 查询按钮
- 筛选项为普通文本输入框,不提供下拉框
- `Segment` 视图切换(卡片 / 表格)
- 手动新建按钮
- Excel 导入按钮
......@@ -251,7 +266,7 @@
- 案例详情全文
- 案例性质
- 关键字
- 关键字以标签形式展示;编辑时保留逗号输入并即时预览拆分后的标签
- 关键字以标签形式展示;编辑时输入框仅用于新增关键字,已有标签支持逐个删除
- 案例警示
- 案例来源 / 案例等级 / 案例年份
- 案例详情字数
......@@ -286,7 +301,8 @@
- 首屏只加载第一页,默认每页 20 条
- 表格与卡片列表通过页码分页加载,不使用下滑自动加载
- 用户可选择每页 20、50、100 条;切换每页数量时从第一页重新加载
- 当用户切换分类或搜索条件时,从第一页重新加载
- 当用户点击 `查询`、重置筛选或切换案例性质时,从第一页重新加载
- 搜索与筛选输入变化时不自动请求;点击 `查询` 后必须发起远程查询,不在当前页结果中做本地搜索
- 总数优先读取 `POST /record/query` 返回的 `total_records`;接口未返回总数时再回退到 `count` 接口
---
......
......@@ -281,7 +281,7 @@ function createSummary(content = '', maxLength = 140) {
export function normalizeKeywordList(value) {
const sourceValues = (Array.isArray(value) ? value : [value]).flatMap((item) =>
String(item || '').split(/[,,]/),
String(item || '').split(/[,,;;\n]/),
);
const seenKeywords = new Set();
......@@ -300,7 +300,7 @@ export function normalizeKeywordList(value) {
}
export function formatKeywordList(value) {
return normalizeKeywordList(value).join(', ');
return normalizeKeywordList(value).join(',');
}
export function formatDateTime(value) {
......@@ -365,6 +365,20 @@ function buildKeywordFilter(keyword = '') {
};
}
function buildMetadataTextFilter(fieldName, value = '') {
const normalizedValue = String(value || '').trim();
if (!normalizedValue) {
return null;
}
return {
[`metadata.${fieldName}`]: {
$regex: escapeRegExp(normalizedValue),
$options: 'i',
},
};
}
function buildRecordFilter(filters = {}) {
const clauses = [];
const keywordFilter = buildKeywordFilter(filters.keyword);
......@@ -380,14 +394,28 @@ function buildRecordFilter(filters = {}) {
}
if (filters.tag) {
const tagRegex = {
$regex: escapeRegExp(String(filters.tag).trim()),
$options: 'i',
};
clauses.push({
'metadata.tag': {
$regex: escapeRegExp(String(filters.tag).trim()),
$options: 'i',
},
$or: [{ 'metadata.tag': tagRegex }, { 'metadata.keyword': tagRegex }],
});
}
[
['case_source', filters.caseSource],
['case_type', filters.caseType],
['case_attribute', filters.caseAttribute],
].forEach(([fieldName, value]) => {
const fieldFilter = buildMetadataTextFilter(fieldName, value);
if (fieldFilter) {
clauses.push(fieldFilter);
}
});
if (clauses.length === 0) {
return {};
}
......@@ -405,7 +433,10 @@ function hasActiveFilters(filters = {}) {
return Boolean(
String(filters.keyword || '').trim() ||
String(filters.category || '').trim() ||
String(filters.tag || '').trim(),
String(filters.tag || '').trim() ||
String(filters.caseSource || '').trim() ||
String(filters.caseType || '').trim() ||
String(filters.caseAttribute || '').trim(),
);
}
......@@ -461,36 +492,6 @@ export function normalizeCaseRecord(record = {}) {
};
}
function applyClientFilters(cases, filters = {}) {
const keyword = String(filters.keyword || '').trim().toLowerCase();
const category = String(filters.category || '').trim();
const tag = String(filters.tag || '').trim().toLowerCase();
return cases.filter((item) => {
const matchesKeyword =
!keyword ||
[
item.label,
item.caseType,
item.caseAttribute,
item.caseDetails,
item.caseWarning,
item.keyword,
item.caseSource,
item.caseLevel,
item.category,
item.tag,
]
.filter(Boolean)
.some((value) => String(value).toLowerCase().includes(keyword));
const matchesCategory = !category || item.category === category;
const matchesTag = !tag || String(item.tag || '').toLowerCase().includes(tag);
return matchesKeyword && matchesCategory && matchesTag;
});
}
export function buildCaseDashboard(cases = [], options = {}) {
const totalCount = Number.isFinite(options.totalCount)
? options.totalCount
......@@ -572,8 +573,6 @@ export function buildCaseDashboard(cases = [], options = {}) {
caseSourceGroups,
recentCases,
attentionCases,
categorySuggestions: categoryGroups.map((item) => item.label),
tagSuggestions: tagGroups.map((item) => item.label),
};
}
......@@ -620,10 +619,7 @@ export async function loadCasePage(filters = {}) {
page === 0 && !hasActiveFilters(filters) && total === 0 && cases.length === 0;
if (shouldUseDemo) {
const demoCases = applyClientFilters(
DEMO_CASES.map((item) => normalizeCaseRecord(item)),
filters,
);
const demoCases = DEMO_CASES.map((item) => normalizeCaseRecord(item));
const start = page * pageSize;
const pageCases = demoCases.slice(start, start + pageSize);
const demoTotal = demoCases.length;
......@@ -662,10 +658,19 @@ export async function loadCasePage(filters = {}) {
total = 0;
}
const demoCases = applyClientFilters(
DEMO_CASES.map((item) => normalizeCaseRecord(item)),
filters,
);
if (hasActiveFilters(filters)) {
return {
cases: [],
total: 0,
page,
pageSize,
hasMore: false,
isDemoMode: false,
error: error?.message || '远程案例查询失败,请稍后重试。',
};
}
const demoCases = DEMO_CASES.map((item) => normalizeCaseRecord(item));
const start = page * pageSize;
const pageCases = demoCases.slice(start, start + pageSize);
......
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