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) {
clauses.push({ const tagRegex = {
'metadata.tag': {
$regex: escapeRegExp(String(filters.tag).trim()), $regex: escapeRegExp(String(filters.tag).trim()),
$options: 'i', $options: 'i',
}, };
clauses.push({
$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) { 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);
......
...@@ -37,11 +37,11 @@ import { ...@@ -37,11 +37,11 @@ import {
Squares2X2Icon, Squares2X2Icon,
TableCellsIcon, TableCellsIcon,
TrashIcon, TrashIcon,
XMarkIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import { import {
startTransition, startTransition,
useDeferredValue,
useEffect, useEffect,
useMemo, useMemo,
useRef, useRef,
...@@ -180,6 +180,93 @@ function KeywordChips({ value, emptyText = '暂无关键字', limit = 0 }) { ...@@ -180,6 +180,93 @@ function KeywordChips({ value, emptyText = '暂无关键字', limit = 0 }) {
); );
} }
function KeywordEditor({
value,
onChange,
placeholder = '输入关键字后按 Enter 新增',
emptyText = '暂无关键字',
}) {
const [draft, setDraft] = useState('');
const keywords = normalizeKeywordList(value);
function commitKeyword(nextDraft = draft) {
const nextKeywords = normalizeKeywordList([...keywords, nextDraft]);
onChange(nextKeywords);
setDraft('');
}
function removeKeyword(keyword) {
onChange(keywords.filter((item) => item !== keyword));
}
function handleInputChange(event) {
const nextValue = event.target.value;
if (/[,,;;\n]/.test(nextValue)) {
commitKeyword(nextValue);
return;
}
setDraft(nextValue);
}
function handleInputKeyDown(event) {
if (event.key !== 'Enter' && event.key !== 'Tab') {
return;
}
const normalizedDraft = String(draft || '').trim();
if (!normalizedDraft) {
return;
}
event.preventDefault();
commitKeyword(normalizedDraft);
}
return (
<div className="rounded-[22px] border border-zinc-200 bg-white p-3 shadow-sm">
<Input
value={draft}
onChange={handleInputChange}
onKeyDown={handleInputKeyDown}
onBlur={() => {
if (String(draft || '').trim()) {
commitKeyword();
}
}}
placeholder={placeholder}
/>
<div className="mt-3 min-h-8">
{keywords.length > 0 ? (
<div className="flex flex-wrap gap-2">
{keywords.map((keyword) => (
<span
key={keyword}
className="inline-flex max-w-full items-center gap-1.5 rounded-full border border-[color:color-mix(in_srgb,var(--pm-brand)_18%,white)] bg-[color:color-mix(in_srgb,var(--pm-brand)_7%,white)] px-2.5 py-1 text-xs font-medium text-[var(--pm-brand-strong)] shadow-sm"
title={keyword}
>
<span className="truncate">{keyword}</span>
<button
type="button"
onClick={() => removeKeyword(keyword)}
className="inline-flex size-4 shrink-0 items-center justify-center rounded-full text-[var(--pm-brand)] transition hover:bg-[color:color-mix(in_srgb,var(--pm-brand)_12%,white)]"
aria-label={`删除关键字 ${keyword}`}
>
<XMarkIcon className="size-3" />
</button>
</span>
))}
</div>
) : (
<span className="text-sm text-zinc-400">{emptyText}</span>
)}
</div>
</div>
);
}
function CategoryTab({ active, label, count, onClick }) { function CategoryTab({ active, label, count, onClick }) {
return ( return (
<button <button
...@@ -265,7 +352,7 @@ function buildEmptyCaseForm() { ...@@ -265,7 +352,7 @@ function buildEmptyCaseForm() {
caseAttribute: '', caseAttribute: '',
caseDetails: '', caseDetails: '',
caseWarning: '', caseWarning: '',
keyword: '', keyword: [],
caseSource: '', caseSource: '',
caseLevel: '', caseLevel: '',
caseYear: '', caseYear: '',
...@@ -285,7 +372,7 @@ function buildCaseForm(caseItem) { ...@@ -285,7 +372,7 @@ function buildCaseForm(caseItem) {
caseAttribute: caseItem.caseAttribute || '', caseAttribute: caseItem.caseAttribute || '',
caseDetails: caseItem.caseDetails || '', caseDetails: caseItem.caseDetails || '',
caseWarning: caseItem.caseWarning || '', caseWarning: caseItem.caseWarning || '',
keyword: caseItem.keyword || '', keyword: caseItem.keywordList || caseItem.keyword || [],
caseSource: caseItem.caseSource || '', caseSource: caseItem.caseSource || '',
caseLevel: caseItem.caseLevel || '', caseLevel: caseItem.caseLevel || '',
caseYear: caseItem.caseYear || '', caseYear: caseItem.caseYear || '',
...@@ -298,7 +385,7 @@ function buildEmptyBatchEditForm() { ...@@ -298,7 +385,7 @@ function buildEmptyBatchEditForm() {
tag: '', tag: '',
caseType: '', caseType: '',
caseAttribute: '', caseAttribute: '',
keyword: '', keyword: [],
caseSource: '', caseSource: '',
caseLevel: '', caseLevel: '',
caseYear: '', caseYear: '',
...@@ -307,11 +394,25 @@ function buildEmptyBatchEditForm() { ...@@ -307,11 +394,25 @@ function buildEmptyBatchEditForm() {
} }
function hasBatchEditValues(values = {}) { function hasBatchEditValues(values = {}) {
return Object.values(values).some((value) => String(value ?? '').trim() !== ''); return Object.values(values).some((value) =>
Array.isArray(value)
? normalizeKeywordList(value).length > 0
: String(value ?? '').trim() !== '',
);
} }
function buildBatchEditPayload(values = {}) { function buildBatchEditPayload(values = {}) {
return Object.entries(values).reduce((accumulator, [key, value]) => { return Object.entries(values).reduce((accumulator, [key, value]) => {
if (Array.isArray(value)) {
const normalizedList = normalizeKeywordList(value);
if (normalizedList.length > 0) {
accumulator[key] = normalizedList;
}
return accumulator;
}
const normalizedValue = String(value ?? '').trim(); const normalizedValue = String(value ?? '').trim();
if (normalizedValue !== '') { if (normalizedValue !== '') {
...@@ -329,8 +430,17 @@ function buildImportPreviewRows(preview) { ...@@ -329,8 +430,17 @@ function buildImportPreviewRows(preview) {
export default function ResourceBookingPage() { export default function ResourceBookingPage() {
const importFileInputRef = useRef(null); const importFileInputRef = useRef(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const [tagFilter, setTagFilter] = useState(''); const [tagFilter, setTagFilter] = useState('');
const [caseSourceFilter, setCaseSourceFilter] = useState('');
const [caseTypeFilter, setCaseTypeFilter] = useState('');
const [caseAttributeFilter, setCaseAttributeFilter] = useState('');
const [appliedFilters, setAppliedFilters] = useState({
keyword: '',
tag: '',
caseSource: '',
caseType: '',
caseAttribute: '',
});
const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY); const [activeCategory, setActiveCategory] = useState(ALL_CATEGORY_KEY);
const [viewMode, setViewMode] = useState(CARD_VIEW_MODE); const [viewMode, setViewMode] = useState(CARD_VIEW_MODE);
const [cases, setCases] = useState([]); const [cases, setCases] = useState([]);
...@@ -371,7 +481,7 @@ export default function ResourceBookingPage() { ...@@ -371,7 +481,7 @@ export default function ResourceBookingPage() {
}, [activeCaseId, cases]); }, [activeCaseId, cases]);
const categoryTabs = useMemo(() => { const categoryTabs = useMemo(() => {
return [ const tabs = [
{ {
key: ALL_CATEGORY_KEY, key: ALL_CATEGORY_KEY,
label: '全部案例', label: '全部案例',
...@@ -383,15 +493,32 @@ export default function ResourceBookingPage() { ...@@ -383,15 +493,32 @@ export default function ResourceBookingPage() {
count: item.count, count: item.count,
})), })),
]; ];
}, [dashboard.categoryGroups, total]);
if (
activeCategory !== ALL_CATEGORY_KEY &&
!tabs.some((item) => item.key === activeCategory)
) {
tabs.push({
key: activeCategory,
label: activeCategory,
count: total,
});
}
return tabs;
}, [activeCategory, dashboard.categoryGroups, total]);
const visibleCases = useMemo(() => { const visibleCases = useMemo(() => {
if (activeCategory === ALL_CATEGORY_KEY) {
return cases; return cases;
} }, [cases]);
return cases.filter((item) => item.category === activeCategory); const activeFilters = useMemo(
}, [activeCategory, cases]); () => ({
...appliedFilters,
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
}),
[activeCategory, appliedFilters],
);
const selectedCaseIdSet = useMemo( const selectedCaseIdSet = useMemo(
() => new Set(selectedCaseIds), () => new Set(selectedCaseIds),
...@@ -442,14 +569,43 @@ export default function ResourceBookingPage() { ...@@ -442,14 +569,43 @@ export default function ResourceBookingPage() {
); );
}, [visibleCases]); }, [visibleCases]);
function buildAppliedFilterValues() {
return {
keyword: String(query || '').trim(),
tag: String(tagFilter || '').trim(),
caseSource: String(caseSourceFilter || '').trim(),
caseType: String(caseTypeFilter || '').trim(),
caseAttribute: String(caseAttributeFilter || '').trim(),
};
}
function handleApplyFilters(event) {
event?.preventDefault();
setAppliedFilters(buildAppliedFilterValues());
}
function handleResetFilters() {
setQuery('');
setTagFilter('');
setCaseSourceFilter('');
setCaseTypeFilter('');
setCaseAttributeFilter('');
setAppliedFilters({
keyword: '',
tag: '',
caseSource: '',
caseType: '',
caseAttribute: '',
});
setActiveCategory(ALL_CATEGORY_KEY);
}
async function loadCaseList(nextPage = 0, nextPageSize = pageSize) { async function loadCaseList(nextPage = 0, nextPageSize = pageSize) {
setLoading(true); setLoading(true);
setError(''); setError('');
const result = await loadCasePage({ const result = await loadCasePage({
keyword: deferredQuery, ...activeFilters,
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
page: nextPage, page: nextPage,
pageSize: nextPageSize, pageSize: nextPageSize,
}); });
...@@ -487,9 +643,7 @@ export default function ResourceBookingPage() { ...@@ -487,9 +643,7 @@ export default function ResourceBookingPage() {
setError(''); setError('');
const result = await loadCasePage({ const result = await loadCasePage({
keyword: deferredQuery, ...activeFilters,
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
page: 0, page: 0,
pageSize, pageSize,
}); });
...@@ -526,7 +680,7 @@ export default function ResourceBookingPage() { ...@@ -526,7 +680,7 @@ export default function ResourceBookingPage() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [activeCategory, deferredQuery, pageSize, tagFilter]); }, [activeFilters, pageSize]);
async function refreshCurrentList(preferredPage = page) { async function refreshCurrentList(preferredPage = page) {
const totalPages = Math.max(1, Math.ceil(total / pageSize)); const totalPages = Math.max(1, Math.ceil(total / pageSize));
...@@ -849,9 +1003,7 @@ export default function ResourceBookingPage() { ...@@ -849,9 +1003,7 @@ export default function ResourceBookingPage() {
try { try {
const result = await exportCaseWorkbook({ const result = await exportCaseWorkbook({
keyword: deferredQuery, ...activeFilters,
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
}); });
setNotice(`已导出 ${result.count} 条案例到 ${result.fileName}。`); setNotice(`已导出 ${result.count} 条案例到 ${result.fileName}。`);
} catch (exportError) { } catch (exportError) {
...@@ -957,11 +1109,7 @@ export default function ResourceBookingPage() { ...@@ -957,11 +1109,7 @@ export default function ResourceBookingPage() {
<Button <Button
plain plain
className="w-full justify-center whitespace-nowrap text-zinc-600 cursor-pointer" className="w-full justify-center whitespace-nowrap text-zinc-600 cursor-pointer"
onClick={() => { onClick={handleResetFilters}
setQuery('');
setTagFilter('');
setActiveCategory(ALL_CATEGORY_KEY);
}}
> >
<ArrowPathIcon className="size-4" /> <ArrowPathIcon className="size-4" />
重置当前筛选 重置当前筛选
...@@ -993,9 +1141,12 @@ export default function ResourceBookingPage() { ...@@ -993,9 +1141,12 @@ export default function ResourceBookingPage() {
</Panel> </Panel>
<Panel className="p-5 sm:p-6"> <Panel className="p-5 sm:p-6">
<div className="grid gap-4 xl:grid-cols-[1.4fr_0.8fr]"> <form
<div> className="grid gap-4 lg:grid-cols-2 xl:grid-cols-[1.15fr_repeat(4,minmax(0,1fr))_auto]"
<div className="mb-2 text-sm font-medium text-zinc-600">搜索案例</div> onSubmit={handleApplyFilters}
>
<div className="lg:col-span-2 xl:col-span-1">
<div className="mb-2 text-sm font-medium text-zinc-600">全文搜索</div>
<InputGroup> <InputGroup>
<MagnifyingGlassIcon data-slot="icon" /> <MagnifyingGlassIcon data-slot="icon" />
<Input <Input
...@@ -1007,15 +1158,53 @@ export default function ResourceBookingPage() { ...@@ -1007,15 +1158,53 @@ export default function ResourceBookingPage() {
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签筛选</div> <div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div>
<Input
value={caseSourceFilter}
onChange={(event) => setCaseSourceFilter(event.target.value)}
placeholder="例如:监管通报"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例类型</div>
<Input
value={caseTypeFilter}
onChange={(event) => setCaseTypeFilter(event.target.value)}
placeholder="例如:审批绕行"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">问题属性</div>
<Input
value={caseAttributeFilter}
onChange={(event) => setCaseAttributeFilter(event.target.value)}
placeholder="例如:流程绕行"
/>
</div>
<div>
<div className="mb-2 text-sm font-medium text-zinc-600">分类标签</div>
<Input <Input
list="case-tag-suggestions"
value={tagFilter} value={tagFilter}
onChange={(event) => setTagFilter(event.target.value)} onChange={(event) => setTagFilter(event.target.value)}
placeholder="例如:采购审批" placeholder="例如:采购审批"
/> />
</div> </div>
<div className="flex items-end">
<Button
color="blue"
type="submit"
className="w-full cursor-pointer whitespace-nowrap"
disabled={loading}
>
<MagnifyingGlassIcon className="size-4" />
查询
</Button>
</div> </div>
</form>
<div className="mt-6 flex flex-wrap gap-2"> <div className="mt-6 flex flex-wrap gap-2">
{categoryTabs.map((item) => ( {categoryTabs.map((item) => (
...@@ -1360,17 +1549,6 @@ export default function ResourceBookingPage() { ...@@ -1360,17 +1549,6 @@ export default function ResourceBookingPage() {
</div> </div>
)} )}
<datalist id="case-tag-suggestions">
{(dashboard.tagSuggestions || []).map((item) => (
<option key={item} value={item} />
))}
</datalist>
<datalist id="case-category-suggestions">
{(dashboard.categorySuggestions || []).map((item) => (
<option key={item} value={item} />
))}
</datalist>
<Dialog open={isCreateOpen} onClose={setIsCreateOpen} size="4xl"> <Dialog open={isCreateOpen} onClose={setIsCreateOpen} size="4xl">
<DialogTitle>手动新建案例</DialogTitle> <DialogTitle>手动新建案例</DialogTitle>
<DialogBody> <DialogBody>
...@@ -1391,7 +1569,6 @@ export default function ResourceBookingPage() { ...@@ -1391,7 +1569,6 @@ export default function ResourceBookingPage() {
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">分类</div> <div className="mb-2 text-sm font-medium text-zinc-600">分类</div>
<Input <Input
list="case-category-suggestions"
value={createForm.category} value={createForm.category}
onChange={(event) => onChange={(event) =>
setCreateForm((prev) => ({ setCreateForm((prev) => ({
...@@ -1405,7 +1582,6 @@ export default function ResourceBookingPage() { ...@@ -1405,7 +1582,6 @@ export default function ResourceBookingPage() {
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签</div> <div className="mb-2 text-sm font-medium text-zinc-600">标签</div>
<Input <Input
list="case-tag-suggestions"
value={createForm.tag} value={createForm.tag}
onChange={(event) => onChange={(event) =>
setCreateForm((prev) => ({ setCreateForm((prev) => ({
...@@ -1444,15 +1620,15 @@ export default function ResourceBookingPage() { ...@@ -1444,15 +1620,15 @@ export default function ResourceBookingPage() {
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div> <div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<Input <KeywordEditor
value={createForm.keyword} value={createForm.keyword}
onChange={(event) => onChange={(nextKeywords) =>
setCreateForm((prev) => ({ setCreateForm((prev) => ({
...prev, ...prev,
keyword: event.target.value, keyword: nextKeywords,
})) }))
} }
placeholder="例如:采购审批, 拆单" placeholder="输入关键字后按 Enter 新增"
/> />
</div> </div>
<div> <div>
...@@ -1640,7 +1816,6 @@ export default function ResourceBookingPage() { ...@@ -1640,7 +1816,6 @@ export default function ResourceBookingPage() {
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">分类</div> <div className="mb-2 text-sm font-medium text-zinc-600">分类</div>
<Input <Input
list="case-category-suggestions"
value={detailForm.category} value={detailForm.category}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...@@ -1653,7 +1828,6 @@ export default function ResourceBookingPage() { ...@@ -1653,7 +1828,6 @@ export default function ResourceBookingPage() {
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签</div> <div className="mb-2 text-sm font-medium text-zinc-600">标签</div>
<Input <Input
list="case-tag-suggestions"
value={detailForm.tag} value={detailForm.tag}
onChange={(event) => onChange={(event) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...@@ -1689,25 +1863,17 @@ export default function ResourceBookingPage() { ...@@ -1689,25 +1863,17 @@ export default function ResourceBookingPage() {
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div> <div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<div className="rounded-[22px] border border-zinc-200 bg-white p-3 shadow-sm"> <KeywordEditor
<Input
value={detailForm.keyword} value={detailForm.keyword}
onChange={(event) => onChange={(nextKeywords) =>
setDetailForm((prev) => ({ setDetailForm((prev) => ({
...prev, ...prev,
keyword: event.target.value, keyword: nextKeywords,
})) }))
} }
placeholder="例如:采购审批, 拆单" placeholder="输入关键字后按 Enter 新增"
/>
<div className="mt-3 min-h-8">
<KeywordChips
value={detailForm.keyword}
emptyText="暂无关键字"
/> />
</div> </div>
</div>
</div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div> <div className="mb-2 text-sm font-medium text-zinc-600">案例来源</div>
<Input <Input
...@@ -1819,7 +1985,6 @@ export default function ResourceBookingPage() { ...@@ -1819,7 +1985,6 @@ export default function ResourceBookingPage() {
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">分类</div> <div className="mb-2 text-sm font-medium text-zinc-600">分类</div>
<Input <Input
list="case-category-suggestions"
value={batchEditForm.category} value={batchEditForm.category}
onChange={(event) => onChange={(event) =>
setBatchEditForm((prev) => ({ setBatchEditForm((prev) => ({
...@@ -1833,7 +1998,6 @@ export default function ResourceBookingPage() { ...@@ -1833,7 +1998,6 @@ export default function ResourceBookingPage() {
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">标签</div> <div className="mb-2 text-sm font-medium text-zinc-600">标签</div>
<Input <Input
list="case-tag-suggestions"
value={batchEditForm.tag} value={batchEditForm.tag}
onChange={(event) => onChange={(event) =>
setBatchEditForm((prev) => ({ setBatchEditForm((prev) => ({
...@@ -1872,15 +2036,16 @@ export default function ResourceBookingPage() { ...@@ -1872,15 +2036,16 @@ export default function ResourceBookingPage() {
</div> </div>
<div> <div>
<div className="mb-2 text-sm font-medium text-zinc-600">关键字</div> <div className="mb-2 text-sm font-medium text-zinc-600">关键字</div>
<Input <KeywordEditor
value={batchEditForm.keyword} value={batchEditForm.keyword}
onChange={(event) => onChange={(nextKeywords) =>
setBatchEditForm((prev) => ({ setBatchEditForm((prev) => ({
...prev, ...prev,
keyword: event.target.value, keyword: nextKeywords,
})) }))
} }
placeholder="填写后将批量覆盖关键字" placeholder="输入后按 Enter,填写后将批量覆盖关键字"
emptyText="不覆盖关键字"
/> />
</div> </div>
<div> <div>
......
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