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

update: 筛选优化

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