Commit 894205db authored by drigle's avatar drigle

feat: improve case search and detail interactions

parent 753f8f99
...@@ -2,7 +2,8 @@ ...@@ -2,7 +2,8 @@
"name": "risk_case_library", "name": "risk_case_library",
"version": "1.0.4", "version": "1.0.4",
"build": "abc", "build": "abc",
"description": "merge master", "description": "警示案例库",
"date": "2025-07-14 19:27:20", "date": "2025-07-14 19:27:20",
"author": "yang" "author": "yang",
"spa": true
} }
...@@ -79,6 +79,7 @@ ...@@ -79,6 +79,7 @@
1. 用户进入 `案例管理` 1. 用户进入 `案例管理`
2. 点击 `手动新建` 2. 点击 `手动新建`
3. 填写标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份 3. 填写标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份
- 标题、案例类型、案例详情至少填写一项;未满足时在三个相关表单字段内部展示必填校验、标记错误状态并聚焦标题输入框,弹框保持打开
4. 前端调用 `POST /record` 4. 前端调用 `POST /record`
5. 成功后生成一条 `risk_case_library` 记录 5. 成功后生成一条 `risk_case_library` 记录
6. 页面顶部提示创建成功,并刷新当前列表 6. 页面顶部提示创建成功,并刷新当前列表
...@@ -110,7 +111,7 @@ ...@@ -110,7 +111,7 @@
1. 用户进入 `案例管理` 1. 用户进入 `案例管理`
2. 点击 `Excel 批量导出` 2. 点击 `Excel 批量导出`
3. 前端按当前全文搜索、案例性质、案例来源、案例类型、问题属性、分类标签等筛选条件查询全部案例 3. 前端按当前案例性质及最近一次点击“查询”提交的全文搜索、案例来源、案例类型、问题属性、分类标签等筛选条件查询全部案例;尚未提交的输入不影响导出范围
4. 导出文件统一输出以下 11 列: 4. 导出文件统一输出以下 11 列:
- `标题` - `标题`
- `分类` - `分类`
...@@ -138,7 +139,7 @@ ...@@ -138,7 +139,7 @@
8. 切换案例性质标签后,从第一页通过 `POST /record/query` 的 `filter.metadata.category` 远程查询;`未分类` 使用 `$in: [null, "", "未分类"]` 兼容缺失及空字符串分类。页面不对当前页案例做本地二次搜索 8. 切换案例性质标签后,从第一页通过 `POST /record/query` 的 `filter.metadata.category` 远程查询;`未分类` 使用 `$in: [null, "", "未分类"]` 兼容缺失及空字符串分类。页面不对当前页案例做本地二次搜索
9. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换 9. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换
10. 卡片视图用于浏览案例摘要;每条案例使用独立一级业务卡片,分类/等级/标签、标题、类型、摘要和时间信息按层级展示,点击卡片后以弹框方式查看并编辑详情 10. 卡片视图用于浏览案例摘要;每条案例使用独立一级业务卡片,分类/等级/标签、标题、类型、摘要和时间信息按层级展示,点击卡片后以弹框方式查看并编辑详情
11. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段;宽表在 DataGrid 内部横向滚动,各字段只在所属单元格内单行截断,完整值通过 HeroUI Tooltip 查看,不允许跨列覆盖;首列标题仅用于展示,不承担点击操作,查看详情统一从右侧操作列进入 11. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段;宽表在 DataGrid 内部横向滚动,各字段只在所属单元格内单行截断,完整值通过 HeroUI Tooltip 查看,不允许跨列覆盖;点击标题(`title` 或 `metadata.label`)或右侧操作列的“查看详情”按钮均打开当前案例详情弹框,默认进入查看态。标题使用 HeroUI Button,支持键盘操作,点击不改变表格勾选状态;两处入口复用同一打开逻辑及异常处理,不新增请求或修改案例字段
12. 表格视图按页加载案例,默认每页 20 条,并显示页码、当前页和匹配总数;分页栏提供 20、50、100 条/页选择器,切换后回到第一页并按原查询条件重新加载 12. 表格视图按页加载案例,默认每页 20 条,并显示页码、当前页和匹配总数;分页栏提供 20、50、100 条/页选择器,切换后回到第一页并按原查询条件重新加载
13. 表格视图支持单选、全选当前页案例,并显示当前勾选数量 13. 表格视图支持单选、全选当前页案例,并显示当前勾选数量
...@@ -257,7 +258,9 @@ ...@@ -257,7 +258,9 @@
- 问题属性筛选 - 问题属性筛选
- 分类标签筛选 - 分类标签筛选
- 筛选项为普通文本输入框,不提供下拉框 - 筛选项为普通文本输入框,不提供下拉框
- 默认首行展示三个筛选项,剩余筛选项通过 HeroUI `Disclosure` 展开;不展示刷新或查询按钮 - 各分类 Tab 共用筛选区,默认首行展示三个筛选项及 HeroUI `Button` 查询按钮,剩余筛选项通过 HeroUI `Disclosure` 展开
- 修改或清空全文关键词、案例来源、案例类型、问题属性、分类标签只更新输入;点击“查询”后统一提交(去除首尾空格),清空勾选并从第一页加载结果,相同条件可再次点击查询
- 查询期间按钮禁用并显示加载状态;查询失败沿用列表错误提示,保留输入,完成后可再次点击查询重试
- `Segment` 视图切换(卡片 / 表格) - `Segment` 视图切换(卡片 / 表格)
- 顶部快捷操作区采用紧凑工具条呈现,保留手动新建、Excel 导入和 Excel 导出入口 - 顶部快捷操作区采用紧凑工具条呈现,保留手动新建、Excel 导入和 Excel 导出入口
- 手动新建按钮 - 手动新建按钮
...@@ -328,8 +331,9 @@ ...@@ -328,8 +331,9 @@
- 首屏只加载第一页,默认每页 20 条 - 首屏只加载第一页,默认每页 20 条
- 表格与卡片列表通过页码分页加载,不使用下滑自动加载 - 表格与卡片列表通过页码分页加载,不使用下滑自动加载
- 每页固定加载 20 条,不展示或提供每页条数选择器 - 每页固定加载 20 条,不展示或提供每页条数选择器
- 当用户修改全文搜索、任一结构化筛选或切换案例性质时,实时从第一页重新加载 - 修改全文搜索或任一结构化筛选不触发请求;点击“查询”后从第一页重新加载。切换案例性质时使用最近已提交的筛选条件加载第一页,不提交尚未查询的输入
- 搜索与筛选统一发起远程查询,不在当前页结果中做本地搜索;连续请求只采用最新筛选状态对应的结果 - 分页、保存后的列表刷新和批量导出均沿用已提交的筛选条件;清空筛选项后也需点击“查询”才生效
- 搜索与筛选在提交后统一发起远程查询,不在当前页结果中做本地搜索;连续筛选请求只采用最新已提交筛选状态对应的结果
- 分类标签使用独立 aggregate 请求;列表仍按页查询,不得先拉取全量案例后再生成标签 - 分类标签使用独立 aggregate 请求;列表仍按页查询,不得先拉取全量案例后再生成标签
- 总数优先读取 `POST /record/query` 返回的 `total_records`;接口未返回总数时再回退到 `count` 接口 - 总数优先读取 `POST /record/query` 返回的 `total_records`;接口未返回总数时再回退到 `count` 接口
......
...@@ -21,6 +21,7 @@ import { ...@@ -21,6 +21,7 @@ import {
Chip, Chip,
CloseButton, CloseButton,
Disclosure, Disclosure,
FieldError,
Input, Input,
Label, Label,
Modal, Modal,
...@@ -79,6 +80,7 @@ const ALL_CATEGORY_KEY = '__all__'; ...@@ -79,6 +80,7 @@ const ALL_CATEGORY_KEY = '__all__';
const CARD_VIEW_MODE = 'card'; const CARD_VIEW_MODE = 'card';
const TABLE_VIEW_MODE = 'table'; const TABLE_VIEW_MODE = 'table';
const XINYUAN_4XL_DIALOG_CLASS = 'bg-background p-7 sm:max-w-[68rem]'; const XINYUAN_4XL_DIALOG_CLASS = 'bg-background p-7 sm:max-w-[68rem]';
const CREATE_CASE_REQUIRED_ERROR = '请至少填写标题、案例类型或案例详情中的一项。';
const FALLBACK_CASE_FIELDS = [ const FALLBACK_CASE_FIELDS = [
{ name: 'label', label: '标题', type: 'text' }, { name: 'label', label: '标题', type: 'text' },
{ name: 'category', label: '分类', type: 'text' }, { name: 'category', label: '分类', type: 'text' },
...@@ -514,6 +516,7 @@ export default function ResourceBookingPage() { ...@@ -514,6 +516,7 @@ export default function ResourceBookingPage() {
const [caseSourceFilter, setCaseSourceFilter] = useState(''); const [caseSourceFilter, setCaseSourceFilter] = useState('');
const [caseTypeFilter, setCaseTypeFilter] = useState(''); const [caseTypeFilter, setCaseTypeFilter] = useState('');
const [caseAttributeFilter, setCaseAttributeFilter] = useState(''); const [caseAttributeFilter, setCaseAttributeFilter] = useState('');
const [submittedFilters, setSubmittedFilters] = 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 [categoryTabLoading, setCategoryTabLoading] = useState(true);
...@@ -539,6 +542,7 @@ export default function ResourceBookingPage() { ...@@ -539,6 +542,7 @@ export default function ResourceBookingPage() {
const [exportingTemplate, setExportingTemplate] = useState(false); const [exportingTemplate, setExportingTemplate] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const [notice, setNotice] = useState(''); const [notice, setNotice] = useState('');
const [createError, setCreateError] = useState('');
const [isDemoMode, setIsDemoMode] = useState(false); const [isDemoMode, setIsDemoMode] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false); const [isCreateOpen, setIsCreateOpen] = useState(false);
const [isDetailOpen, setIsDetailOpen] = useState(false); const [isDetailOpen, setIsDetailOpen] = useState(false);
...@@ -562,6 +566,7 @@ export default function ResourceBookingPage() { ...@@ -562,6 +566,7 @@ export default function ResourceBookingPage() {
const pendingLocalMaterialsRef = useRef({}); const pendingLocalMaterialsRef = useRef({});
const editSessionRef = useRef(0); const editSessionRef = useRef(0);
const categoryTabRequestRef = useRef(0); const categoryTabRequestRef = useRef(0);
const createTitleInputRef = useRef(null);
const activeGridView = useMemo( const activeGridView = useMemo(
() => gridViews.find((view) => view.name === activeGridViewName) || null, () => gridViews.find((view) => view.name === activeGridViewName) || null,
...@@ -602,22 +607,22 @@ export default function ResourceBookingPage() { ...@@ -602,22 +607,22 @@ export default function ResourceBookingPage() {
const activeFilters = useMemo( const activeFilters = useMemo(
() => ({ () => ({
...submittedFilters,
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
}),
[activeCategory, submittedFilters],
);
function handleSearch() {
setSelectedCaseIds([]);
setSubmittedFilters({
keyword: String(query || '').trim(), keyword: String(query || '').trim(),
tag: String(tagFilter || '').trim(), tag: String(tagFilter || '').trim(),
caseSource: String(caseSourceFilter || '').trim(), caseSource: String(caseSourceFilter || '').trim(),
caseType: String(caseTypeFilter || '').trim(), caseType: String(caseTypeFilter || '').trim(),
caseAttribute: String(caseAttributeFilter || '').trim(), caseAttribute: String(caseAttributeFilter || '').trim(),
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory, });
}), }
[
activeCategory,
caseAttributeFilter,
caseSourceFilter,
caseTypeFilter,
query,
tagFilter,
],
);
const selectedCaseIdSet = useMemo( const selectedCaseIdSet = useMemo(
() => new Set(selectedCaseIds), () => new Set(selectedCaseIds),
...@@ -645,12 +650,30 @@ export default function ResourceBookingPage() { ...@@ -645,12 +650,30 @@ export default function ResourceBookingPage() {
pin: column.fixed === 'left' || column.pin === 'start' ? 'start' : undefined, pin: column.fixed === 'left' || column.pin === 'start' ? 'start' : undefined,
isPrimary: index === 0, isPrimary: index === 0,
sortable: Boolean(column.sortable || column.sort), sortable: Boolean(column.sortable || column.sort),
render: (item) => ( render: (item) => {
<DataTableText const text = getCaseColumnDisplayValue(item, column) || '-';
showTooltip const isTitle = column.field === 'title' || getFieldNameFromPath(column.field) === 'label';
text={getCaseColumnDisplayValue(item, column) || '-'}
/> return isTitle ? (
), <Tooltip>
<Button
aria-label={`查看详情:${text}`}
className="h-auto min-w-0 max-w-full justify-start rounded-sm px-0 py-1 text-left font-medium text-accent hover:underline"
variant="ghost"
onPress={() => openCaseDetail(item)}
>
<span className="min-w-0 truncate">{text}</span>
</Button>
<Tooltip.Content>
<span className="block max-w-[min(32rem,calc(100vw-2rem))] whitespace-pre-wrap break-words">
{text}
</span>
</Tooltip.Content>
</Tooltip>
) : (
<DataTableText showTooltip text={text} />
);
},
})), })),
{ {
key: 'actions', key: 'actions',
...@@ -852,6 +875,16 @@ export default function ResourceBookingPage() { ...@@ -852,6 +875,16 @@ export default function ResourceBookingPage() {
function resetCreateForm() { function resetCreateForm() {
setCreateForm(buildEmptyCaseForm()); setCreateForm(buildEmptyCaseForm());
setCreateError('');
}
function handleCreateOpenChange(isOpen) {
if (!isOpen && creating) {
return;
}
setCreateError('');
setIsCreateOpen(isOpen);
} }
function resetBatchEditForm() { function resetBatchEditForm() {
...@@ -933,11 +966,13 @@ export default function ResourceBookingPage() { ...@@ -933,11 +966,13 @@ export default function ResourceBookingPage() {
!String(createForm.caseType || '').trim() && !String(createForm.caseType || '').trim() &&
!String(createForm.caseDetails || '').trim() !String(createForm.caseDetails || '').trim()
) { ) {
setError('请至少填写标题、案例类型或案例详情中的一项。'); setCreateError(CREATE_CASE_REQUIRED_ERROR);
createTitleInputRef.current?.focus();
return; return;
} }
setCreating(true); setCreating(true);
setCreateError('');
setError(''); setError('');
setNotice(''); setNotice('');
...@@ -950,7 +985,7 @@ export default function ResourceBookingPage() { ...@@ -950,7 +985,7 @@ export default function ResourceBookingPage() {
await Promise.all([refreshCurrentList(0), loadCategoryTabItems()]); await Promise.all([refreshCurrentList(0), loadCategoryTabItems()]);
setActiveCaseId(created.id || ''); setActiveCaseId(created.id || '');
} catch (createError) { } catch (createError) {
setError(createError?.message || '案例创建失败,请稍后重试。'); setCreateError(createError?.message || '案例创建失败,请稍后重试。');
} finally { } finally {
setCreating(false); setCreating(false);
} }
...@@ -1348,7 +1383,15 @@ export default function ResourceBookingPage() { ...@@ -1348,7 +1383,15 @@ export default function ResourceBookingPage() {
</div> </div>
</div> </div>
<div className="flex min-w-0 items-end justify-end"> <div className="flex min-w-0 items-end justify-end gap-2">
<Button
isDisabled={loading}
onPress={handleSearch}
variant="primary"
>
{loading ? <Spinner size="sm" /> : <MagnifyingGlassIcon size={16} />}
查询
</Button>
<Disclosure.Trigger className="button button--sm button--ghost"> <Disclosure.Trigger className="button button--sm button--ghost">
<span>{showAdvancedFilters ? '收起' : '展开'}</span> <span>{showAdvancedFilters ? '收起' : '展开'}</span>
<Disclosure.Indicator> <Disclosure.Indicator>
...@@ -1461,6 +1504,7 @@ export default function ResourceBookingPage() { ...@@ -1461,6 +1504,7 @@ export default function ResourceBookingPage() {
onPress={() => { onPress={() => {
setError(''); setError('');
setNotice(''); setNotice('');
setCreateError('');
setIsCreateOpen(true); setIsCreateOpen(true);
}} }}
> >
...@@ -1547,7 +1591,7 @@ export default function ResourceBookingPage() { ...@@ -1547,7 +1591,7 @@ export default function ResourceBookingPage() {
</div> </div>
)} )}
<Modal.Root isOpen={isCreateOpen} onOpenChange={setIsCreateOpen}> <Modal.Root isOpen={isCreateOpen} onOpenChange={handleCreateOpenChange}>
<Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} /> <Modal.Trigger aria-hidden="true" className="sr-only" tabIndex={-1} />
<Modal.Backdrop> <Modal.Backdrop>
<Modal.Container placement="center" scroll="inside" size="lg"> <Modal.Container placement="center" scroll="inside" size="lg">
...@@ -1557,21 +1601,34 @@ export default function ResourceBookingPage() { ...@@ -1557,21 +1601,34 @@ export default function ResourceBookingPage() {
<Modal.Heading>手动新建案例</Modal.Heading> <Modal.Heading>手动新建案例</Modal.Heading>
</Modal.Header> </Modal.Header>
<Modal.Body> <Modal.Body>
{createError && createError !== CREATE_CASE_REQUIRED_ERROR ? (
<Alert className="mb-4" status="danger" role="alert">
<Alert.Content>
<Alert.Description>{createError}</Alert.Description>
</Alert.Content>
</Alert>
) : null}
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<TextField className="sm:col-span-2"> <TextField
className="sm:col-span-2"
isInvalid={createError === CREATE_CASE_REQUIRED_ERROR}
>
<Label>标题</Label> <Label>标题</Label>
<Input <Input
ref={createTitleInputRef}
fullWidth fullWidth
value={createForm.label} value={createForm.label}
variant="secondary" variant="secondary"
onChange={(event) => onChange={(event) => {
setCreateError('');
setCreateForm((prev) => ({ setCreateForm((prev) => ({
...prev, ...prev,
label: event.target.value, label: event.target.value,
})) }));
} }}
placeholder="例如:采购审批绕行典型案例" placeholder="例如:采购审批绕行典型案例"
/> />
<FieldError>{createError === CREATE_CASE_REQUIRED_ERROR ? createError : null}</FieldError>
</TextField> </TextField>
<TextField> <TextField>
<Label>分类</Label> <Label>分类</Label>
...@@ -1603,20 +1660,24 @@ export default function ResourceBookingPage() { ...@@ -1603,20 +1660,24 @@ export default function ResourceBookingPage() {
placeholder="例如:采购审批" placeholder="例如:采购审批"
/> />
</TextField> </TextField>
<TextField> <TextField isInvalid={createError === CREATE_CASE_REQUIRED_ERROR}>
<Label>案例类型</Label> <Label>案例类型</Label>
<Input <Input
fullWidth fullWidth
value={createForm.caseType} value={createForm.caseType}
variant="secondary" variant="secondary"
onChange={(event) => onChange={(event) => {
setCreateError('');
setCreateForm((prev) => ({ setCreateForm((prev) => ({
...prev, ...prev,
caseType: event.target.value, caseType: event.target.value,
})) }));
} }}
placeholder="例如:审批绕行" placeholder="例如:审批绕行"
/> />
<FieldError>
{createError === CREATE_CASE_REQUIRED_ERROR ? '请填写案例类型,或改填标题、案例详情。' : null}
</FieldError>
</TextField> </TextField>
<TextField> <TextField>
<Label>案例属性</Label> <Label>案例属性</Label>
...@@ -1692,7 +1753,10 @@ export default function ResourceBookingPage() { ...@@ -1692,7 +1753,10 @@ export default function ResourceBookingPage() {
placeholder="例如:2024" placeholder="例如:2024"
/> />
</TextField> </TextField>
<TextField className="sm:col-span-2"> <TextField
className="sm:col-span-2"
isInvalid={createError === CREATE_CASE_REQUIRED_ERROR}
>
<div className="mb-2 flex items-center justify-between gap-3"> <div className="mb-2 flex items-center justify-between gap-3">
<Label>案例详情</Label> <Label>案例详情</Label>
<span> <span>
...@@ -1704,14 +1768,18 @@ export default function ResourceBookingPage() { ...@@ -1704,14 +1768,18 @@ export default function ResourceBookingPage() {
rows={12} rows={12}
variant="secondary" variant="secondary"
value={createForm.caseDetails} value={createForm.caseDetails}
onChange={(event) => onChange={(event) => {
setCreateError('');
setCreateForm((prev) => ({ setCreateForm((prev) => ({
...prev, ...prev,
caseDetails: event.target.value, caseDetails: event.target.value,
})) }));
} }}
placeholder="粘贴或录入完整案例详情" placeholder="粘贴或录入完整案例详情"
/> />
<FieldError>
{createError === CREATE_CASE_REQUIRED_ERROR ? '请填写案例详情,或改填标题、案例类型。' : null}
</FieldError>
</TextField> </TextField>
<TextField className="sm:col-span-2"> <TextField className="sm:col-span-2">
<Label>案例警示</Label> <Label>案例警示</Label>
...@@ -1737,7 +1805,7 @@ export default function ResourceBookingPage() { ...@@ -1737,7 +1805,7 @@ export default function ResourceBookingPage() {
size="sm" size="sm"
variant="secondary" variant="secondary"
onPress={() => { onPress={() => {
setIsCreateOpen(false); handleCreateOpenChange(false);
resetCreateForm(); resetCreateForm();
}} }}
> >
......
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