Commit 7b7c281e authored by David Yang's avatar David Yang

update: ui

parent 6f4521a3
...@@ -14,6 +14,8 @@ const ROUTE_PATHS = { ...@@ -14,6 +14,8 @@ const ROUTE_PATHS = {
export default defineConfig({ export default defineConfig({
layout: false, layout: false,
fastRefresh: true, fastRefresh: true,
base: '/risk_case_library/',
publicPath: '/risk_case_library/',
alias: { alias: {
react: resolvePackageRoot('react'), react: resolvePackageRoot('react'),
'react-dom': resolvePackageRoot('react-dom'), 'react-dom': resolvePackageRoot('react-dom'),
......
{
"name": "project_manager_new",
"version": "1.0.1",
"build": "abc",
"description": "merge master",
"date": "2025-07-14 19:27:20",
"author": "yang"
}
...@@ -17,7 +17,7 @@ const menuGroups = [ ...@@ -17,7 +17,7 @@ const menuGroups = [
title: '案例库工作台', title: '案例库工作台',
items: [ items: [
{ {
name: '案例总览', name: '驾驶舱',
path: '/resource/stat', path: '/resource/stat',
icon: Squares2X2Icon, icon: Squares2X2Icon,
}, },
......
...@@ -22,14 +22,15 @@ ...@@ -22,14 +22,15 @@
保留现有两级菜单框架,但替换业务语义: 保留现有两级菜单框架,但替换业务语义:
- 左侧菜单:`案例总览` - 左侧菜单:`驾驶舱`
- 左侧菜单:`案例管理` - 左侧菜单:`案例管理`
推荐职责划分: 推荐职责划分:
- `案例总览` - `驾驶舱`
- 展示案例总数、案例性质分布、关键字热度、最近更新 - 展示案例总数、分类数量、标签数量、案例类型数量、待整理数量、最近 7 天更新
- 通过图表展示案例性质占比、案例年份分布、近 7 日更新活跃度 - 通过图表展示分类结构、案例类型排行、案例来源分布
- 展示关键字词云与最近更新案例
- 提供对库内内容密度和结构完整度的快速判断 - 提供对库内内容密度和结构完整度的快速判断
- `案例管理` - `案例管理`
- 搜索、筛选、浏览案例卡片 - 搜索、筛选、浏览案例卡片
...@@ -55,7 +56,7 @@ ...@@ -55,7 +56,7 @@
- `case_attribute`:案例属性 - `case_attribute`:案例属性
- `case_details`:案例详情正文 - `case_details`:案例详情正文
- `case_warning`:案例警示 - `case_warning`:案例警示
- `keyword`:关键字,用于检索和热度统计 - `keyword`:关键字,多值字符串数组,用于检索和热度统计
- `case_source`:案例来源 - `case_source`:案例来源
- `case_level`:案例等级 - `case_level`:案例等级
- `case_year`:案例年份 - `case_year`:案例年份
...@@ -88,7 +89,7 @@ ...@@ -88,7 +89,7 @@
- `案例属性` → `metadata.case_attribute` - `案例属性` → `metadata.case_attribute`
- `案例详情` → `content`、`metadata.case_details` - `案例详情` → `content`、`metadata.case_details`
- `案例警示` → `metadata.case_warning` - `案例警示` → `metadata.case_warning`
- `关键字` → `metadata.keyword`,同时回填 `metadata.tag` 以兼容现有检索 - `关键字` → 按英文逗号或中文逗号拆分、去空、去重后写入 `metadata.keyword` 字符串数组,同时回填 `metadata.tag` 以兼容现有检索
- `案例来源` → `metadata.case_source` - `案例来源` → `metadata.case_source`
- `案例等级` → `metadata.case_level` - `案例等级` → `metadata.case_level`
- `案例年份` → `metadata.case_year` - `案例年份` → `metadata.case_year`
...@@ -114,6 +115,7 @@ ...@@ -114,6 +115,7 @@
- `案例等级` - `案例等级`
- `案例年份` - `案例年份`
5. 浏览器直接下载 `.xlsx` 文件 5. 浏览器直接下载 `.xlsx` 文件
6. `metadata.keyword` 导出到 `关键字` 列时恢复为逗号拼接字符串,便于线下继续维护
### 4.4 浏览与检索案例 ### 4.4 浏览与检索案例
...@@ -124,14 +126,16 @@ ...@@ -124,14 +126,16 @@
5. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换 5. 页面顶部提供 `Segment` 视图切换,可在“卡片视图 / 表格视图”之间切换
6. 卡片视图用于浏览案例摘要,点击卡片后以弹框方式查看并编辑详情 6. 卡片视图用于浏览案例摘要,点击卡片后以弹框方式查看并编辑详情
7. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段 7. 表格视图展示标题、分类、标签、案例类型、关键字、来源、等级、年份、更新时间等字段
8. 表格视图支持单选、全选当前已加载案例,并显示当前勾选数量 8. 表格视图按页加载案例,支持选择每页 20、50、100 条,并显示页码、当前页范围和匹配总数
9. 表格视图支持单选、全选当前页案例,并显示当前勾选数量
### 4.5 编辑案例 ### 4.5 编辑案例
1. 用户在列表中打开某个案例详情 1. 用户在列表中打开某个案例详情
2. 修改标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份 2. 修改标题、分类、标签、案例类型、案例属性、案例详情、案例警示、关键字、案例来源、案例等级、案例年份
3. 前端调用 `PUT /record/:name` 3. 关键字编辑区支持逗号输入,并实时预览为关键字标签;保存时拆分为 `metadata.keyword` 字符串数组
4. 保存成功后刷新列表与统计数据 4. 前端调用 `PUT /record/:name`
5. 保存成功后刷新列表与统计数据
### 4.6 批量修改案例 ### 4.6 批量修改案例
...@@ -176,40 +180,44 @@ ...@@ -176,40 +180,44 @@
- 未识别到的列忽略,不中断导入 - 未识别到的列忽略,不中断导入
- 空行直接跳过 - 空行直接跳过
- `标题` 为空时,优先使用 `案例类型`,再回退为 `案例详情` 摘要自动生成 `title` - `标题` 为空时,优先使用 `案例类型`,再回退为 `案例详情` 摘要自动生成 `title`
- 所有文本字段统一按字符串保存 - 普通文本字段统一按字符串保存
- `关键字` 是多值字段,导入时将逗号拼接文本拆分为字符串数组保存
- `案例年份` 优先按数字写入,不可解析时保留原字符串 - `案例年份` 优先按数字写入,不可解析时保留原字符串
- 导出模板固定为 11 列标准表头,便于线下统一维护 - 导出模板固定为 11 列标准表头,便于线下统一维护
### 5.3 筛选规则 ### 5.3 筛选规则
- 案例性质筛选使用 `metadata.category` - 案例性质筛选使用 `metadata.category`
- 关键字筛选优先使用 `metadata.keyword`,同时兼容历史 `metadata.tag` - 关键字筛选优先使用多值数组 `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`
--- ---
## 6. 页面与交互要求 ## 6. 页面与交互要求
### 6.1 案例总览 ### 6.1 驾驶舱
建议至少展示: 建议至少展示:
- 案例总数 - 案例总数
- 分类数量
- 标签数量
- 案例类型数量
- 最近 7 天更新数 - 最近 7 天更新数
- 案例性质分布 - 分类结构饼图
- 关键字热度 - 案例类型排行柱状图
- 案例年份分布趋势图 - 案例来源分布横向柱状图
- 近 7 日更新活跃度图 - 关键字词云
- 最近更新案例 - 最近更新案例
- 重点长文本案例或待整理案例,待整理规则关注缺少案例性质、关键字、案例详情或详情偏短
- 数据洞察卡片,例如分类最集中主题、最高频关键字、结构完整度 - 数据洞察卡片,例如分类最集中主题、最高频关键字、结构完整度
图表口径说明: 图表口径说明:
- 案例性质分布:按 `metadata.category` 聚合 - 分类结构:按 `metadata.category` 聚合,缺失时归入 `未分类`
- 关键字热度:按 `metadata.keyword`,缺失时兼容 `metadata.tag` - 案例类型排行:按 `metadata.case_type` 聚合,缺失时归入 `未填写类型`
- 案例年份分布:按 `metadata.case_year` 聚合 - 案例来源分布:按 `metadata.case_source` 聚合,缺失时归入 `未填写来源`
- 近 7 日更新活跃度:按 `updated_at` 汇总最近 7 天更新次数 - 关键字词云:按 `metadata.keyword` 聚合并按热度调整字号,缺失时兼容 `metadata.tag`
- 最近 7 天更新:按 `updated_at` 统计近 7 天更新的案例数量
### 6.2 案例管理 ### 6.2 案例管理
...@@ -243,6 +251,7 @@ ...@@ -243,6 +251,7 @@
- 案例详情全文 - 案例详情全文
- 案例性质 - 案例性质
- 关键字 - 关键字
- 关键字以标签形式展示;编辑时保留逗号输入并即时预览拆分后的标签
- 案例警示 - 案例警示
- 案例来源 / 案例等级 / 案例年份 - 案例来源 / 案例等级 / 案例年份
- 案例详情字数 - 案例详情字数
...@@ -274,10 +283,11 @@ ...@@ -274,10 +283,11 @@
## 8. 分页与性能建议 ## 8. 分页与性能建议
- 首屏只加载一页,建议每页 24 条 - 首屏只加载第一页,默认每页 20 条
- 列表滚动到底部时自动加载下一页 - 表格与卡片列表通过页码分页加载,不使用下滑自动加载
- 用户可选择每页 20、50、100 条;切换每页数量时从第一页重新加载
- 当用户切换分类或搜索条件时,从第一页重新加载 - 当用户切换分类或搜索条件时,从第一页重新加载
- 总数通过 `count` 接口单独获取 - 总数优先读取 `POST /record/query` 返回的 `total_records`;接口未返回总数时再回退到 `count` 接口
--- ---
...@@ -297,4 +307,4 @@ ...@@ -297,4 +307,4 @@
1. `risk_case_library` 1. `risk_case_library`
当前不拆分独立关键字表、案例性质表或附件表。 当前不拆分独立关键字表、案例性质表或附件表。
案例性质与关键字先通过文本字段实现,后续如有需要再演进。 案例性质先通过文本字段实现,关键字通过多值文本数组实现,后续如有需要再演进。
...@@ -9,7 +9,8 @@ import dayjs from 'dayjs'; ...@@ -9,7 +9,8 @@ import dayjs from 'dayjs';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
export const CASE_SUBJECT = 'risk_case_library'; export const CASE_SUBJECT = 'risk_case_library';
export const DEFAULT_PAGE_SIZE = 24; export const CASE_PAGE_SIZE_OPTIONS = [20, 50, 100];
export const DEFAULT_PAGE_SIZE = 20;
const CASE_EXPORT_HEADERS = [ const CASE_EXPORT_HEADERS = [
'标题', '标题',
...@@ -44,7 +45,7 @@ const DEMO_CASES = [ ...@@ -44,7 +45,7 @@ const DEMO_CASES = [
case_details: case_details:
'某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。', '某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。',
case_warning: '涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。', case_warning: '涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。',
keyword: '采购审批, 拆单', keyword: ['采购审批', '拆单'],
case_source: '内部审计复盘', case_source: '内部审计复盘',
case_level: '高', case_level: '高',
case_year: 2024, case_year: 2024,
...@@ -68,7 +69,7 @@ const DEMO_CASES = [ ...@@ -68,7 +69,7 @@ const DEMO_CASES = [
case_details: case_details:
'部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。', '部门负责人以“紧急签约”为由临时借出印章,未在台账中完整登记用途和归还时间,后续合同版本与审批留痕不一致。最终在复盘中确认,印章借用、复核与回收链条均缺少双人校验。',
case_warning: '印章借用、回收与留痕链路必须双人复核并形成闭环。', case_warning: '印章借用、回收与留痕链路必须双人复核并形成闭环。',
keyword: '印章, 台账, 紧急签约', keyword: ['印章', '台账', '紧急签约'],
case_source: '合规检查复盘', case_source: '合规检查复盘',
case_level: '中', case_level: '中',
case_year: 2025, case_year: 2025,
...@@ -92,7 +93,7 @@ const DEMO_CASES = [ ...@@ -92,7 +93,7 @@ const DEMO_CASES = [
case_details: case_details:
'财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。', '财务负责人通过虚构往来科目和短期借款名义转移资金,月末再以内部调账方式冲回,导致多轮抽样测试均未及时发现异常。案件暴露出资金支付复核与异常波动分析两道防线同时失效。',
case_warning: '资金支付复核应与月末异常波动分析交叉验证。', case_warning: '资金支付复核应与月末异常波动分析交叉验证。',
keyword: '资金管理, 异常调账', keyword: ['资金管理', '异常调账'],
case_source: '内部通报', case_source: '内部通报',
case_level: '高', case_level: '高',
case_year: 2025, case_year: 2025,
...@@ -116,7 +117,7 @@ const DEMO_CASES = [ ...@@ -116,7 +117,7 @@ const DEMO_CASES = [
case_details: case_details:
'项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。', '项目团队为赶进度直接沿用历史供应商名单,新签供应商未重新完成资质核验,导致营业执照、关联关系与受益所有人信息长期缺失。后续排查时已无法还原当时的准入审批依据。',
case_warning: '供应商准入资料缺失会直接削弱后续尽调和责任追溯能力。', case_warning: '供应商准入资料缺失会直接削弱后续尽调和责任追溯能力。',
keyword: '供应商, 尽调, 资质核验', keyword: ['供应商', '尽调', '资质核验'],
case_source: '专项排查', case_source: '专项排查',
case_level: '中', case_level: '中',
case_year: 2024, case_year: 2024,
...@@ -140,7 +141,7 @@ const DEMO_CASES = [ ...@@ -140,7 +141,7 @@ const DEMO_CASES = [
case_details: case_details:
'运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。', '运维团队在夜间批量发布期间共享高权限账号密码,虽然缩短了操作路径,但审计日志无法区分责任人。问题暴露出特权账号借用、审批、回收与日志关联机制均不完整。',
case_warning: '高权限账号必须做到实名借用、短时授权和日志可追溯。', case_warning: '高权限账号必须做到实名借用、短时授权和日志可追溯。',
keyword: '特权账号, 权限控制, 审计日志', keyword: ['特权账号', '权限控制', '审计日志'],
case_source: '安全审计', case_source: '安全审计',
case_level: '高', case_level: '高',
case_year: 2023, case_year: 2023,
...@@ -192,6 +193,9 @@ function extractCount(response) { ...@@ -192,6 +193,9 @@ function extractCount(response) {
response, response,
response?.data, response?.data,
response?.result, response?.result,
response?.total_records,
response?.data?.total_records,
response?.result?.total_records,
response?.count, response?.count,
response?.data?.count, response?.data?.count,
response?.data?.total, response?.data?.total,
...@@ -209,6 +213,29 @@ function extractCount(response) { ...@@ -209,6 +213,29 @@ function extractCount(response) {
return 0; return 0;
} }
function extractTotalRecords(response) {
const candidates = [
response?.total_records,
response?.data?.total_records,
response?.result?.total_records,
response?.total,
response?.data?.total,
response?.result?.total,
response?.count,
response?.data?.count,
response?.result?.count,
];
for (const candidate of candidates) {
const numericCandidate = Number(candidate);
if (Number.isFinite(numericCandidate)) {
return numericCandidate;
}
}
return null;
}
function normalizeHeaderKey(value = '') { function normalizeHeaderKey(value = '') {
return String(value) return String(value)
.trim() .trim()
...@@ -252,6 +279,30 @@ function createSummary(content = '', maxLength = 140) { ...@@ -252,6 +279,30 @@ function createSummary(content = '', maxLength = 140) {
return `${normalized.slice(0, maxLength).trim()}...`; return `${normalized.slice(0, maxLength).trim()}...`;
} }
export function normalizeKeywordList(value) {
const sourceValues = (Array.isArray(value) ? value : [value]).flatMap((item) =>
String(item || '').split(/[,,]/),
);
const seenKeywords = new Set();
return sourceValues.reduce((keywords, item) => {
const keyword = String(item || '').trim();
const dedupeKey = keyword.toLowerCase();
if (!keyword || seenKeywords.has(dedupeKey)) {
return keywords;
}
seenKeywords.add(dedupeKey);
keywords.push(keyword);
return keywords;
}, []);
}
export function formatKeywordList(value) {
return normalizeKeywordList(value).join(', ');
}
export function formatDateTime(value) { export function formatDateTime(value) {
if (!value) { if (!value) {
return '-'; return '-';
...@@ -365,7 +416,8 @@ export function normalizeCaseRecord(record = {}) { ...@@ -365,7 +416,8 @@ export function normalizeCaseRecord(record = {}) {
const caseAttribute = String(metadata.case_attribute || '').trim(); const caseAttribute = String(metadata.case_attribute || '').trim();
const caseDetails = String(metadata.case_details || metadata.content || record.content || '').trim(); const caseDetails = String(metadata.case_details || metadata.content || record.content || '').trim();
const caseWarning = String(metadata.case_warning || '').trim(); const caseWarning = String(metadata.case_warning || '').trim();
const keyword = String(metadata.keyword || '').trim(); const keywordList = normalizeKeywordList(metadata.keyword);
const keyword = formatKeywordList(keywordList);
const caseSource = String(metadata.case_source || '').trim(); const caseSource = String(metadata.case_source || '').trim();
const caseLevel = String(metadata.case_level || '').trim(); const caseLevel = String(metadata.case_level || '').trim();
const category = String(metadata.category || '').trim(); const category = String(metadata.category || '').trim();
...@@ -389,6 +441,7 @@ export function normalizeCaseRecord(record = {}) { ...@@ -389,6 +441,7 @@ export function normalizeCaseRecord(record = {}) {
caseDetails, caseDetails,
caseWarning, caseWarning,
keyword, keyword,
keywordList,
caseSource, caseSource,
caseLevel, caseLevel,
caseYear, caseYear,
...@@ -467,6 +520,28 @@ export function buildCaseDashboard(cases = [], options = {}) { ...@@ -467,6 +520,28 @@ export function buildCaseDashboard(cases = [], options = {}) {
(item) => item.tag, (item) => item.tag,
(item) => item.tag, (item) => item.tag,
); );
const keywordGroups = buildGroup(
cases.flatMap((item) => {
const keywords = normalizeKeywordList(item.keywordList || item.keyword);
const fallbackKeywords = keywords.length > 0 ? keywords : normalizeKeywordList(item.tag);
return fallbackKeywords.map((keywordLabel) => ({
keywordLabel,
}));
}),
(item) => item.keywordLabel,
(item) => item.keywordLabel,
);
const caseTypeGroups = buildGroup(
cases,
(item) => item.caseType || '未填写类型',
(item) => item.caseType || '未填写类型',
);
const caseSourceGroups = buildGroup(
cases,
(item) => item.caseSource || '未填写来源',
(item) => item.caseSource || '未填写来源',
);
const recentCases = [...cases] const recentCases = [...cases]
.sort((left, right) => .sort((left, right) =>
String(right.updatedAt || right.createdAt).localeCompare( String(right.updatedAt || right.createdAt).localeCompare(
...@@ -474,9 +549,6 @@ export function buildCaseDashboard(cases = [], options = {}) { ...@@ -474,9 +549,6 @@ export function buildCaseDashboard(cases = [], options = {}) {
), ),
) )
.slice(0, 6); .slice(0, 6);
const longCases = [...cases]
.sort((left, right) => Number(right.textLength || 0) - Number(left.textLength || 0))
.slice(0, 5);
const attentionCases = cases const attentionCases = cases
.filter( .filter(
(item) => (item) =>
...@@ -495,15 +567,17 @@ export function buildCaseDashboard(cases = [], options = {}) { ...@@ -495,15 +567,17 @@ export function buildCaseDashboard(cases = [], options = {}) {
uncategorizedCount: cases.filter((item) => item.category === '未分类').length, uncategorizedCount: cases.filter((item) => item.category === '未分类').length,
categoryGroups, categoryGroups,
tagGroups, tagGroups,
keywordGroups,
caseTypeGroups,
caseSourceGroups,
recentCases, recentCases,
longCases,
attentionCases, attentionCases,
categorySuggestions: categoryGroups.map((item) => item.label), categorySuggestions: categoryGroups.map((item) => item.label),
tagSuggestions: tagGroups.map((item) => item.label), tagSuggestions: tagGroups.map((item) => item.label),
}; };
} }
async function queryCaseRecords(filters = {}) { async function queryCaseRecordPage(filters = {}) {
const response = await queryRecords({ const response = await queryRecords({
subject: CASE_SUBJECT, subject: CASE_SUBJECT,
page: Number(filters.page || 0), page: Number(filters.page || 0),
...@@ -512,7 +586,12 @@ async function queryCaseRecords(filters = {}) { ...@@ -512,7 +586,12 @@ async function queryCaseRecords(filters = {}) {
sort: '-updated_at -created_at', sort: '-updated_at -created_at',
}); });
return extractArray(response).map((item) => normalizeCaseRecord(item)); const records = extractArray(response);
return {
cases: records.map((item) => normalizeCaseRecord(item)),
total: extractTotalRecords(response),
};
} }
export async function loadCasePage(filters = {}) { export async function loadCasePage(filters = {}) {
...@@ -521,19 +600,22 @@ export async function loadCasePage(filters = {}) { ...@@ -521,19 +600,22 @@ export async function loadCasePage(filters = {}) {
const recordFilter = buildRecordFilter(filters); const recordFilter = buildRecordFilter(filters);
try { try {
const [cases, totalResponse] = await Promise.all([ const pageResult = await queryCaseRecordPage({
queryCaseRecords({
...filters, ...filters,
page, page,
pageSize, pageSize,
}), });
countRecords({ const total =
Number.isFinite(pageResult.total)
? pageResult.total
: extractCount(
await countRecords({
subject: CASE_SUBJECT, subject: CASE_SUBJECT,
filter: recordFilter, filter: recordFilter,
}), }),
]); );
const cases = pageResult.cases;
const total = extractCount(totalResponse);
const shouldUseDemo = const shouldUseDemo =
page === 0 && !hasActiveFilters(filters) && total === 0 && cases.length === 0; page === 0 && !hasActiveFilters(filters) && total === 0 && cases.length === 0;
...@@ -567,6 +649,19 @@ export async function loadCasePage(filters = {}) { ...@@ -567,6 +649,19 @@ export async function loadCasePage(filters = {}) {
error: '', error: '',
}; };
} catch (error) { } catch (error) {
let total = 0;
try {
total = extractCount(
await countRecords({
subject: CASE_SUBJECT,
filter: recordFilter,
}),
);
} catch (_countError) {
total = 0;
}
const demoCases = applyClientFilters( const demoCases = applyClientFilters(
DEMO_CASES.map((item) => normalizeCaseRecord(item)), DEMO_CASES.map((item) => normalizeCaseRecord(item)),
filters, filters,
...@@ -611,7 +706,7 @@ export function buildCasePayload(values = {}) { ...@@ -611,7 +706,7 @@ export function buildCasePayload(values = {}) {
const caseAttribute = String(values.caseAttribute || values.case_attribute || '').trim(); const caseAttribute = String(values.caseAttribute || values.case_attribute || '').trim();
const caseDetails = String(values.caseDetails || values.case_details || values.content || '').trim(); const caseDetails = String(values.caseDetails || values.case_details || values.content || '').trim();
const caseWarning = String(values.caseWarning || values.case_warning || '').trim(); const caseWarning = String(values.caseWarning || values.case_warning || '').trim();
const keyword = String(values.keyword || '').trim(); const keyword = normalizeKeywordList(values.keyword);
const caseSource = String(values.caseSource || values.case_source || '').trim(); const caseSource = String(values.caseSource || values.case_source || '').trim();
const caseLevel = String(values.caseLevel || values.case_level || '').trim(); const caseLevel = String(values.caseLevel || values.case_level || '').trim();
const category = String(values.category || '').trim(); const category = String(values.category || '').trim();
...@@ -749,7 +844,6 @@ export async function updateCaseRecords(caseItems = [], values = {}) { ...@@ -749,7 +844,6 @@ export async function updateCaseRecords(caseItems = [], values = {}) {
['case_attribute', values.caseAttribute ?? values.case_attribute], ['case_attribute', values.caseAttribute ?? values.case_attribute],
['case_details', values.caseDetails ?? values.case_details], ['case_details', values.caseDetails ?? values.case_details],
['case_warning', values.caseWarning ?? values.case_warning], ['case_warning', values.caseWarning ?? values.case_warning],
['keyword', values.keyword],
['case_source', values.caseSource ?? values.case_source], ['case_source', values.caseSource ?? values.case_source],
['case_level', values.caseLevel ?? values.case_level], ['case_level', values.caseLevel ?? values.case_level],
]; ];
...@@ -762,6 +856,14 @@ export async function updateCaseRecords(caseItems = [], values = {}) { ...@@ -762,6 +856,14 @@ export async function updateCaseRecords(caseItems = [], values = {}) {
} }
}); });
if (values.keyword !== undefined && values.keyword !== null) {
const keywordPatch = normalizeKeywordList(values.keyword);
if (keywordPatch.length > 0) {
metadataPatch.keyword = keywordPatch;
}
}
const caseYearRaw = values.caseYear ?? values.case_year; const caseYearRaw = values.caseYear ?? values.case_year;
const normalizedCaseYear = String(caseYearRaw ?? '').trim(); const normalizedCaseYear = String(caseYearRaw ?? '').trim();
if ( if (
...@@ -995,7 +1097,7 @@ function buildExportRow(caseItem = {}) { ...@@ -995,7 +1097,7 @@ function buildExportRow(caseItem = {}) {
案例属性: caseItem.caseAttribute || '', 案例属性: caseItem.caseAttribute || '',
案例详情: caseItem.caseDetails || caseItem.content || '', 案例详情: caseItem.caseDetails || caseItem.content || '',
案例警示: caseItem.caseWarning || '', 案例警示: caseItem.caseWarning || '',
关键字: caseItem.keyword || '', 关键字: formatKeywordList(caseItem.keywordList || caseItem.keyword || ''),
案例来源: caseItem.caseSource || '', 案例来源: caseItem.caseSource || '',
案例等级: caseItem.caseLevel || '', 案例等级: caseItem.caseLevel || '',
案例年份: caseItem.caseYear || '', 案例年份: caseItem.caseYear || '',
......
...@@ -7,10 +7,16 @@ import { ...@@ -7,10 +7,16 @@ import {
DialogTitle, DialogTitle,
} from '@/catalyst-ui-kit/javascript/dialog'; } from '@/catalyst-ui-kit/javascript/dialog';
import { Input, InputGroup } from '@/catalyst-ui-kit/javascript/input'; import { Input, InputGroup } from '@/catalyst-ui-kit/javascript/input';
import {
Pagination,
PaginationGap,
PaginationList,
} from '@/catalyst-ui-kit/javascript/pagination';
import { import {
Segment, Segment,
SegmentOption, SegmentOption,
} from '@/catalyst-ui-kit/javascript/segment'; } from '@/catalyst-ui-kit/javascript/segment';
import { Select } from '@/catalyst-ui-kit/javascript/select';
import { import {
Table, Table,
TableBody, TableBody,
...@@ -44,6 +50,7 @@ import { ...@@ -44,6 +50,7 @@ import {
import EmptyStatePage from '../EmptyStatePage'; import EmptyStatePage from '../EmptyStatePage';
import { import {
buildCaseDashboard, buildCaseDashboard,
CASE_PAGE_SIZE_OPTIONS,
createCaseRecord, createCaseRecord,
createCaseTitle, createCaseTitle,
deleteCaseRecord, deleteCaseRecord,
...@@ -54,6 +61,7 @@ import { ...@@ -54,6 +61,7 @@ import {
exportSelectedCaseWorkbook, exportSelectedCaseWorkbook,
importCaseWorkbook, importCaseWorkbook,
loadCasePage, loadCasePage,
normalizeKeywordList,
previewCaseWorkbook, previewCaseWorkbook,
updateCaseRecord, updateCaseRecord,
updateCaseRecords, updateCaseRecords,
...@@ -65,6 +73,47 @@ const TABLE_VIEW_MODE = 'table'; ...@@ -65,6 +73,47 @@ const TABLE_VIEW_MODE = 'table';
const STICKY_TABLE_HEADER_CLASS_NAME = const STICKY_TABLE_HEADER_CLASS_NAME =
'sticky top-0 z-10 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/85'; 'sticky top-0 z-10 bg-white/95 backdrop-blur supports-[backdrop-filter]:bg-white/85';
function buildPaginationItems(currentPage, totalPages) {
if (totalPages <= 7) {
return Array.from({ length: totalPages }, (_item, index) => index);
}
const pages = new Set([0, totalPages - 1, currentPage]);
if (currentPage > 0) {
pages.add(currentPage - 1);
}
if (currentPage < totalPages - 1) {
pages.add(currentPage + 1);
}
if (currentPage <= 2) {
pages.add(1);
pages.add(2);
pages.add(3);
}
if (currentPage >= totalPages - 3) {
pages.add(totalPages - 2);
pages.add(totalPages - 3);
pages.add(totalPages - 4);
}
const sortedPages = [...pages]
.filter((item) => item >= 0 && item < totalPages)
.sort((left, right) => left - right);
return sortedPages.reduce((items, item, index) => {
if (index > 0 && item - sortedPages[index - 1] > 1) {
items.push(`gap-${sortedPages[index - 1]}-${item}`);
}
items.push(item);
return items;
}, []);
}
function Panel({ className, children }) { function Panel({ className, children }) {
return ( return (
<section <section
...@@ -102,6 +151,35 @@ function Badge({ children, tone = 'default' }) { ...@@ -102,6 +151,35 @@ function Badge({ children, tone = 'default' }) {
); );
} }
function KeywordChips({ value, emptyText = '暂无关键字', limit = 0 }) {
const keywords = normalizeKeywordList(value);
const visibleKeywords = limit > 0 ? keywords.slice(0, limit) : keywords;
const remainingCount = limit > 0 ? Math.max(keywords.length - limit, 0) : 0;
if (keywords.length === 0) {
return <span className="text-sm text-zinc-400">{emptyText}</span>;
}
return (
<div className="flex flex-wrap gap-2">
{visibleKeywords.map((keyword) => (
<span
key={keyword}
className="inline-flex max-w-full items-center 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>
</span>
))}
{remainingCount > 0 ? (
<span className="inline-flex items-center rounded-full border border-zinc-200 bg-zinc-50 px-2.5 py-1 text-xs font-medium text-zinc-500">
+{remainingCount}
</span>
) : null}
</div>
);
}
function CategoryTab({ active, label, count, onClick }) { function CategoryTab({ active, label, count, onClick }) {
return ( return (
<button <button
...@@ -249,7 +327,6 @@ function buildImportPreviewRows(preview) { ...@@ -249,7 +327,6 @@ function buildImportPreviewRows(preview) {
} }
export default function ResourceBookingPage() { export default function ResourceBookingPage() {
const loadMoreRef = useRef(null);
const importFileInputRef = useRef(null); const importFileInputRef = useRef(null);
const [query, setQuery] = useState(''); const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query); const deferredQuery = useDeferredValue(query);
...@@ -261,10 +338,9 @@ export default function ResourceBookingPage() { ...@@ -261,10 +338,9 @@ export default function ResourceBookingPage() {
const [activeCaseId, setActiveCaseId] = useState(''); const [activeCaseId, setActiveCaseId] = useState('');
const [selectedCaseIds, setSelectedCaseIds] = useState([]); const [selectedCaseIds, setSelectedCaseIds] = useState([]);
const [page, setPage] = useState(0); const [page, setPage] = useState(0);
const [pageSize, setPageSize] = useState(DEFAULT_PAGE_SIZE);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [creating, setCreating] = useState(false); const [creating, setCreating] = useState(false);
const [deleting, setDeleting] = useState(false); const [deleting, setDeleting] = useState(false);
...@@ -337,6 +413,13 @@ export default function ResourceBookingPage() { ...@@ -337,6 +413,13 @@ export default function ResourceBookingPage() {
() => buildImportPreviewRows(importPreview), () => buildImportPreviewRows(importPreview),
[importPreview], [importPreview],
); );
const totalPages = Math.max(1, Math.ceil(total / pageSize));
const paginationItems = useMemo(
() => buildPaginationItems(page, totalPages),
[page, totalPages],
);
const pageStart = total === 0 ? 0 : page * pageSize + 1;
const pageEnd = Math.min(total, page * pageSize + visibleCases.length);
useEffect(() => { useEffect(() => {
setDetailForm(buildCaseForm(activeCase)); setDetailForm(buildCaseForm(activeCase));
...@@ -359,34 +442,28 @@ export default function ResourceBookingPage() { ...@@ -359,34 +442,28 @@ export default function ResourceBookingPage() {
); );
}, [visibleCases]); }, [visibleCases]);
useEffect(() => { async function loadCaseList(nextPage = 0, nextPageSize = pageSize) {
let cancelled = false;
async function runFirstPage() {
setLoading(true); setLoading(true);
setError('');
const result = await loadCasePage({ const result = await loadCasePage({
keyword: deferredQuery, keyword: deferredQuery,
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory, category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter, tag: tagFilter,
page: 0, page: nextPage,
pageSize: DEFAULT_PAGE_SIZE, pageSize: nextPageSize,
}); });
if (cancelled) {
return;
}
setCases(result.cases || []); setCases(result.cases || []);
setDashboard( setDashboard(
buildCaseDashboard(result.cases || [], { buildCaseDashboard(result.cases || [], {
totalCount: result.total || 0, totalCount: result.total || 0,
}), }),
); );
setPage(0); setPage(nextPage);
setTotal(result.total || 0); setTotal(result.total || 0);
setHasMore(Boolean(result.hasMore));
setError(result.error || '');
setIsDemoMode(Boolean(result.isDemoMode)); setIsDemoMode(Boolean(result.isDemoMode));
setError(result.error || '');
setLoading(false); setLoading(false);
startTransition(() => { startTransition(() => {
...@@ -398,24 +475,29 @@ export default function ResourceBookingPage() { ...@@ -398,24 +475,29 @@ export default function ResourceBookingPage() {
return result.cases?.[0]?.id || ''; return result.cases?.[0]?.id || '';
}); });
}); });
return result;
} }
runFirstPage(); useEffect(() => {
let cancelled = false;
return () => { async function runFirstPage() {
cancelled = true; setLoading(true);
}; setError('');
}, [activeCategory, deferredQuery, tagFilter]);
async function refreshCurrentList() {
const result = await loadCasePage({ const result = await loadCasePage({
keyword: deferredQuery, keyword: deferredQuery,
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory, category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter, tag: tagFilter,
page: 0, page: 0,
pageSize: DEFAULT_PAGE_SIZE, pageSize,
}); });
if (cancelled) {
return;
}
setCases(result.cases || []); setCases(result.cases || []);
setDashboard( setDashboard(
buildCaseDashboard(result.cases || [], { buildCaseDashboard(result.cases || [], {
...@@ -424,86 +506,57 @@ export default function ResourceBookingPage() { ...@@ -424,86 +506,57 @@ export default function ResourceBookingPage() {
); );
setPage(0); setPage(0);
setTotal(result.total || 0); setTotal(result.total || 0);
setHasMore(Boolean(result.hasMore));
setIsDemoMode(Boolean(result.isDemoMode));
setError(result.error || ''); setError(result.error || '');
} setIsDemoMode(Boolean(result.isDemoMode));
setLoading(false);
async function handleLoadMore() { startTransition(() => {
if (loadingMore || !hasMore) { setActiveCaseId((current) => {
return; if ((result.cases || []).some((item) => item.id === current)) {
return current;
} }
setLoadingMore(true); return result.cases?.[0]?.id || '';
try {
const nextPage = page + 1;
const result = await loadCasePage({
keyword: deferredQuery,
category: activeCategory === ALL_CATEGORY_KEY ? '' : activeCategory,
tag: tagFilter,
page: nextPage,
pageSize: DEFAULT_PAGE_SIZE,
}); });
});
}
setCases((prev) => { runFirstPage();
const existingIds = new Set(prev.map((item) => item.id));
const merged = [
...prev,
...(result.cases || []).filter((item) => !existingIds.has(item.id)),
];
setDashboard( return () => {
buildCaseDashboard(merged, { cancelled = true;
totalCount: result.total || merged.length, };
}), }, [activeCategory, deferredQuery, pageSize, tagFilter]);
);
return merged; async function refreshCurrentList(preferredPage = page) {
}); const totalPages = Math.max(1, Math.ceil(total / pageSize));
setPage(nextPage); const safePage = Math.min(Math.max(Number(preferredPage) || 0, 0), totalPages - 1);
setTotal(result.total || total); const result = await loadCaseList(safePage, pageSize);
setHasMore(Boolean(result.hasMore));
setIsDemoMode(Boolean(result.isDemoMode)); if ((result.cases || []).length === 0 && result.total > 0 && safePage > 0) {
if (result.error) { await loadCaseList(safePage - 1, pageSize);
setError(result.error);
}
} catch (loadMoreError) {
setError(loadMoreError?.message || '加载更多案例失败,请稍后重试。');
} finally {
setLoadingMore(false);
} }
} }
useEffect(() => { function handlePageChange(nextPage) {
const node = loadMoreRef.current; if (nextPage === page || nextPage < 0) {
return;
if (!node || !hasMore || loading || loadingMore) {
return undefined;
} }
const observer = new IntersectionObserver( const totalPages = Math.max(1, Math.ceil(total / pageSize));
(entries) => { if (nextPage >= totalPages) {
const [entry] = entries;
if (!entry?.isIntersecting) {
return; return;
} }
handleLoadMore(); loadCaseList(nextPage, pageSize);
}, }
{
root: null,
rootMargin: '280px 0px',
threshold: 0.1,
},
);
observer.observe(node);
return () => { function handlePageSizeChange(event) {
observer.disconnect(); const nextPageSize = Number(event.target.value) || DEFAULT_PAGE_SIZE;
}; setPageSize(nextPageSize);
}, [hasMore, loading, loadingMore, page, deferredQuery, activeCategory, tagFilter]); setPage(0);
setSelectedCaseIds([]);
}
function resetCreateForm() { function resetCreateForm() {
setCreateForm(buildEmptyCaseForm()); setCreateForm(buildEmptyCaseForm());
...@@ -567,7 +620,7 @@ export default function ResourceBookingPage() { ...@@ -567,7 +620,7 @@ export default function ResourceBookingPage() {
setIsCreateOpen(false); setIsCreateOpen(false);
setIsDetailOpen(false); setIsDetailOpen(false);
resetCreateForm(); resetCreateForm();
await refreshCurrentList(); await refreshCurrentList(0);
setActiveCaseId(created.id || ''); setActiveCaseId(created.id || '');
} catch (createError) { } catch (createError) {
setError(createError?.message || '案例创建失败,请稍后重试。'); setError(createError?.message || '案例创建失败,请稍后重试。');
...@@ -781,7 +834,7 @@ export default function ResourceBookingPage() { ...@@ -781,7 +834,7 @@ export default function ResourceBookingPage() {
} }
setIsImportOpen(false); setIsImportOpen(false);
resetImportState(); resetImportState();
await refreshCurrentList(); await refreshCurrentList(0);
} catch (importError) { } catch (importError) {
setError(importError?.message || 'Excel 导入失败,请稍后重试。'); setError(importError?.message || 'Excel 导入失败,请稍后重试。');
} finally { } finally {
...@@ -976,14 +1029,9 @@ export default function ResourceBookingPage() { ...@@ -976,14 +1029,9 @@ export default function ResourceBookingPage() {
))} ))}
</div> </div>
<div className="mt-5 grid gap-3 sm:grid-cols-3"> <div className="mt-5 grid gap-3 sm:grid-cols-2">
<MetricCard label="匹配案例数" value={total} /> <MetricCard label="匹配案例数" value={total} />
<MetricCard label="最近 7 天更新" value={dashboard.recentCount} /> <MetricCard label="最近 7 天更新" value={dashboard.recentCount} />
<MetricCard
label="平均篇幅"
value={dashboard.averageTextLength || 0}
hint={dashboard.averageTextLengthLabel}
/>
</div> </div>
</Panel> </Panel>
...@@ -1204,7 +1252,11 @@ export default function ResourceBookingPage() { ...@@ -1204,7 +1252,11 @@ export default function ResourceBookingPage() {
) : null} ) : null}
</TableCell> </TableCell>
<TableCell className="max-w-52 whitespace-normal text-zinc-600"> <TableCell className="max-w-52 whitespace-normal text-zinc-600">
{item.keyword || '-'} <KeywordChips
value={item.keywordList || item.keyword}
emptyText="-"
limit={3}
/>
</TableCell> </TableCell>
<TableCell className="whitespace-normal text-zinc-600"> <TableCell className="whitespace-normal text-zinc-600">
{item.caseSource || '-'} {item.caseSource || '-'}
...@@ -1236,24 +1288,74 @@ export default function ResourceBookingPage() { ...@@ -1236,24 +1288,74 @@ export default function ResourceBookingPage() {
)} )}
</Panel> </Panel>
<div <div className="flex flex-col gap-4 rounded-[24px] border border-zinc-200 bg-white/80 px-4 py-4 shadow-sm sm:flex-row sm:items-center sm:justify-between">
ref={loadMoreRef}
className="flex min-h-24 flex-col items-center justify-center gap-3 rounded-[24px] border border-dashed border-zinc-200 bg-white/70 px-4 py-5"
>
<div className="text-sm text-zinc-500"> <div className="text-sm text-zinc-500">
当前已加载 {cases.length} 条,匹配总数 {total} 条。 显示第 {pageStart}-{pageEnd} 条,共 {total} 条
{hasMore </div>
? loadingMore
? ' 正在自动加载下一页...' <div className="flex flex-col gap-3 sm:flex-row sm:items-center">
: ' 继续向下滚动会自动加载更多。' <label className="flex items-center gap-2 text-sm text-zinc-500">
: ' 已全部加载完成。'} <span className="shrink-0">每页</span>
</div> <Select
{loadingMore ? ( className="w-24"
<div className="text-sm font-medium text-[var(--pm-brand)]">正在加载更多...</div> value={pageSize}
) : null} onChange={handlePageSizeChange}
{!hasMore ? ( aria-label="选择每页案例数量"
<div className="text-sm font-medium text-emerald-700">已全部加载</div> >
) : null} {CASE_PAGE_SIZE_OPTIONS.map((option) => (
<option key={option} value={option}>
{option} 条
</option>
))}
</Select>
</label>
<Pagination aria-label="案例分页" className="items-center">
<span className="grow basis-0">
<Button
plain
aria-label="上一页"
onClick={() => handlePageChange(page - 1)}
disabled={page === 0 || loading}
>
上一页
</Button>
</span>
<PaginationList>
{paginationItems.map((item) =>
typeof item === 'string' ? (
<PaginationGap key={item} />
) : (
<Button
plain
key={item}
aria-current={item === page ? 'page' : undefined}
aria-label={`第 ${item + 1} 页`}
className={clsx(
'min-w-9',
item === page &&
'bg-zinc-950/5 text-[var(--pm-brand)]',
)}
onClick={() => handlePageChange(item)}
disabled={loading}
>
{item + 1}
</Button>
),
)}
</PaginationList>
<span className="flex grow basis-0 justify-end">
<Button
plain
aria-label="下一页"
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages - 1 || loading}
>
下一页
</Button>
</span>
</Pagination>
</div>
</div> </div>
</div> </div>
)} )}
...@@ -1458,34 +1560,6 @@ export default function ResourceBookingPage() { ...@@ -1458,34 +1560,6 @@ export default function ResourceBookingPage() {
<div className="space-y-6"> <div className="space-y-6">
<div className="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]"> <div className="grid gap-6 xl:grid-cols-[0.8fr_1.2fr]">
<div className="space-y-4"> <div className="space-y-4">
<div className="rounded-[26px] border border-zinc-200 bg-[linear-gradient(145deg,rgba(255,255,255,0.96),rgba(255,241,242,0.9))] p-5">
<div className="flex flex-wrap items-center gap-2">
<Badge tone="sky">{activeCase.category}</Badge>
{activeCase.caseLevel ? <Badge tone="amber">{activeCase.caseLevel}</Badge> : null}
{activeCase.tag ? <Badge tone="emerald">{activeCase.tag}</Badge> : null}
</div>
<div className="mt-4 text-2xl font-semibold tracking-tight text-zinc-950">
{activeCase.label}
</div>
{activeCase.caseType ? (
<div className="mt-3 text-sm font-medium text-zinc-500">
{activeCase.caseType}
{activeCase.caseAttribute
? ` / ${activeCase.caseAttribute}`
: ''}
</div>
) : null}
<div className="mt-4 text-sm leading-7 text-zinc-600">
{activeCase.contentPreview}
</div>
{activeCase.caseWarning ? (
<div className="mt-4 rounded-[20px] border border-amber-200 bg-amber-50/80 px-4 py-3 text-sm leading-6 text-amber-900">
<div className="font-medium">案例警示</div>
<div className="mt-1">{activeCase.caseWarning}</div>
</div>
) : null}
</div>
<div className="grid gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4"> <div className="grid gap-3 rounded-[24px] border border-zinc-200 bg-zinc-50/80 p-4">
<div> <div>
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500"> <div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
...@@ -1499,11 +1573,12 @@ export default function ResourceBookingPage() { ...@@ -1499,11 +1573,12 @@ export default function ResourceBookingPage() {
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500"> <div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
标签与关键字 标签与关键字
</div> </div>
<div className="mt-2 text-sm text-zinc-700"> <div className="mt-3 flex flex-wrap gap-2">
标签:{activeCase.tag || '-'} {activeCase.tag ? <Badge tone="emerald">{activeCase.tag}</Badge> : null}
</div> <KeywordChips
<div className="mt-1 text-sm text-zinc-500"> value={activeCase.keywordList || activeCase.keyword}
关键字:{activeCase.keyword || '-'} emptyText="暂无关键字"
/>
</div> </div>
</div> </div>
<div> <div>
...@@ -1614,6 +1689,7 @@ export default function ResourceBookingPage() { ...@@ -1614,6 +1689,7 @@ 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">
<Input <Input
value={detailForm.keyword} value={detailForm.keyword}
onChange={(event) => onChange={(event) =>
...@@ -1622,8 +1698,16 @@ export default function ResourceBookingPage() { ...@@ -1622,8 +1698,16 @@ export default function ResourceBookingPage() {
keyword: event.target.value, keyword: event.target.value,
})) }))
} }
placeholder="例如:采购审批, 拆单"
/>
<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
......
import { import {
ArrowTrendingUpIcon, ArrowTrendingUpIcon,
BookOpenIcon, ChartPieIcon,
DocumentTextIcon, DocumentTextIcon,
RectangleStackIcon, RectangleStackIcon,
TagIcon, TagIcon,
} from '@heroicons/react/24/outline'; } from '@heroicons/react/24/outline';
import clsx from 'clsx'; import clsx from 'clsx';
import ReactECharts from 'echarts-for-react';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { import {
buildCaseDashboard, buildCaseDashboard,
...@@ -68,22 +69,190 @@ function SectionHeader({ title }) { ...@@ -68,22 +69,190 @@ function SectionHeader({ title }) {
); );
} }
function DistributionRow({ label, value, total, accent = 'bg-sky-500' }) { const CHART_COLORS = [
const percentage = total > 0 ? Math.max(8, Math.round((value / total) * 100)) : 0; '#0ea5e9',
'#10b981',
'#f59e0b',
'#6366f1',
'#ef4444',
'#14b8a6',
];
function getPieOption(groups = []) {
return {
color: CHART_COLORS,
tooltip: {
trigger: 'item',
formatter: '{b}<br/>{c} 条 · {d}%',
},
legend: {
bottom: 0,
icon: 'circle',
itemWidth: 8,
itemHeight: 8,
textStyle: {
color: '#52525b',
fontSize: 12,
},
},
series: [
{
name: '分类',
type: 'pie',
radius: ['46%', '70%'],
center: ['50%', '42%'],
avoidLabelOverlap: true,
label: {
color: '#3f3f46',
formatter: '{b}\n{c} 条',
lineHeight: 18,
},
labelLine: {
length: 10,
length2: 8,
},
itemStyle: {
borderColor: '#ffffff',
borderWidth: 3,
},
data: groups.map((item) => ({
name: item.label,
value: item.count,
})),
},
],
};
}
function getBarOption(groups = [], { horizontal = false } = {}) {
const visibleGroups = groups.slice(0, 8);
const labels = visibleGroups.map((item) => item.label);
const values = visibleGroups.map((item) => item.count);
return {
color: CHART_COLORS,
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow',
},
formatter: (params = []) => {
const item = params[0];
return item ? `${item.name}<br/>${item.value} 条` : '';
},
},
grid: {
top: 18,
right: 14,
bottom: horizontal ? 18 : 48,
left: horizontal ? 84 : 36,
containLabel: true,
},
xAxis: horizontal
? {
type: 'value',
splitLine: {
lineStyle: {
color: '#e4e4e7',
type: 'dashed',
},
},
axisLabel: {
color: '#71717a',
},
}
: {
type: 'category',
data: labels,
axisTick: {
show: false,
},
axisLine: {
lineStyle: {
color: '#e4e4e7',
},
},
axisLabel: {
color: '#71717a',
interval: 0,
rotate: labels.some((label) => label.length > 5) ? 28 : 0,
},
},
yAxis: horizontal
? {
type: 'category',
data: labels,
axisTick: {
show: false,
},
axisLine: {
show: false,
},
axisLabel: {
color: '#52525b',
width: 74,
overflow: 'truncate',
},
}
: {
type: 'value',
splitLine: {
lineStyle: {
color: '#e4e4e7',
type: 'dashed',
},
},
axisLabel: {
color: '#71717a',
},
},
series: [
{
type: 'bar',
data: values,
barMaxWidth: horizontal ? 18 : 34,
itemStyle: {
borderRadius: horizontal ? [0, 8, 8, 0] : [8, 8, 0, 0],
color: {
type: 'linear',
x: 0,
y: 0,
x2: horizontal ? 1 : 0,
y2: horizontal ? 0 : 1,
colorStops: [
{ offset: 0, color: '#0ea5e9' },
{ offset: 1, color: '#10b981' },
],
},
},
},
],
};
}
function ChartPanel({ title, groups, type = 'bar' }) {
const hasData = groups.length > 0;
const option =
type === 'pie'
? getPieOption(groups)
: getBarOption(groups, { horizontal: type === 'horizontalBar' });
return ( return (
<div> <Panel className="p-6">
<div className="mb-2 flex items-center justify-between text-sm text-zinc-600"> <SectionHeader title={title} />
<span>{label}</span> {hasData ? (
<span>{value} 条</span> <ReactECharts
</div> option={option}
<div className="h-2.5 rounded-full bg-zinc-100"> notMerge
<div lazyUpdate
className={clsx('h-2.5 rounded-full', accent)} style={{ height: type === 'pie' ? 320 : 300, width: '100%' }}
style={{ width: `${percentage}%` }}
/> />
) : (
<div className="flex h-[300px] items-center justify-center rounded-[22px] border border-dashed border-zinc-200 bg-zinc-50 text-sm text-zinc-400">
暂无可展示的数据
</div> </div>
</div> )}
</Panel>
); );
} }
...@@ -107,6 +276,87 @@ function Pill({ children, tone = 'blue' }) { ...@@ -107,6 +276,87 @@ function Pill({ children, tone = 'blue' }) {
); );
} }
const WORD_CLOUD_TONES = [
{
className:
'border-sky-200 bg-sky-50 text-sky-700 shadow-[0_12px_28px_rgba(14,165,233,0.14)]',
countClassName: 'bg-sky-100 text-sky-700',
},
{
className:
'border-emerald-200 bg-emerald-50 text-emerald-700 shadow-[0_12px_28px_rgba(16,185,129,0.14)]',
countClassName: 'bg-emerald-100 text-emerald-700',
},
{
className:
'border-amber-200 bg-amber-50 text-amber-700 shadow-[0_12px_28px_rgba(245,158,11,0.14)]',
countClassName: 'bg-amber-100 text-amber-700',
},
{
className:
'border-indigo-200 bg-indigo-50 text-indigo-700 shadow-[0_12px_28px_rgba(99,102,241,0.14)]',
countClassName: 'bg-indigo-100 text-indigo-700',
},
{
className:
'border-rose-200 bg-rose-50 text-rose-700 shadow-[0_12px_28px_rgba(244,63,94,0.14)]',
countClassName: 'bg-rose-100 text-rose-700',
},
];
function KeywordCloud({ groups = [] }) {
const visibleGroups = groups.slice(0, 24);
const maxCount = Math.max(...visibleGroups.map((item) => item.count), 1);
const minCount = Math.min(...visibleGroups.map((item) => item.count), maxCount);
const range = Math.max(maxCount - minCount, 1);
if (visibleGroups.length === 0) {
return (
<div className="flex min-h-[300px] items-center justify-center rounded-[24px] border border-dashed border-zinc-200 bg-zinc-50 text-sm text-zinc-400">
当前还没有可展示的关键字
</div>
);
}
return (
<div className="flex min-h-[300px] content-center items-center justify-center rounded-[28px] border border-zinc-200 bg-[linear-gradient(145deg,rgba(255,255,255,0.98),rgba(240,249,255,0.78),rgba(236,253,245,0.7))] px-5 py-6">
<div className="flex flex-wrap items-center justify-center gap-3">
{visibleGroups.map((item, index) => {
const heat = (item.count - minCount) / range;
const fontSize = Math.round(13 + heat * 15 + (index === 0 ? 4 : 0));
const tone = WORD_CLOUD_TONES[index % WORD_CLOUD_TONES.length];
return (
<span
key={item.key}
className={clsx(
'inline-flex max-w-full items-center gap-2 rounded-full border px-3.5 py-2 font-semibold transition duration-200 hover:-translate-y-0.5 hover:shadow-lg',
tone.className,
)}
style={{
fontSize,
lineHeight: 1.1,
}}
title={`${item.label} · ${item.count} 条`}
aria-label={`${item.label},${item.count} 条`}
>
<span className="truncate">{item.label}</span>
<span
className={clsx(
'inline-flex h-5 min-w-5 items-center justify-center rounded-full px-1.5 text-[11px] font-semibold',
tone.countClassName,
)}
>
{item.count}
</span>
</span>
);
})}
</div>
</div>
);
}
export default function ResourceStatPage() { export default function ResourceStatPage() {
const [cases, setCases] = useState([]); const [cases, setCases] = useState([]);
const [dashboard, setDashboard] = useState(() => buildCaseDashboard([])); const [dashboard, setDashboard] = useState(() => buildCaseDashboard([]));
...@@ -145,22 +395,11 @@ export default function ResourceStatPage() { ...@@ -145,22 +395,11 @@ export default function ResourceStatPage() {
<div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between"> <div className="mt-5 flex flex-col gap-6 xl:flex-row xl:items-end xl:justify-between">
<div className="max-w-3xl"> <div className="max-w-3xl">
<h1 className="text-3xl font-semibold tracking-tight text-zinc-950 sm:text-4xl"> <h1 className="text-3xl font-semibold tracking-tight text-zinc-950 sm:text-4xl">
案例总览 驾驶舱
</h1> </h1>
</div> </div>
<div className="grid gap-3 sm:grid-cols-2 xl:w-[26rem]"> <div className="grid gap-3 sm:w-56">
<div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm">
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
平均篇幅
</div>
<div className="mt-2 text-2xl font-semibold text-zinc-950">
{dashboard.averageTextLength || 0}
</div>
<div className="mt-1 text-sm text-zinc-500">
{dashboard.averageTextLengthLabel}
</div>
</div>
<div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm"> <div className="rounded-[24px] border border-white/70 bg-white/80 p-4 shadow-sm">
<div className="text-xs font-semibold tracking-[0.18em] text-zinc-500"> <div className="text-xs font-semibold tracking-[0.18em] text-zinc-500">
最近 7 天更新 最近 7 天更新
...@@ -187,7 +426,7 @@ export default function ResourceStatPage() { ...@@ -187,7 +426,7 @@ export default function ResourceStatPage() {
{loading ? ( {loading ? (
<div className="mt-6 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700"> <div className="mt-6 rounded-2xl border border-sky-200 bg-sky-50 px-4 py-3 text-sm text-sky-700">
正在加载案例总览... 正在加载驾驶舱...
</div> </div>
) : null} ) : null}
</div> </div>
...@@ -212,6 +451,12 @@ export default function ResourceStatPage() { ...@@ -212,6 +451,12 @@ export default function ResourceStatPage() {
value={dashboard.uniqueTagCount} value={dashboard.uniqueTagCount}
tone="amber" tone="amber"
/> />
<MetricCard
icon={ChartPieIcon}
label="案例类型"
value={dashboard.caseTypeGroups.length}
tone="blue"
/>
<MetricCard <MetricCard
icon={ArrowTrendingUpIcon} icon={ArrowTrendingUpIcon}
label="待整理" label="待整理"
...@@ -220,52 +465,23 @@ export default function ResourceStatPage() { ...@@ -220,52 +465,23 @@ export default function ResourceStatPage() {
/> />
</div> </div>
<div className="grid gap-6 xl:grid-cols-[1.05fr_0.95fr]"> <div className="grid gap-6 xl:grid-cols-[0.95fr_1.05fr]">
<Panel className="p-6"> <ChartPanel title="分类结构" groups={dashboard.categoryGroups} type="pie" />
<SectionHeader title="分类分布" /> <ChartPanel title="案例类型排行" groups={dashboard.caseTypeGroups} />
<div className="space-y-4">
{dashboard.categoryGroups.length > 0 ? (
dashboard.categoryGroups.map((item, index) => (
<DistributionRow
key={item.key}
label={item.label}
value={item.count}
total={dashboard.totalCount}
accent={
index % 3 === 1
? 'bg-emerald-500'
: index % 3 === 2
? 'bg-amber-500'
: 'bg-sky-500'
}
/>
))
) : (
<div className="text-sm text-zinc-400">暂无分类数据</div>
)}
</div> </div>
</Panel>
<div className="grid gap-6 xl:grid-cols-[1fr_0.9fr]">
<ChartPanel
title="案例来源分布"
groups={dashboard.caseSourceGroups}
type="horizontalBar"
/>
<Panel className="p-6"> <Panel className="p-6">
<SectionHeader title="标签热度" /> <SectionHeader title="关键字词云" />
<div className="flex flex-wrap gap-2"> <KeywordCloud groups={dashboard.keywordGroups} />
{dashboard.tagGroups.length > 0 ? (
dashboard.tagGroups.map((item, index) => (
<Pill
key={item.key}
tone={index % 3 === 1 ? 'green' : index % 3 === 2 ? 'amber' : 'blue'}
>
{item.label} · {item.count}
</Pill>
))
) : (
<div className="text-sm text-zinc-400">当前还没有可展示的标签</div>
)}
</div>
</Panel> </Panel>
</div> </div>
<div className="grid gap-6 xl:grid-cols-[1.1fr_0.9fr]">
<Panel className="p-6"> <Panel className="p-6">
<SectionHeader title="最近更新" /> <SectionHeader title="最近更新" />
<div className="space-y-3"> <div className="space-y-3">
...@@ -296,65 +512,6 @@ export default function ResourceStatPage() { ...@@ -296,65 +512,6 @@ export default function ResourceStatPage() {
</div> </div>
</Panel> </Panel>
<div className="space-y-6">
<Panel className="p-6">
<SectionHeader title="长篇案例" />
<div className="space-y-3">
{dashboard.longCases.length > 0 ? (
dashboard.longCases.map((item) => (
<div
key={item.id}
className="rounded-[22px] border border-zinc-200 bg-white px-4 py-4 shadow-sm"
>
<div className="flex items-center gap-2">
<BookOpenIcon className="size-4 text-sky-600" />
<div className="text-sm font-semibold text-zinc-900">
{item.label}
</div>
</div>
<div className="mt-2 text-xs text-zinc-500">
{item.category} · {item.textLengthLabel}
</div>
</div>
))
) : (
<div className="text-sm text-zinc-400">暂无长篇案例</div>
)}
</div>
</Panel>
<Panel className="p-6">
<SectionHeader title="待整理清单" />
<div className="space-y-3">
{dashboard.attentionCases.length > 0 ? (
dashboard.attentionCases.map((item) => (
<div
key={item.id}
className="rounded-[22px] border border-amber-200 bg-amber-50 px-4 py-4"
>
<div className="text-sm font-semibold text-zinc-900">
{item.label}
</div>
<div className="mt-2 text-xs leading-6 text-amber-800">
{item.category === '未分类' ? '缺少分类' : ''}
{item.category === '未分类' && !item.tag ? ' · ' : ''}
{!item.tag ? '缺少标签' : ''}
{(item.category === '未分类' || !item.tag) &&
item.textLength < 80
? ' · '
: ''}
{item.textLength < 80 ? '正文偏短' : ''}
</div>
</div>
))
) : (
<div className="text-sm text-zinc-400">当前没有待整理案例</div>
)}
</div>
</Panel>
</div>
</div>
{!loading && cases.length === 0 ? ( {!loading && cases.length === 0 ? (
<Panel className="p-8 text-center text-sm text-zinc-500"> <Panel className="p-8 text-center text-sm text-zinc-500">
当前没有可展示的案例记录。 当前没有可展示的案例记录。
......
...@@ -15,7 +15,7 @@ ...@@ -15,7 +15,7 @@
- 所有业务字段都放在 `metadata` 中 - 所有业务字段都放在 `metadata` 中
- 案例详情走纯文本,不单独拆分附件对象 - 案例详情走纯文本,不单独拆分附件对象
- 一期先不建独立关键字表、案例性质表 - 一期先不建独立关键字表、案例性质表
- 案例性质和关键字先通过文本字段实现 - 案例性质先通过文本字段实现,关键字通过多值文本字段实现
--- ---
...@@ -47,7 +47,7 @@ ...@@ -47,7 +47,7 @@
| 案例属性 | `case_attribute` | `text` | 否 | 否 | 否 | 无 | 案例属性或归属维度 | | 案例属性 | `case_attribute` | `text` | 否 | 否 | 否 | 无 | 案例属性或归属维度 |
| 案例详情 | `case_details` | `long_text` | 否 | 否 | 否 | 无 | 案例详细内容正文 | | 案例详情 | `case_details` | `long_text` | 否 | 否 | 否 | 无 | 案例详细内容正文 |
| 案例警示 | `case_warning` | `text` | 否 | 否 | 否 | 无 | 警示或风险提示 | | 案例警示 | `case_warning` | `text` | 否 | 否 | 否 | 无 | 警示或风险提示 |
| 关键字 | `keyword` | `text` | 否 | 否 | 否 | 无 | 用户自定义检索关键字 | | 关键字 | `keyword` | `text` | 否 | 是 | 否 | 无 | 用户自定义检索关键字;导入逗号拼接值需拆分为数组 |
| 案例来源 | `case_source` | `text` | 否 | 否 | 否 | 无 | 如监管通报、内部复盘 | | 案例来源 | `case_source` | `text` | 否 | 否 | 否 | 无 | 如监管通报、内部复盘 |
| 案例等级 | `case_level` | `text` | 否 | 否 | 否 | 无 | 风险等级或分级 | | 案例等级 | `case_level` | `text` | 否 | 否 | 否 | 无 | 风险等级或分级 |
| 案例年份 | `case_year` | `number` | 否 | 否 | 否 | 无 | 案例发生或归档年份 | | 案例年份 | `case_year` | `number` | 否 | 否 | 否 | 无 | 案例发生或归档年份 |
...@@ -98,7 +98,7 @@ ...@@ -98,7 +98,7 @@
1. 在案例管理页手动创建后,能生成 `risk_case_library` 1. 在案例管理页手动创建后,能生成 `risk_case_library`
2. 通过 Excel 导入后,能批量生成 `risk_case_library` 2. 通过 Excel 导入后,能批量生成 `risk_case_library`
3. 案例总览页能正确读取案例性质、关键字和更新统计 3. 驾驶舱页能正确读取分类、案例类型、案例来源、关键字和更新统计
4. 案例详情页能完成编辑与删除 4. 案例详情页能完成编辑与删除
--- ---
......
...@@ -154,7 +154,7 @@ ...@@ -154,7 +154,7 @@
"case_attribute": "流程绕行", "case_attribute": "流程绕行",
"case_details": "某项目在高峰采购期内,多次通过拆分订单绕过审批阈值……", "case_details": "某项目在高峰采购期内,多次通过拆分订单绕过审批阈值……",
"case_warning": "涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。", "case_warning": "涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。",
"keyword": "采购审批, 拆单", "keyword": ["采购审批", "拆单"],
"case_source": "内部审计复盘", "case_source": "内部审计复盘",
"case_level": "高", "case_level": "高",
"case_year": 2024 "case_year": 2024
...@@ -229,7 +229,7 @@ ...@@ -229,7 +229,7 @@
"case_attribute": "财务舞弊", "case_attribute": "财务舞弊",
"case_details": "财务负责人通过虚构往来科目转移资金……", "case_details": "财务负责人通过虚构往来科目转移资金……",
"case_warning": "资金支付复核应与月末异常波动分析交叉验证。", "case_warning": "资金支付复核应与月末异常波动分析交叉验证。",
"keyword": "资金管理, 异常调账", "keyword": ["资金管理", "异常调账"],
"case_source": "内部通报", "case_source": "内部通报",
"case_level": "高", "case_level": "高",
"case_year": 2025 "case_year": 2025
...@@ -242,3 +242,5 @@ ...@@ -242,3 +242,5 @@
- `title`、`content_type`、`content`、`metadata`、`subject` 这一层结构正确 - `title`、`content_type`、`content`、`metadata`、`subject` 这一层结构正确
- 案例业务字段全部放入 `metadata` - 案例业务字段全部放入 `metadata`
- `metadata.keyword` 为多值字符串数组;Excel 导入如读取到 `资金管理, 异常调账`,前端需拆分为 `["资金管理", "异常调账"]` 后保存
- Excel 导出时,`metadata.keyword` 数组需恢复为逗号拼接字符串写入 `关键字` 列
...@@ -48,7 +48,7 @@ type Record = { ...@@ -48,7 +48,7 @@ type Record = {
"case_attribute": "内部控制缺陷", "case_attribute": "内部控制缺陷",
"case_details": "项目经理通过虚构供应商合同套取资金……", "case_details": "项目经理通过虚构供应商合同套取资金……",
"case_warning": "供应商准入与合同审批需交叉复核", "case_warning": "供应商准入与合同审批需交叉复核",
"keyword": "资金挪用, 虚构合同", "keyword": ["资金挪用", "虚构合同"],
"case_source": "内部通报", "case_source": "内部通报",
"case_level": "高", "case_level": "高",
"case_year": 2025 "case_year": 2025
...@@ -64,6 +64,7 @@ type Record = { ...@@ -64,6 +64,7 @@ type Record = {
- 统一存储为字符串 - 统一存储为字符串
- Excel 导入时,空单元格需要转换为空字符串,避免 `undefined` - Excel 导入时,空单元格需要转换为空字符串,避免 `undefined`
- 若字段配置 `multiple: true`,统一存储为字符串数组;Excel 单元格中的英文逗号或中文逗号拼接值需要拆分、去空并去重
### 2.2 `Record.title` ### 2.2 `Record.title`
...@@ -78,7 +79,7 @@ type Record = { ...@@ -78,7 +79,7 @@ type Record = {
- 卡片或详情摘要使用 `case_details` - 卡片或详情摘要使用 `case_details`
- 案例性质筛选读取 `category` - 案例性质筛选读取 `category`
- 标签筛选读取 `tag` - 标签筛选读取 `tag`
- 关键字筛选读取 `keyword`,历史数据可兼容读取 `tag` - 关键字筛选读取多值数组 `keyword`,历史数据可兼容读取字符串型 `keyword` 与 `tag`
- 关键词检索优先覆盖 `label`、`keyword`、`case_details`、`case_warning` - 关键词检索优先覆盖 `label`、`keyword`、`case_details`、`case_warning`
--- ---
...@@ -100,7 +101,7 @@ type Record = { ...@@ -100,7 +101,7 @@ type Record = {
- `case_attribute` 用于补充案例属性、归属维度或风险特征 - `case_attribute` 用于补充案例属性、归属维度或风险特征
- `case_details` 为案例详情正文,通常为几百到上千字 - `case_details` 为案例详情正文,通常为几百到上千字
- `case_warning` 用于提炼关键警示信息 - `case_warning` 用于提炼关键警示信息
- `keyword` 用于检索和热度统计,可与历史 `tag` 并存 - `keyword` 用于检索和热度统计,按多值字符串数组存储,可与历史 `tag` 并存
- `case_year` 建议存储为数字类型年份,便于筛选和排序 - `case_year` 建议存储为数字类型年份,便于筛选和排序
| 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 | | 字段标签 | 字段别名 | 字段类型 | 默认值 | 是否主键 | 是否多值 | 是否必填 | 搜索 | 聚合 | 说明 |
...@@ -112,7 +113,7 @@ type Record = { ...@@ -112,7 +113,7 @@ type Record = {
| 案例属性 | `case_attribute` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例属性或归属维度 | | 案例属性 | `case_attribute` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例属性或归属维度 |
| 案例详情 | `case_details` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例详细内容正文 | | 案例详情 | `case_details` | `long_text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例详细内容正文 |
| 案例警示 | `case_warning` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例提炼出的警示或风险提示 | | 案例警示 | `case_warning` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例提炼出的警示或风险提示 |
| 关键字 | `keyword` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 检索关键字,支持人工维护 | | 关键字 | `keyword` | `text` | `null` | 否 | 是 | 否 | 否 | 否 | 检索关键字,支持人工维护;导入时逗号拼接值需拆分为数组 |
| 案例来源 | `case_source` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例来源,如监管通报、内部复盘 | | 案例来源 | `case_source` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例来源,如监管通报、内部复盘 |
| 案例等级 | `case_level` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例风险等级或分级 | | 案例等级 | `case_level` | `text` | `null` | 否 | 否 | 否 | 否 | 否 | 案例风险等级或分级 |
| 案例年份 | `case_year` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 案例发生或归档年份 | | 案例年份 | `case_year` | `number` | `null` | 否 | 否 | 否 | 否 | 否 | 案例发生或归档年份 |
...@@ -130,7 +131,7 @@ type Record = { ...@@ -130,7 +131,7 @@ type Record = {
{ "name": "case_attribute", "label": "案例属性", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} }, { "name": "case_attribute", "label": "案例属性", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_details", "label": "案例详情", "type": "long_text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} }, { "name": "case_details", "label": "案例详情", "type": "long_text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_warning", "label": "案例警示", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} }, { "name": "case_warning", "label": "案例警示", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "keyword", "label": "关键字", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} }, { "name": "keyword", "label": "关键字", "type": "text", "default": null, "multiple": true, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_source", "label": "案例来源", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} }, { "name": "case_source", "label": "案例来源", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_level", "label": "案例等级", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} }, { "name": "case_level", "label": "案例等级", "type": "text", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} },
{ "name": "case_year", "label": "案例年份", "type": "number", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} } { "name": "case_year", "label": "案例年份", "type": "number", "default": null, "multiple": false, "required": false, "primary": false, "search": { "enabled": false }, "aggregation": { "enabled": false }, "settings": {} }
...@@ -148,7 +149,7 @@ type Record = { ...@@ -148,7 +149,7 @@ type Record = {
"case_attribute": "流程绕行", "case_attribute": "流程绕行",
"case_details": "某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。", "case_details": "某项目在高峰采购期内,多次通过拆分订单绕过审批阈值,导致同一供应商在短期内累计发生异常采购金额。复盘发现,审批节点设置与例外授权规则未同步更新,最终形成流程漏洞。",
"case_warning": "涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。", "case_warning": "涉及审批阈值、例外授权和供应商集中度的场景应设置联合预警。",
"keyword": "审批, 采购, 拆单", "keyword": ["审批", "采购", "拆单"],
"case_source": "内部审计复盘", "case_source": "内部审计复盘",
"case_level": "高", "case_level": "高",
"case_year": 2024 "case_year": 2024
......
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